Scarnox · Blog

Captcha solver error handling - retries, backoff and what never to retry

A captcha solve is a network call to a system you do not control, against a challenge that can change without warning. Handling failures well is the difference between a pipeline that degrades gracefully and one that wastes time in a retry storm.

With Scarnox the economics help you: failed solves are free, so a retry costs time and nothing else. But retrying the wrong failure still wastes that time, and retrying hard enough can cause problems of its own. This guide gives you a retry policy you can paste into a Python job.

Scarnox solves PopularCaptcha today. The same error handling applies to any captcha solver, so the policy below does not depend on the captcha type.

Always check errorId first

A solve response has an errorId field. 0 means success and the token sits under solution.token. Any other value means the solve failed. A failed response also carries an errorCode and an errorDescription. Log both: the code is what you will search for later, and the description is what you will read when a run goes wrong.

{"errorId": 1, "errorCode": "...", "errorDescription": "..."}

Never assume a response is a success because the HTTP status is 200. Check errorId every time.

Failures to retry

Some failures are temporary. Retry these with exponential backoff and a small random delay:

These failures are worth a retry because the next attempt may succeed without any change on your side.

Failures to fix before retrying

Other failures will not go away by waiting. A retry with the same input will fail the same way. Fix these first:

When a failure is not on either list, treat it as a fix-first failure. Look at the error text, decide, and add the code to the right list in your code. Keep the lists short and reviewed.

A policy you can paste

import os
import random
import time

import requests

API_URL = "https://api.scarnox.com/api/tasks/create"
HEADERS = {"Authorization": f"Bearer {os.environ['SCARNOX_KEY']}"}
MAX_ATTEMPTS = 4

class SolveError(Exception):
    def __init__(self, code: str, description: str, retryable: bool):
        super().__init__(f"{code}: {description}")
        self.code = code
        self.retryable = retryable

def solve_once(payload: dict) -> str:
    try:
        r = requests.post(API_URL, headers=HEADERS, json=payload, timeout=185)
    except requests.RequestException as exc:
        # Network error or timeout on our side: safe to retry.
        raise SolveError("NETWORK", str(exc), retryable=True)

    if r.status_code == 429 or r.status_code >= 500:
        raise SolveError(f"HTTP_{r.status_code}", r.text[:200], retryable=True)

    try:
        data = r.json()
    except ValueError:
        raise SolveError(f"HTTP_{r.status_code}", r.text[:200], retryable=False)

    if data.get("errorId") == 0:
        return data["solution"]["token"]

    # The API reported a failure. Retry only codes you have seen to be transient.
    code = data.get("errorCode", "UNKNOWN")
    retryable = code in TRANSIENT_CODES
    raise SolveError(code, data.get("errorDescription", ""), retryable=retryable)

# Add API error codes here only after you have seen them to be transient in your logs.
TRANSIENT_CODES: set[str] = set()

def solve(payload: dict) -> str:
    for attempt in range(MAX_ATTEMPTS):
        try:
            return solve_once(payload)
        except SolveError as err:
            last_attempt = attempt == MAX_ATTEMPTS - 1
            if not err.retryable or last_attempt:
                raise
            delay = min(30, 2 ** attempt) + random.random()  # backoff + jitter
            print(f"attempt {attempt + 1} failed ({err.code}), retrying in {delay:.1f}s")
            time.sleep(delay)
    raise RuntimeError("unreachable")

The policy never retries a fix-first failure, and it stops after a fixed number of attempts. The TRANSIENT_CODES set starts empty on purpose: add a code only after your logs show it is temporary.

Why jitter matters

If fifty workers all fail at the same moment and all sleep exactly two seconds, they all retry together and fail together again. A random fraction of a second spreads them out. This is the same advice most large API providers give for their own clients.

Timeouts

Set your HTTP timeout well above the typical solve time, about 185 seconds. The request blocks until the token is ready, so a short client timeout can turn a slow success into a failure that you then retry for nothing. A client timeout that is longer than the solve is the safe setting.

Tokens are not cacheable

A token expires within about two minutes. Do not cache tokens between runs or reuse one for a second submission. If a submit fails after the token was used, solve again rather than resubmitting the old token. The token expiry guide covers the timing in more detail.

Concurrency and backoff across workers

If many workers share one key, the backoff in one worker does not slow the others. Add a shared limit on concurrent solves, such as a semaphore or a queue, so a burst of retries does not turn into a burst of requests. A simple concurrency cap solves most rate-limit failures before they start.

Logging that helps

For every failed attempt, log the errorCode, the attempt number, the sitekey, the page host and whether the failure was retried. Do not log the API key or a proxy password. A dashboard that counts codes per hour tells you quickly whether a problem is in your code, the proxy or the service.

Next steps

Test your handling against a free trial: create an account and start with free trial credit, no card needed.