# Errors and retries

> Which ZeroCaptcha errors a retry can fix, how long to wait, how to back off, and how an Idempotency-Key makes retrying a create safe.

Source: https://zerocaptcha.io/docs/errors-and-retries

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

| 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](https://zerocaptcha.io/docs/reference/errors) explains every code.

## 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

1. **When the reply has `Retry-After`,** wait that many seconds. `rate_limited` names the time
   until your budget has room; `queue_full` and `idempotency_key_in_use` say 2 seconds.
2. **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.
3. **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.

**curl**

```sh
# Retries a request on 429, 5xx or no answer, waiting as Retry-After says, else 1, 2, 4, 8, 16 s.
attempt=0
while :; 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 ))
done
cat reply.json
```

**Node**

```js
const 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));
  }
}
```

**Python**

```python
import random
import 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
```

**Go**

```go
// 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

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`](https://zerocaptcha.io/docs/reference/errors#idempotency_key_reused) (HTTP 422), or
  `ERROR_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`](https://zerocaptcha.io/docs/reference/errors#idempotency_key_in_use) (HTTP 409,
  `Retry-After: 2`), or `ERROR_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

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`](https://zerocaptcha.io/docs/reference/errors#ERROR_TOKEN_EXPIRED)): use tokens as soon as they
are ready.
