This guide builds a CaptchaAI client as an Effect service in which every API outcome is a tagged error. CAPCHA_NOT_READY is retried on a 5-second Schedule under a 120-second budget, server errors back off exponentially, and a rejected key or a full plan stops the run instead of being retried. The constraint behind the design is your plan's thread count: BASIC ($15/month, 5 threads) allows five tasks in flight, so the service owns a semaphore that every caller shares.
You finish with a Playwright batch that solves and submits reCAPTCHA v2 forms, tests that run the polling loop on a virtual clock, and OpenTelemetry spans that never contain your key. Use it on forms you own or are authorized to test, such as staging sign-up pages. If you are not using Effect, the plain TypeScript version is in Type-Safe CaptchaAI Client with TypeScript Generics.
How the CaptchaAI contract maps onto Effect
CaptchaAI's submit-and-poll API is two form endpoints on https://ocr.captchaai.com. in.php accepts a task and returns its ID; res.php with action=get returns the answer once it is ready. With json=1, both reply {"status":0|1,"request":...}, where request holds the task ID, the answer or an error code. The rest of this article translates that contract:
| CaptchaAI behavior | Effect construct |
|---|---|
status / request JSON body |
Schema.Struct decoded with Schema.parseJson |
CAPCHA_NOT_READY: poll again in 5 s |
NotReady error retried on Schedule.spaced("5 seconds") |
| No documented overall limit, so you pick one | Effect.timeoutFail producing Timeout |
ERROR_SERVER_ERROR, ERROR_INTERNAL_SERVER_ERROR: retry after about 10 s |
Schedule.exponential("10 seconds"), jittered, at most 3 retries |
| Key and plan errors: stop sending | InvalidKey, NoFreeThreads, never retried |
| Plan threads = tasks in flight | Effect.makeSemaphore(threads) |
| The API key | Config.redacted |
| Token used in the session that asked for it | Effect.acquireRelease inside Effect.scoped |
Project setup and pinned versions
mkdir captcha-pipeline && cd captcha-pipeline
npm init -y
npm pkg set type=module
npm install [email protected] @effect/[email protected] @effect/[email protected] \
@effect/[email protected] @opentelemetry/[email protected] \
@opentelemetry/[email protected] @opentelemetry/[email protected] \
[email protected]
npm install -D [email protected] [email protected] @types/node@22
npx playwright install chromium
mkdir src
Pin these. The HTTP client in @effect/platform is still documented as an unstable module, Effect.Service carries an experimental tag in its type declarations, and Effect 4 is published on npm under the rc tag. Everything below was type-checked with strict and run on Node.js 22.18 against exactly these versions:
| Package | Version | Used for |
|---|---|---|
effect |
3.22.2 | Service, Schema, Schedule, Config, semaphore, TestClock |
@effect/platform |
0.97.2 | HttpClient, HttpClientRequest |
@effect/platform-node |
0.108.2 | NodeHttpClient.layerUndici, NodeRuntime.runMain |
@effect/opentelemetry |
0.64.1 | NodeSdk.layer |
@opentelemetry/sdk-trace-base, sdk-trace-node |
2.11.0 | BatchSpanProcessor; NodeSdk builds its tracer provider from sdk-trace-node |
@opentelemetry/exporter-trace-otlp-http |
0.222.0 | OTLP exporter |
playwright |
1.63.0 | the browser session |
typescript / tsx / @types/node |
5.9.3 / 4.23.15 / 22.x | type-check and run .ts directly |
The Playwright callback in main.ts runs inside the page, so tsconfig.json needs the DOM types as well:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
The error model: one class per decision
Group errors by what your code must do next, not by which endpoint returned them. Save this as src/errors.ts:
import { Data } from "effect"
// res.php said CAPCHA_NOT_READY: the only failure the poll loop retries.
export class NotReady extends Data.TaggedError("NotReady")<{ readonly taskId: string }> {}
// Wrong or unknown key (or the IP ban that repeated bad keys cause). Stop the batch.
export class InvalidKey extends Data.TaggedError("InvalidKey")<{ readonly code: string }> {}
// ERROR_ZERO_BALANCE: no free thread on the plan, or no active plan. Never retried blindly.
export class NoFreeThreads extends Data.TaggedError("NoFreeThreads")<{ readonly code: string }> {}
// The request itself is wrong. Fix it; resending it unchanged cannot succeed.
export class BadRequest extends Data.TaggedError("BadRequest")<{ readonly code: string }> {}
// Server-side hiccup, HTTP failure or an HTML error page. Retried with backoff.
export class Transient extends Data.TaggedError("Transient")<{ readonly code: string }> {}
// ERROR_CAPTCHA_UNSOLVABLE: stop polling this task ID.
export class Unsolvable extends Data.TaggedError("Unsolvable")<{ readonly taskId: string }> {}
// Our own 120-second budget ran out. Not an API code.
export class Timeout extends Data.TaggedError("Timeout")<{ readonly taskId: string }> {}
export type ApiError = NotReady | InvalidKey | NoFreeThreads | BadRequest | Transient | Unsolvable
// One place that turns the API's `request` string into a typed failure.
export const fromApi = (code: string, taskId = ""): ApiError => {
switch (code) {
case "CAPCHA_NOT_READY":
return new NotReady({ taskId })
case "ERROR_CAPTCHA_UNSOLVABLE":
return new Unsolvable({ taskId })
case "ERROR_WRONG_USER_KEY":
case "ERROR_KEY_DOES_NOT_EXIST":
case "IP_BANNED":
return new InvalidKey({ code })
case "ERROR_ZERO_BALANCE":
return new NoFreeThreads({ code })
case "ERROR_SERVER_ERROR":
case "ERROR_INTERNAL_SERVER_ERROR":
return new Transient({ code })
default:
// ERROR_PAGEURL, ERROR_BAD_PARAMETERS, ERROR_WRONG_GOOGLEKEY,
// ERROR_BAD_TOKEN_OR_PAGEURL, ERROR_WRONG_CAPTCHA_ID and anything unknown
return new BadRequest({ code })
}
}
Four of these choices are deliberate:
ERROR_ZERO_BALANCEbecomesNoFreeThreads. CaptchaAI bills per concurrent thread, and this code means every thread on the plan is busy or the account has no active plan. Resubmitting the same task in a tight loop fixes neither.IP_BANNEDjoinsInvalidKeybecause it follows repeated wrong-key requests. The ban lifts after 5 minutes, and sending the same key again only earns another one.- The
defaultbranch fails closed. Any code you have not planned for becomesBadRequest, which nothing retries. Retrying unknown codes is a classic source of retry storms. Unsolvableends polling for that task ID. A fresh submit with freshly read page parameters is acceptable, but keep it to one or two attempts.
The CaptchaAI API error handling decision tree walks through the remaining codes one by one.
The service: submit, poll, retry
src/CaptchaAI.ts holds the whole API contract. Task is a discriminated union, so a Turnstile task without a sitekey does not compile:
import { HttpClient, HttpClientRequest } from "@effect/platform"
import { NodeHttpClient } from "@effect/platform-node"
import { Config, Effect, Either, Redacted, Schedule, Schema } from "effect"
import { type ApiError, fromApi, Timeout, Transient } from "./errors.js"
export type Task =
| { readonly method: "userrecaptcha"; readonly googlekey: string; readonly pageurl: string }
| { readonly method: "turnstile"; readonly sitekey: string; readonly pageurl: string }
| { readonly method: "base64"; readonly body: string } // image captcha, base64 data
// in.php and res.php both answer {"status":0|1,"request":...} when json=1 is sent.
// A task ID can come back as a JSON number, so accept both and keep it a string.
const ApiResponse = Schema.Struct({
status: Schema.Number,
request: Schema.Union(Schema.String, Schema.Number)
})
const decode = Schema.decodeUnknownEither(Schema.parseJson(ApiResponse))
const PLAIN_CODE = /^(?:ERROR_[A-Z_]+|IP_BANNED|CAPCHA_NOT_READY)$/
// Server errors: about 10 s, doubling, jittered, at most 3 retries.
const transientRetry = Schedule.exponential("10 seconds").pipe(
Schedule.jittered,
Schedule.intersect(Schedule.recurs(3))
)
export class CaptchaAI extends Effect.Service<CaptchaAI>()("CaptchaAI", {
accessors: true,
effect: Effect.gen(function* () {
const apiKey = yield* Config.redacted("CAPTCHAAI_API_KEY")
const threads = yield* Config.integer("CAPTCHAAI_THREADS").pipe(Config.withDefault(5))
const permits = yield* Effect.makeSemaphore(threads)
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.filterStatusOk,
HttpClient.withTracerPropagation(false)
)
// POST for both endpoints: the key travels in the body, never in a URL
// that a span attribute or an access log could record.
const call = (path: "in.php" | "res.php", params: Record<string, string>, taskId = "") =>
HttpClientRequest.post(`https://ocr.captchaai.com/${path}`).pipe(
HttpClientRequest.bodyUrlParams({ ...params, key: Redacted.value(apiKey), json: "1" }),
client.execute,
Effect.flatMap((response) => response.text),
Effect.mapError((e) => new Transient({ code: `${e._tag}:${e.reason}` })),
Effect.flatMap((text): Effect.Effect<string, ApiError> => {
const body = text.trim()
if (PLAIN_CODE.test(body)) return Effect.fail(fromApi(body, taskId)) // plain text despite json=1
return Either.match(decode(body), {
onLeft: () => Effect.fail(new Transient({ code: "UNPARSEABLE_BODY" })), // neither JSON nor a known code
onRight: ({ status, request }) =>
status === 1 ? Effect.succeed(String(request)) : Effect.fail(fromApi(String(request), taskId))
})
}),
Effect.retry({ schedule: transientRetry, while: (e) => e._tag === "Transient" })
)
const solve = (task: Task) =>
Effect.gen(function* () {
const taskId = yield* call("in.php", task)
yield* Effect.annotateCurrentSpan("captcha.task_id", taskId)
const token = yield* Effect.sleep(task.method === "base64" ? "5 seconds" : "15 seconds").pipe(
Effect.zipRight(
call("res.php", { action: "get", id: taskId }, taskId).pipe(
Effect.retry({ schedule: Schedule.spaced("5 seconds"), while: (e) => e._tag === "NotReady" })
)
),
Effect.timeoutFail({ duration: "120 seconds", onTimeout: () => new Timeout({ taskId }) })
)
return { taskId, token }
}).pipe(
// spaced() never ends on its own, so NotReady cannot escape the loop above;
// this line only removes it from the error type.
Effect.catchTag("NotReady", (e) => Effect.fail(new Timeout({ taskId: e.taskId }))),
permits.withPermits(1), // one plan thread per in-flight task, across every caller
Effect.withSpan("captchaai.solve", { attributes: { "captcha.method": task.method } })
)
return { solve } as const
}),
dependencies: [NodeHttpClient.layerUndici]
}) {}
Read the body as text, then decode
HttpClientResponse.schemaBodyJson is the usual way to decode a response with @effect/platform, but it fails on anything that is not JSON, and CaptchaAI occasionally sends exactly that. Even with json=1, some errors arrive as a bare string such as ERROR_UPLOAD, and in rare cases the server answers with an HTML 500 or 502 page. So call reads the text first. A non-2xx status already fails in filterStatusOk and becomes Transient; after that, a bare code becomes its typed error, any other body that is not valid JSON becomes Transient, and only a real JSON body reaches the schema. The Schema.Union(Schema.String, Schema.Number) exists because one documented example returns the task ID as a JSON number; callers always receive a string.
Two schedules, two predicates
The while option of Effect.retry keeps the two policies apart (Effect's retrying guide covers the options). The poll retries only NotReady, every 5 seconds, with no count of its own. The surrounding timeoutFail ends the loop 120 seconds after the task was accepted, and that budget includes the first wait: 15 seconds for token types and 5 for images, in line with the per-type guidance in CaptchaAI's docs. To tune either number, see the polling and timeout strategy guide.
The transient policy sits inside call, so submits and polls both get it. The delays are roughly 10, 20 and 40 seconds, since Schedule.jittered scales each one by a random factor between 0.8 and 1.2. After the third retry the Transient error escapes. InvalidKey, NoFreeThreads and BadRequest fail the while test on the first attempt, so the retry policy never resends a rejected key or a malformed task.
The catchTag("NotReady", ...) line exists for the type checker. Effect.retry never narrows the error type, so without it NotReady would stay in solve's signature even though the timeout is the only way out of the loop.
Keeping the key out of logs and traces
Config.redacted returns a Redacted<string>. Printing it with Effect.log, String() or JSON.stringify produces <redacted>, and Redacted.value unwraps it only inside bodyUrlParams. The HTTP method matters too. In the pinned @effect/platform version, every client span records the full URL (url.full, plus url.query when there is one), so a GET to res.php with key in the query string would export your key to your tracing backend. res.php accepts POST as well as GET, and the client does not record request bodies, so both calls POST a form. withTracerPropagation(false) also stops the client from adding trace headers to requests for a third-party host. In a run against a local mock of both endpoints, no exported span contained the key and no traceparent header reached the mock.
The semaphore belongs to the service
permits.withPermits(1) wraps the whole task, from submit to answer, because that is how long it occupies a plan thread. Every fiber, route handler or batch that uses the same CaptchaAI layer draws from the same permits. Waiting for a permit happens before the 120-second budget starts, so a busy queue never turns into false timeouts.
The permit goes back the moment solve ends, Timeout included. No res.php action cancels a task, though, so a task you stopped waiting for may keep its plan thread busy on CaptchaAI's side for a while after your semaphore has handed the permit to the next form. If timeouts are frequent, set CAPTCHAAI_THREADS one or two below the plan's count.
Set CAPTCHAAI_THREADS to your plan's thread count; the default of 5 matches BASIC. Oversubscribing does not always surface as ERROR_ZERO_BALANCE. The source of CaptchaAI's official Python SDK notes that the API may queue tasks when every thread is busy. You would see that as longer CAPCHA_NOT_READY polling and, eventually, Timeout. The semaphore is per process, so divide the thread count between processes or size each one from the threadsinfo action, as described in Hitting your CaptchaAI thread limit.
Testing the polling loop in virtual time
CaptchaAI.DefaultWithoutDependencies builds the real service without the Node HTTP client, so a test can supply an HttpClient that replays canned bodies. Save as src/CaptchaAI.test.ts:
import { HttpClient, HttpClientResponse } from "@effect/platform"
import { ConfigProvider, Effect, Fiber, Layer, TestClock, TestContext } from "effect"
import assert from "node:assert/strict"
import { test } from "node:test"
import { CaptchaAI, type Task } from "./CaptchaAI.js"
import { Unsolvable } from "./errors.js"
const task: Task = { method: "userrecaptcha", googlekey: "test-sitekey", pageurl: "https://staging.test/signup" }
// Replays canned in.php/res.php bodies in order, so the real parsing and retry code runs.
const run = (bodies: Array<string>) => {
const sent: Array<string> = []
const http = HttpClient.make((request) =>
Effect.sync(() => {
sent.push(request.url)
return HttpClientResponse.fromWeb(request, new Response(bodies.shift() ?? "CAPCHA_NOT_READY"))
})
)
const program = Effect.gen(function* () {
const fiber = yield* Effect.fork(Effect.either(CaptchaAI.solve(task)))
yield* TestClock.adjust("5 minutes") // virtual time: the 15 s wait and 5 s polls take no real time
return yield* Fiber.join(fiber)
})
return program.pipe(
Effect.provide(CaptchaAI.DefaultWithoutDependencies),
Effect.provide(Layer.succeed(HttpClient.HttpClient, http)),
Effect.withConfigProvider(ConfigProvider.fromMap(new Map([["CAPTCHAAI_API_KEY", "test-key"]]))),
Effect.provide(TestContext.TestContext),
Effect.runPromise
).then((result) => ({ result, sent }))
}
test("polls through CAPCHA_NOT_READY until the token arrives", async () => {
const { result, sent } = await run([
'{"status":1,"request":"73512908114"}',
'{"status":0,"request":"CAPCHA_NOT_READY"}',
'{"status":0,"request":"CAPCHA_NOT_READY"}',
'{"status":1,"request":"03AGdBq24PBCbwiDRaS_MJ7Z..."}'
])
assert.equal(result._tag, "Right")
assert.equal(sent.length, 4)
})
test("a rejected key fails once and is never retried", async () => {
const { result, sent } = await run(['{"status":0,"request":"ERROR_WRONG_USER_KEY"}'])
assert.equal(result._tag === "Left" && result.left._tag, "InvalidKey")
assert.equal(sent.length, 1)
})
test("a plain-text error body is still typed", async () => {
const { result } = await run(["ERROR_ZERO_BALANCE"])
assert.equal(result._tag === "Left" && result.left._tag, "NoFreeThreads")
})
test("gives up with Timeout after the 120 s budget", async () => {
const { result } = await run(['{"status":1,"request":"73512908114"}'])
assert.equal(result._tag === "Left" && result.left._tag, "Timeout")
})
// Code that only consumes CaptchaAI (main.ts, route handlers) can skip HTTP entirely.
const unsolvable = Layer.succeed(CaptchaAI, new CaptchaAI({ solve: () => Effect.fail(new Unsolvable({ taskId: "1" })) }))
test("consumers can be tested against a canned outcome", async () => {
const result = await CaptchaAI.solve(task).pipe(Effect.either, Effect.provide(unsolvable), Effect.runPromise)
assert.equal(result._tag === "Left" && result.left._tag, "Unsolvable")
})
TestContext.TestContext swaps in a TestClock, and time moves only when the test calls TestClock.adjust. The 15-second wait, every 5-second poll and the full 120-second timeout therefore finish in about a second of real time. The last test follows the pattern in Effect's Managing Layers guide: construct the service class with a canned solve, and code that only consumes CaptchaAI never touches HTTP.
npx tsc # type-check first: tsx strips types without checking them
npx tsx --test src/CaptchaAI.test.ts
Solving and submitting forms in one scoped session
src/main.ts opens each form, reads its sitekey, solves it and submits it, five forms at a time:
import { NodeRuntime } from "@effect/platform-node"
import { Data, Effect } from "effect"
import { type Browser, chromium } from "playwright"
import { CaptchaAI } from "./CaptchaAI.js"
import { TracingLive } from "./tracing.js"
// Log, and make the process exit non-zero so CI marks the run failed.
const stop = (message: string) => Effect.logError(message).pipe(Effect.andThen(() => { process.exitCode = 1 }))
class BrowserError extends Data.TaggedError("BrowserError")<{ readonly cause: unknown }> {
get reason() {
return String(this.cause).split("\n")[0] // Playwright errors carry a multi-line call log
}
}
const step = <A>(f: () => Promise<A>) => Effect.tryPromise({ try: f, catch: (cause) => new BrowserError({ cause }) })
// A fresh browser context per form: solved for, and submitted from, the same session.
const submitForm = (browser: Browser, pageurl: string) =>
Effect.gen(function* () {
const context = yield* Effect.acquireRelease(step(() => browser.newContext()), (c) => Effect.promise(() => c.close()))
const page = yield* step(() => context.newPage())
yield* step(() => page.goto(pageurl))
const googlekey = yield* step(() => page.locator("[data-sitekey]").first().getAttribute("data-sitekey", { timeout: 10_000 }))
if (!googlekey) return yield* new BrowserError({ cause: "no data-sitekey on the page" })
const { taskId, token } = yield* CaptchaAI.solve({ method: "userrecaptcha", googlekey, pageurl })
yield* step(() =>
page.locator('textarea[name="g-recaptcha-response"]').evaluate((el, t) => {
;(el as HTMLTextAreaElement).value = t
}, token)
)
yield* step(() => page.locator('form [type="submit"]').click())
return `ok ${pageurl} (task ${taskId})`
}).pipe(Effect.scoped) // the context closes here, on success, failure or interruption
const program: Effect.Effect<void, never, CaptchaAI> = Effect.gen(function* () {
const pages = process.argv.slice(2) // your own staging forms
const browser = yield* Effect.acquireRelease(step(() => chromium.launch()), (b) => Effect.promise(() => b.close()))
const lines = yield* Effect.forEach(
pages,
(url) =>
submitForm(browser, url).pipe(
// Per-form failures become report lines. InvalidKey and NoFreeThreads are not caught
// here, so the first one fails the forEach and interrupts every sibling fiber.
Effect.catchTags({
BadRequest: (e) => Effect.succeed(`fix ${url}: ${e.code}`),
Transient: (e) => Effect.succeed(`retry ${url}: ${e.code} after 3 retries`),
Unsolvable: (e) => Effect.succeed(`skipped ${url}: task ${e.taskId} unsolvable`),
Timeout: (e) => Effect.succeed(`timeout ${url}: task ${e.taskId} over 120 s`),
BrowserError: (e) => Effect.succeed(`page ${url}: ${e.reason}`)
})
),
{ concurrency: 5 }
)
yield* Effect.forEach(lines, (line) => Effect.log(line))
}).pipe(
Effect.scoped, // closes the browser
Effect.catchTags({
InvalidKey: (e) => stop(`batch stopped: key rejected (${e.code})`),
NoFreeThreads: () => stop("batch stopped: no free thread or no active plan"),
BrowserError: (e) => stop(`browser failed to start: ${e.reason}`)
})
)
program.pipe(Effect.provide(CaptchaAI.Default), Effect.provide(TracingLive), NodeRuntime.runMain)
What the scopes and handlers buy you:
- Nested lifetimes. The browser is acquired once for the batch, and each form gets its own browser context inside
Effect.scoped. Finalizers close them whether the job succeeded, failed or was interrupted.NodeRuntime.runMaininterrupts the main fiber on SIGINT or SIGTERM, so pressing Ctrl+C mid-batch still closes every Chromium process. - Same-session handoff. The token goes into the
g-recaptcha-responsetextarea of the page that supplied the sitekey, and the form is submitted straight after the solve. For invisible widgets, callbacks and other CAPTCHA types, see the token injection methods reference. - What
okproves. It means solved and submitted, not accepted. Leaving the scope closes the context as soon asclick()returns, and Playwright'sclick()waits for a navigation the click starts but not for afetchor XHR request. If your form submits by script, or you want to assert acceptance, wait for the response insidesubmitForm(for example withpage.waitForResponse) before returning. - Fatal versus per-form errors. The inner
catchTagsturns per-form failures into report lines.InvalidKeyandNoFreeThreadsstay in the error channel on purpose: the first one failsEffect.forEach, which interrupts the other fibers, and the outercatchTagsreports it once. At most the forms already in flight (five here) will have sent the key by then.Effect.partition(pages, submitForm, { concurrency: 5 })looks tidier, but it collects every failure, so a rejected key would be sent once for every page in the list, which is the repeated bad-auth pattern behindIP_BANNED. - Exhaustiveness at compile time. The
Effect.Effect<void, never, CaptchaAI>annotation makestscreject the file while any error tag is unhandled: remove thecatchTaginsolveandtscreportsType 'NotReady' is not assignable to type 'never'on this line.tsxruns the file without type-checking, so putnpx tscin CI, or the guarantee never runs.
concurrency: 5 limits open browser contexts; the service's semaphore limits CaptchaAI tasks. On STANDARD ($30/month, 15 threads), raise both.
export CAPTCHAAI_API_KEY="YOUR_API_KEY" # 32 characters, from https://captchaai.com/api.php
export CAPTCHAAI_THREADS=5 # your plan's thread count
npx tsx src/main.ts https://staging.example.com/signup https://staging.example.com/newsletter \
https://staging.example.com/contact https://staging.example.com/quote https://staging.example.com/about
Lines print after the whole batch finishes, in input order. This run shows one line of each kind; it was captured against a local mock of in.php and res.php, with the staging URLs substituted:
[10:42:31.209] INFO (#1): ok https://staging.example.com/signup (task 73512908111)
[10:42:31.212] INFO (#1): fix https://staging.example.com/newsletter: ERROR_WRONG_GOOGLEKEY
[10:42:31.213] INFO (#1): skipped https://staging.example.com/contact: task 73512908110 unsolvable
[10:42:31.214] INFO (#1): retry https://staging.example.com/quote: ERROR_SERVER_ERROR after 3 retries
[10:42:31.214] INFO (#1): page https://staging.example.com/about: TimeoutError: locator.getAttribute: Timeout 10000ms exceeded.
A rejected key ends the batch with a single line and exit code 1:
[10:45:02.741] ERROR (#1): batch stopped: key rejected (ERROR_KEY_DOES_NOT_EXIST)
Tracing each solve
src/tracing.ts exports spans over OTLP/HTTP:
import { NodeSdk } from "@effect/opentelemetry"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
// Sends spans to an OTLP/HTTP collector (http://localhost:4318/v1/traces unless
// OTEL_EXPORTER_OTLP_ENDPOINT says otherwise).
export const TracingLive = NodeSdk.layer(() => ({
resource: { serviceName: "captcha-pipeline" },
spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter())
}))
Each task becomes a captchaai.solve span with captcha.method and the captcha.task_id added by Effect.annotateCurrentSpan, plus one http.client POST child per request. Because withSpan wraps withPermits, time spent waiting for a thread shows up as a gap before the first child. Transient retries show up as extra children with the backoff between them. The layer follows Effect's tracing guide; for which span attributes are worth alerting on, see OpenTelemetry tracing for CAPTCHA solving pipelines.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
tsc: Type 'NotReady' is not assignable to type 'never' |
A code path can still fail with NotReady, often because the catchTag in solve was removed |
Keep the catchTag, or handle the tag where the compiler points |
Error: (Missing data at CAPTCHAAI_API_KEY: "<redacted>") and exit code 1 |
The variable is not set in this shell or CI step. The layer reads it at startup, before the browser launches | Export it, or inject it from your CI secret store |
Most tasks end in Timeout while other work runs |
More tasks in flight than plan threads: CAPTCHAAI_THREADS set too high, or several processes sharing one plan |
Match CAPTCHAAI_THREADS to the plan and divide it between processes |
Cannot find name 'HTMLTextAreaElement' |
lib in tsconfig.json lacks DOM |
Add "DOM" to lib |
| The form rejects a token the API returned | The token was submitted from another context, or held back before use | Inject and submit inside the same scoped context right after solve |
FAQ
Does this work for reCAPTCHA Enterprise or Cloudflare Challenge?
Yes, with two changes. First, add the task shapes to the Task union: reCAPTCHA v2 Enterprise is userrecaptcha plus enterprise: "1", v3 Enterprise adds version: "v3" and should always carry the page's action, and Cloudflare Challenge is method: "cloudflare_challenge" with pageurl, proxy and proxytype. Second, widen the schema. These three types answer {"status":1,"result":"...","user_agent":"..."} with no request field, so the ApiResponse above would reject a successful solve as UNPARSEABLE_BODY. Make request, result and user_agent all optional, take the answer from result when it is present, and return user_agent from solve.
Your browser context must then reuse that User-Agent. For Cloudflare Challenge, result is the cf_clearance cookie value, which is bound to both the User-Agent and the proxy IP, so route the context through the same proxy. CaptchaAI's proxy guide notes that proxy use is disabled on accounts by default, so confirm it is enabled for yours first.
How many threads does a batch need?
One per task you want in flight. Each solve holds a thread for the initial wait plus polling, and anything beyond the thread count waits on the semaphore instead of failing. BASIC ($15/month, 5 threads) runs five forms at a time; a larger batch finishes sooner on a plan with more threads, not by raising concurrency alone.
Run it on your plan's thread count
Create a CaptchaAI API key, set CAPTCHAAI_THREADS to your plan's thread count, and run the batch against one staging form before you point it at a list. Thread counts for every plan are on the CaptchaAI pricing page.