Errors and retries
More
Most errors say something is wrong with the request, the key or the balance, and sending the same request again fails the same way. A few say only “not now”. Retry those, and nothing else, and your integration neither gives up too early nor hammers the API.
How an error looks
Section titled “How an error looks”| Format | A failure is |
|---|---|
| REST | A 4xx or 5xx status with an RFC 9457 problem document (application/problem+json): branch on its code |
| createTask | HTTP 200 with errorId: 1, errorCode and errorDescription |
| 2Captcha | HTTP 200 with the code in place of the result, such as ERROR_ZERO_BALANCE |
A request can also fail before it reaches the API’s own format, at a proxy, on a body that is too
large, or on a server timeout. That comes back as a 4xx or 5xx status, so check the HTTP status as
well as the body in every format. Every reply carries X-Request-Id; quote it when you write to
support. The errors reference explains every code.
What to do about each code
Section titled “What to do about each code”| Policy | What your code does | Codes |
|---|---|---|
retry | Retry the same request with exponential backoff (a create with the same Idempotency-Key); honour Retry-After when present. | internal_error, service_unavailable, request_timeout, payments_unavailable, ERROR_SERVICE_UNAVAILABLE |
wait | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | idempotency_key_in_use, queue_full, rate_limited, ERROR_NO_SLOT_AVAILABLE, ERROR_RATE_LIMIT, ERROR_IDEMPOTENCY_KEY_IN_USE, MAX_USER_TURN, ERROR: 1005 |
fix | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | bad_request, not_found, method_not_allowed, payload_too_large, unauthorized, invalid_credentials, key_revoked, key_limit_reached, key_state_conflict, csrf_rejected, insufficient_scope, ip_not_allowed, insufficient_funds, validation_failed, idempotency_key_reused, reauthentication_required, link_invalid, link_expired, link_used, weak_password, state_conflict, role_required, spend_cap_reached, email_taken, email_unverified, ERROR_TASK_ABSENT, ERROR_TASK_NOT_SUPPORTED, ERROR_INVALID_TASK_DATA, ERROR_KEY_DOES_NOT_EXIST, ERROR_KEY_REVOKED, ERROR_IP_NOT_ALLOWED, ERROR_ACCESS_DENIED, ERROR_ZERO_BALANCE, ERROR_IDEMPOTENCY_KEY_REUSED, ERROR_NO_SUCH_CAPCHA_ID, ERROR_REPORT_NOT_RECORDED, ERROR_DUPLICATE_REPORT, ERROR_INVALID_REQUEST, ERROR_SPEND_CAP_REACHED, ERROR_PROXY_NOT_ALLOWED, ERROR_WRONG_USER_KEY, ERROR_PAGEURL, ERROR_BAD_PARAMETERS, ERROR_PROXY_FORMAT, ERROR_EMPTY_ACTION, ERROR_WRONG_ID_FORMAT, ERROR_WRONG_CAPTCHA_ID, ERROR_BAD_PROXY |
new-task | The task is over and nothing more will come of it: create a new task if you still need a token. | ERROR_TOKEN_EXPIRED, ERROR_CAPTCHA_UNSOLVABLE, ERROR_TASK_TIMEOUT |
stop | Stop: do not send it again. Tell the user; a person must act (support, or not using this site). | account_suspended, domain_blocked, ERROR_ACCOUNT_SUSPENDED, ERROR_DOMAIN_BLOCKED, ERROR_ACCOUNT_DELETED |
In short: retry 429, 5xx, a lost connection or a timeout, and 409
idempotency_key_in_use. Everything else needs a change first, or a new task.
A task that failed or expired is final: reading it again will not change it. Create a new task if you still need a token; the failed one cost nothing.
How long to wait
Section titled “How long to wait”- When the reply has
Retry-After, wait that many seconds.rate_limitednames the time until your budget has room;queue_fullandidempotency_key_in_usesay 2 seconds. - Otherwise, back off: wait 1 second, then 2, 4, 8, and 16 seconds at most, each multiplied by a random factor between 0.5 and 1, so many clients that failed together do not retry together.
- Give up at a deadline you choose for the whole operation, such as 3 minutes for one solve, and give each request its own timeout, such as 15 seconds.
# Retries a request on 429, 5xx or no answer, waiting as Retry-After says, else 1, 2, 4, 8, 16 s.attempt=0while :; do status=$(curl -s -o reply.json -D headers.txt -w '%{http_code}' --max-time 15 \ "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ZEROCAPTCHA_KEY") || status=000 case $status in 2??) break ;; 429 | 5?? | 000) ;; *) cat reply.json; exit 1 ;; esac wait=$(tr -d '\r' < headers.txt | awk 'tolower($1) == "retry-after:" { print $2 }') sleep "${wait:-$(( attempt < 4 ? 1 << attempt : 16 ))}" attempt=$(( attempt + 1 ))donecat reply.jsonconst RETRYABLE = new Set([429, 500, 502, 503, 504]);
/** Sends a request until it succeeds, a retry cannot help, or the deadline passes. */export async function withRetries(send, deadline = Date.now() + 180_000) { for (let attempt = 0; ; attempt += 1) { let response; try { response = await send(); } catch (error) { if (Date.now() >= deadline) throw error; } if (response?.ok) return response; const body = response ? await response.clone().json().catch(() => ({})) : {}; const again = response === undefined || RETRYABLE.has(response.status) || (response.status === 409 && body.code === "idempotency_key_in_use"); if (!again) throw new Error(`${body.code ?? `HTTP ${response.status}`}: ${body.detail ?? ""}`); const retryAfter = Number(response?.headers.get("retry-after")); const wait = Number.isInteger(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 16_000) * (0.5 + Math.random() / 2); if (Date.now() + wait > deadline) throw new Error("gave up: the deadline passed"); await new Promise((resolve) => setTimeout(resolve, wait)); }}import randomimport time
import requests
RETRYABLE = {429, 500, 502, 503, 504}
def with_retries(send, deadline_seconds=180): """Sends a request until it succeeds, a retry cannot help, or the deadline passes.""" deadline = time.monotonic() + deadline_seconds attempt = 0 while True: try: response = send() except (requests.ConnectionError, requests.Timeout): response = None if response is not None and response.ok: return response code = None if response is not None: try: code = response.json().get("code") except ValueError: pass if not (response.status_code in RETRYABLE or (response.status_code == 409 and code == "idempotency_key_in_use")): raise RuntimeError(f"{code or response.status_code}: {response.text}") retry_after = response.headers.get("Retry-After", "") if response is not None else "" wait = int(retry_after) if retry_after.isdigit() else min(2**attempt, 16) * random.uniform(0.5, 1) if time.monotonic() + wait > deadline: raise TimeoutError("gave up: the deadline passed") time.sleep(wait) attempt += 1// withRetries sends a request until it succeeds, a retry cannot help, or ctx ends. newRequest// must build a fresh request each time, with the same Idempotency-Key for a create.func withRetries(ctx context.Context, newRequest func() (*http.Request, error)) (*http.Response, error) { for attempt := 0; ; attempt++ { req, err := newRequest() if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req.WithContext(ctx)) wait := time.Duration(0) if err == nil { if resp.StatusCode < 300 { return resp, nil } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var problem struct{ Code string } _ = json.Unmarshal(body, &problem) retryable := resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode == 409 && problem.Code == "idempotency_key_in_use") if !retryable { return nil, fmt.Errorf("%s: HTTP %d: %s", problem.Code, resp.StatusCode, body) } if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(seconds) * time.Second } } if wait == 0 { backoff := time.Duration(1<<min(attempt, 4)) * time.Second wait = backoff/2 + time.Duration(rand.Int64N(int64(backoff/2))) } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(wait): } }}Idempotency
Section titled “Idempotency”Retrying a create is safe only if the retry cannot make a second task. Send an Idempotency-Key
header with every create: POST /v1/tasks, POST /createTask or in.php.
- The value is yours: 1 to 255 visible ASCII characters, such as a UUID. Make a new one for each task you mean to create, before the first attempt, and send the same one on every retry of that create.
- For 24 hours from the first request, the same key with the same request returns the first
reply, with the same task, instead of creating another. REST marks such a reply with
Idempotent-Replayed: true. - The same key with a different request is refused with
idempotency_key_reused(HTTP 422), orERROR_IDEMPOTENCY_KEY_REUSED. Reuse a key only to retry exactly the same task. - While the first request is still being served, a retry gets
idempotency_key_in_use(HTTP 409,Retry-After: 2), orERROR_IDEMPOTENCY_KEY_IN_USE. Wait and send it again: you then get the first request’s reply. - To find the task a lost reply created, list your tasks with the key as a filter:
GET /v1/tasks?idempotencyKey=…. The list holds that task, or nothing if the first request never arrived.
Reads (GET /v1/tasks/{id}, getTaskResult, getBalance) change nothing, so they need no key and
can always be retried.
Refusals cost nothing
Section titled “Refusals cost nothing”A refused request is never charged, whatever its code. A task that fails or expires is released
in full. Only a task that succeeds is charged, even if you read it after its token expired
(ERROR_TOKEN_EXPIRED): use tokens as soon as they
are ready.