Evomi

Blog / Scraping Techniques

Scraper API Retry Handling for Production Pipelines

Satyam TripathiSatyam Tripathi20 min read
A small round robot in a sunny red-brick yard pushes a glass canister of crumpled pages back into a brass pneumatic-tube terminal under a large API plate, beside a RETRY signboard, while a brass pipeline feeds a conveyor belt carrying finished pages into a wire basket.

Scraper API retry handling is the code that decides whether your pipeline resends, changes, delays, or abandons a failed scraping call. The API already retries some failures itself, so your retries add a second layer.

A client with a 5-second timeout asked a scraper API 3 times for a page that takes 12 seconds to fetch. The client got no page, but the server finished all 3 fetches.

This guide shows which failures to resend or change, and how to build a service that collects results instead of paying again for them.

TL;DR

  • Read 2 status codes before any retry. The API's HTTP status describes your call, and status_code describes the site's response.
  • Poll the task instead of resubmitting the job. The fetch keeps running after a timeout, so submit asynchronously and poll later.
  • Match concurrency to your key's limit. The limit counts running requests, not requests per second, and async tasks count until they end.
  • Check a site's 200 for a block page, then switch to a browser render. On Walmart searches, where plain HTTP clients got a "Robot or human?" page with a 200, all 15 Evomi browser renders succeeded.

Two status codes decide most retries

The Evomi Scraper API fetches pages through residential or datacenter proxies, and each call returns 2 status codes. The HTTP status of your call says how the API handled the request, and with delivery=json, status_code says what the target site returned. If the site returns 503, your call still returns HTTP 200, because the API completed the request, and status_code contains the 503.

Request mode sends a plain HTTP request, and browser mode renders the page. Auto mode starts with a plain request and upgrades to a browser render when the page needs one. Add capture_headers=true to get the target's headers, including Retry-After and Cloudflare's cf-mitigated: challenge header.

With both codes available, this failure table maps each failure to a clear action:

What you get

What it means

What to do

API 400, 401, 402, or 422

Rejected before any fetch, including a domain that doesn't exist

Fix the input, key, or credits before you send it again

API 429

All your key's slots are busy

Wait for a free slot, then resend

API 503

The API is briefly unavailable

Resend after a short random delay

API 202 or 408 with a task_id

The job is still running

Poll the task instead of resending

API 500 with a scraping error

The fetch failed after the API's own retries

Retry up to 2 times

API 200, status_code 404 or 410

The page no longer exists

Stop

API 200, status_code 429

The site is rate-limiting you

Try that domain again later

API 200, status_code 403 or a CAPTCHA or challenge page

The site refused this request

Send it again with a browser render

API 200, status_code 5xx

The site had an error

Retry the same mode up to 2 times

API 200, any other non-2xx status_code

A status your code doesn't handle yet, such as a 402 from a pay-per-crawl site

Stop and review it

API 200, page fails your content validation

A failure that looks like success

Try a browser render once, then set the URL aside for review

A site's HTTP errors and its CAPTCHA or challenge pages are usually returned inside an API 200.

The API retries before you do

The Scraper API retries short-lived, or transient, errors such as network errors, page crashes, SSL errors, and gateway errors. It makes up to 5 retries in request mode and up to 3 in browser and auto modes.

In request mode, the API also retried a 403 response inside the same call. In each of 5 test calls, it sent 3 requests to a page that returns 403, 2 to 3 seconds apart.

Those retries are part of the call and are included in its price. If you add your own retries as well, the site receives far more requests. Sending the same 403 URL 3 times in request mode produced 9 requests at the target:

Three client attempts, each one API call with three internal attempts, sent nine requests to a target that returned 403

Stop resending a 403 in the same mode, and the target receives 3 requests, not 9.

Give each layer one job, and let the API handle transient errors. Your wrapper should add at most 2 retries per mode for errors that remain after the API's retries, such as a site's 5xx. It should also make the decisions only your pipeline can make. Those decisions are whether a page is usable, when to pay for a browser render, and when to pause a domain.

An AI agent that scrapes through our MCP server adds one more layer, because an agent tends to call a failed tool again. Give the agent your wrapper as its tool, so it gets an outcome such as gone or blocked instead of a raw failure to resend.

A client timeout doesn't mean the request failed

A client timeout means only that your HTTP client stopped waiting, while the fetch keeps running on the server. A client with a 5-second read timeout tried 3 times to get a page that takes 12 seconds. It raised 3 ReadTimeout errors while the target logged 3 complete fetches, so its 2 retries became duplicates.

A synchronous call waits up to 30 seconds in request mode and up to 45 seconds in browser mode. Then it moves the request to the async queue and responds with a task_id, so a slow page keeps processing. Set your read timeout to 60 seconds so it waits longer than both limits.

The stronger fix is to submit with async=true, which typically returns a task_id in under a second. Every retry after that is a GET on /api/v1/scraper/tasks/{task_id}, and a GET is idempotent, so repeating it never creates a second task. Handle 2 details of the task endpoint:

  • The status code describes the task, not your request to read it. Every finished task includes an X-Task-Finished-At header, and a failed one also returns HTTP 500. Check that header before you treat a 500 as a failed read.
  • Store the result on the first successful read. Read each task soon after it finishes, write the content to your own storage, and log the task_id with the URL. Or set up Cloud Storage and pass use_default_storage=true, so the API writes each result to your S3, GCS, or Azure bucket.

Match concurrency to your key's limit

The Scraper API has no requests-per-second limit. It limits how many requests run at once per key, and your plan sets that number. Read your limit from the X-RateLimit-Limit header on any successful response, or from the dashboard. On a test key with 5 slots, a burst of 8 requests to a page that takes 12 seconds gave these results:

Requests

Result

Response time

5

HTTP 200 with the page

13.2 to 15.9 seconds

3

HTTP 429, Concurrency limit exceeded (5/5)

0.7 seconds

Instead of pausing after a 429, limit your concurrent requests to X-RateLimit-Limit with an asyncio.Semaphore, so 429s become rare. Two more rules follow:

  • A slot stays busy until the task ends. That includes async tasks and requests your client stopped waiting for, so keep the semaphore until polling ends, not only during submission.
  • A rejected submission is safe to resend. A 429 creates no task, so resending it can't create a duplicate.

To choose a plan, multiply URLs per second by the average seconds per task. For example, 200,000 URLs a day is about 2.3 per second, and at 6 seconds per task that needs 14 slots.

Pick a plan on the pricing page with at least that many slots, plus room for retries. Our support team can raise a key's limit beyond the largest plan. A semaphore counts only the requests in its own process, so split the limit between processes that share a key.

A site's 200 can still be a block page

A status-based retry loop treats every 200 as a success. A site with bot protection can respond to a bot with a CAPTCHA or challenge page and a 200. Our post on scrapers that look healthy while returning bad data explains that silent failure.

Your content validation decides which 200s are real. Match the <title> against known challenge titles, check cf-mitigated, and confirm the fields you parse exist. Send a page that fails the check to a browser render. If it fails again, move the URL to a dead-letter queue, a list of failed items set aside for review.

Walmart responded to plain HTTP requests with a 15 KB page titled "Robot or human?", served with HTTP 200 and status_code 200. In 3 calls with mode=auto, each one switched to a browser render, reported as auto (browser). The 2.3 to 2.6 MB pages contained the search results. Across all our test runs, 15 of 15 browser renders on Walmart returned the results.

Repeating a plain request on Walmart returned the same challenge page each time. Browser renders from the same rotating IP pool got the results, which suggests Walmart checked the plain HTTP client, not the IP address. On sites where your use is permitted, a retry after a block must change the request in one of these ways:

  • The mode. Move from request mode to a browser render, or let auto mode switch.
  • The exit IP. Without proxy_session_id, each call already gets a rotating exit IP, so use a session ID only when several requests must share the same IP.
  • The country. proxy_country sends the request from any of 195+ countries when a site treats regions differently.

For targets that need clicks or a multi-step workflow, the Evomi Scraping Browser runs a live browser session. Our guide to routing each host to an API call or a live browser session shows when to switch.

Build a retry wrapper that classifies, switches modes, and stops

The wrapper submits every job asynchronously, polls its task until it finishes, and applies the failure table to the result. A general retry library such as tenacity, or axios-retry in Node.js, repeats the same call by default, while this wrapper changes the call between attempts. For simpler scripts, our official Python client, evomi-client, wraps the same endpoints. The wrapper uses httpx directly so every status code and header it acts on stays visible in the code.

Set your API key and slot count

Start a free trial or a plan, then open the Playground in your Evomi dashboard. It shows your API key and your concurrency limit, the same slot count that X-RateLimit-Limit reports:

The Scraper API Playground in the Evomi dashboard, with the Concurrency card showing 0 of 5 slots in use and the API Key card next to it, both outlined in red

Copy the key from the API Key card, and use the number after the slash as your slot count. This test key has 5 slots, and your plan sets your own number.

Set the key and the slot count:

Shell
export EVOMI_API_KEY=your-api-key
export EVOMI_CONCURRENCY=5  # your slot count

On Windows, set each variable in PowerShell, such as $env:EVOMI_API_KEY="your-api-key". The code needs Python 3.11 or newer, and it ran on 3.11 through 3.14.

Save the wrapper code

The header at the top of the file lists its dependencies, so uv run installs them on the first run. Without uv, install httpx in a virtual environment with pip install "httpx>=0.28,<1", and run the file with python. Save the file as scrape_with_retries.py:

Python
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx>=0.28,<1"]
# ///
import asyncio
import hashlib
import os
import pathlib
import random
import re
import sys

import httpx

BASE = "https://scrape.evomi.com/api/v1/scraper"
if not os.environ.get("EVOMI_API_KEY"):
    raise SystemExit("Set EVOMI_API_KEY to the key from your Evomi dashboard")
HEADERS = {"x-api-key": os.environ["EVOMI_API_KEY"]}
# Your plan's slot count: every accepted response shows X-RateLimit-Limit
CONCURRENCY = int(os.environ.get("EVOMI_CONCURRENCY", "5"))
CREDIT_BUDGET = 12  # No attempt starts if it could push a URL past this
TASK_DEADLINE = 300  # Seconds to wait for one task, then no_result
COST = {"request": 2, "browser": 5}  # Credits per call, residential proxies
# Append {"mode": "browser", "proxy_country": "GB"} to try another region
STEPS = [{"mode": "request"}, {"mode": "browser"}]
STOP_BATCH = {401, 402}  # Bad key or no credits: every URL would fail
BLOCK_TITLES = (
    "robot or human",
    "just a moment",
    "access denied",
    "attention required",
    "access to this page has been denied",
)


class StopBatch(Exception):
    """The key or the credits need attention before anything else runs."""


class Rejected(Exception):
    """The API refused this request's parameters. Fix them, don't retry."""


class TaskLost(Exception):
    """No task record exists for this task ID."""


def backoff(attempt, base=1.0, cap=20.0):
    # Full jitter: a random wait, so workers don't retry in the same second
    return random.uniform(0, min(cap, base * 2**attempt))


async def submit(client, body):
    """Send the paid POST. Resend only when the API rejected it."""
    for n in range(10):
        try:
            r = await client.post(f"{BASE}/realtime", headers=HEADERS, json=body)
        except (httpx.ConnectError, httpx.ConnectTimeout):
            if n == 9:
                raise
            await asyncio.sleep(backoff(n))  # The request was never sent
            continue
        if r.status_code not in (429, 503):
            break
        await asyncio.sleep(backoff(n))  # Rejected, so no task was created
    if r.status_code in STOP_BATCH:
        raise StopBatch(f"{r.status_code}: {r.text[:200]}")
    if r.status_code in (400, 422):
        raise Rejected(r.status_code)
    r.raise_for_status()
    return r.json()["task_id"]


async def wait_for(client, task_id):
    """Poll the task. A repeated GET never creates a new task."""
    async with asyncio.timeout(TASK_DEADLINE):
        while True:
            await asyncio.sleep(2)
            try:
                r = await client.get(f"{BASE}/tasks/{task_id}", headers=HEADERS)
            except httpx.TransportError:
                continue
            if r.headers.get("x-task-finished-at"):
                return r.json()  # Finished: a 500 here is the task's result
            if r.status_code == 404:
                raise TaskLost(task_id)
            if r.status_code in STOP_BATCH:
                raise StopBatch(f"{r.status_code} while reading a task")
            # A 202 while running, or a temporary error: read again


def has_text(html, minimum=500):
    """Default check: enough visible text outside scripts and styles."""
    text = re.sub(r"<(script|style)\b.*?</\1>|<[^>]+>", " ", html, flags=re.I | re.S)
    return len(" ".join(text.split())) >= minimum


def save(url, html):
    """Write a usable page to pages/ and list it in pages/index.tsv."""
    folder = pathlib.Path("pages")
    folder.mkdir(exist_ok=True)
    path = folder / f"{hashlib.sha256(url.encode()).hexdigest()[:16]}.html"
    path.write_text(html, encoding="utf-8")
    with open(folder / "index.tsv", "a", encoding="utf-8") as index:
        index.write(f"{path.name}\t{url}\n")
    return path


def target_headers(job):
    raw = (job.get("headers") or {}).get("response_headers") or {}
    return {k.lower(): v for k, v in raw.items()}


def classify(job, looks_ok):
    code, html = job.get("status_code"), job.get("content") or ""
    if not job.get("success"):
        return "api_error"
    if code in (404, 410):
        return "gone"
    if code == 429:
        return "throttled"  # The site is rate-limiting you
    title = re.search(r"<title[^>]*>(.*?)</title>", html, re.I | re.S)
    title = title.group(1).strip().lower() if title else ""
    challenged = target_headers(job).get("cf-mitigated") == "challenge"
    if code == 403 or challenged or title.startswith(BLOCK_TITLES):
        return "blocked"
    if code and code >= 500:
        return "target_error"
    if not code or not 200 <= code < 300:
        return "unexpected"  # Review it before you write a rule for it
    return "ok" if looks_ok(html) else "invalid"


async def scrape(client, slots, url, looks_ok):
    spent, attempts, outcome, code = 0.0, 0, "failed", None
    for step in STEPS:
        for retry in range(3):
            if spent + COST[step["mode"]] > CREDIT_BUDGET:
                return url, "over_budget", code, attempts, spent
            # No proxy_session_id: each call gets a rotating exit IP
            body = {
                "url": url,
                **step,
                "async": True,
                "delivery": "json",
                "include_content": True,
                "capture_headers": True,
            }
            async with slots:  # A slot stays busy until the task ends
                task_id = await submit(client, body)
                # The service saves (url, task_id) to resume after a crash
                try:
                    job = await wait_for(client, task_id)
                except (TimeoutError, TaskLost) as exc:
                    outcome = f"no_result ({type(exc).__name__})"
                    return url, outcome, code, attempts + 1, spent
            attempts += 1
            spent += float(job.get("credits_used") or 0)
            code = job.get("status_code")
            outcome = classify(job, looks_ok)
            if outcome == "ok":
                save(url, job["content"])
            if outcome in ("ok", "gone"):
                outcome = f"{outcome} via {job.get('mode_used')}"
                return url, outcome, code, attempts, spent
            if outcome == "throttled":  # Return Retry-After for a later retry
                wait = target_headers(job).get("retry-after")
                return (url, f"throttled, retry-after={wait}", code, attempts, spent)
            if outcome == "unexpected":
                return url, outcome, code, attempts, spent
            if outcome in ("api_error", "target_error"):
                if retry == 2:  # A different mode will not fix it
                    return url, outcome, code, attempts, spent
                await asyncio.sleep(backoff(retry))
                continue  # Transient: same step again after a pause
            break  # Blocked or invalid: this step returns the same page
    return url, outcome, code, attempts, spent


async def run(client, slots, url, looks_ok):
    try:
        result = await scrape(client, slots, url, looks_ok)
    except StopBatch:
        raise  # The TaskGroup cancels every other URL
    except Rejected as exc:  # Includes a domain that doesn't exist
        result = url, f"rejected ({exc})", None, 0, 0.0
    except Exception as exc:
        result = url, f"error ({type(exc).__name__})", None, 0, 0.0
    url, outcome, code, attempts, spent = result
    # Print as each URL finishes, so a long batch shows progress
    print(
        f"{outcome:<28} site={code!s:<4} attempts={attempts}  "
        f"credits={spent:g}  {url}",
        flush=True,
    )
    return result


async def main(urls, looks_ok=has_text):
    slots = asyncio.Semaphore(CONCURRENCY)
    timeout = httpx.Timeout(30.0, connect=10.0)  # Async calls return fast
    stopped = None
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            async with asyncio.TaskGroup() as group:
                tasks = [
                    group.create_task(run(client, slots, u, looks_ok)) for u in urls
                ]
    except* StopBatch as stop:
        stopped = stop.exceptions[0]
    if stopped:  # Exit with an error, so a scheduler sees the failure
        raise SystemExit(f"Batch stopped: {stopped}")
    total = sum(task.result()[4] for task in tasks)
    print(f"done: {len(tasks)} URLs, {total:g} credits")


if __name__ == "__main__":
    # Pass a file with one URL per line, or run the sample list below
    urls = [
        "https://www.walmart.com/search?q=coffee+maker",
        "https://quotes.toscrape.com/js/",
        "https://www.python.org/this-page-does-not-exist",
        "https://httpbin.io/status/503",
        "https://httpbin.io/status/429",
    ]
    if len(sys.argv) > 1:
        path = pathlib.Path(sys.argv[1])
        if not path.is_file():
            raise SystemExit(f"No URL file at {path}")
        urls = path.read_text().split()
    asyncio.run(main(urls))

The key decisions in the file:

  • submit resends only after an API 429 or 503, or a connection error that stopped the request before it was sent. A read timeout on the POST stops with an error instead, because the API may have created a task whose ID never arrived.
  • classify reads the target's status and page title, and sends unknown statuses to unexpected. Its default content validation, has_text, needs 500 characters of visible text, so a JavaScript shell switches to browser mode. Because the default is strict, a short static page such as example.com switches too. Replace it with a check of the fields your parser needs, such as a price and a title.
  • scrape goes through STEPS, the list of modes to try in order. It moves blocked and invalid results to the next step and retries transient errors in the same step with full-jitter backoff. COST lists the credits per call with the default residential proxies, 2 in request mode and 5 in browser mode. CREDIT_BUDGET stops any attempt whose price would take a URL over the budget.
  • main runs the batch, prints each outcome and its credits as its URL finishes, and ends with the batch total. It stops everything on a 401 or 402, and ends only the affected URL on a 400 or 422.

To try another region before a URL goes to the dead-letter queue, append {"mode": "browser", "proxy_country": "GB"} to STEPS. To let the API switch to a browser inside one call, set the first step to {"mode": "auto"}. Auto mode charges the upgraded price only when it switches, so set COST["auto"] to the upgraded price from the pricing page.

Run the wrapper and read the output

Run the file with no argument to scrape its sample list, or pass a file with one URL per line:

Shell
uv run scrape_with_retries.py

The run printed:

Plain Text
gone via request             site=404  attempts=1  credits=2  https://www.python.org/this-page-does-not-exist
throttled, retry-after=None  site=429  attempts=1  credits=2  https://httpbin.io/status/429
ok via browser               site=200  attempts=2  credits=7  https://quotes.toscrape.com/js/
target_error                 site=503  attempts=3  credits=6  https://httpbin.io/status/503
ok via browser               site=200  attempts=2  credits=7  https://www.walmart.com/search?q=coffee+maker
done: 5 URLs, 24 credits

Walmart's challenge page and the quotes page's JavaScript shell both moved to a browser render. Each usable page was saved to pages/ and listed in pages/index.tsv. That 429 page sent no Retry-After header, so the wrapper printed retry-after=None. Lines appear as each URL finishes.

Run the wrapper as a service

The wrapper keeps every task_id in memory, which suits a one-off run. For a pipeline, a service that reuses the wrapper's classify adds a job table, a webhook receiver, per-domain state, and a background poller. With them, a restart collects the jobs still running, and throttled URLs get another try later.

Receive results by webhook

Webhooks report when a task ends, so the service doesn't poll every task. Three things about the deliveries matter for the receiver:

  • The payload reports the outcome, not the page. A completed event contains the task's outcome and credits, so the receiver fetches the page from the task.
  • The signature is computed over the raw body. X-Webhook-Signature contains an HMAC-SHA256 of the exact request bytes, keyed with the secret you send with the job, and prefixed with sha256=. The same JSON re-serialized without spaces fails the check, so verify the bytes before you parse them.
  • Delivery is at least once, as in many webhook systems. The receiver stores each pair of task_id and event once and ignores duplicates.

The poller reads any task still open after 2 minutes. A service that restarts soon still collects the tasks that finished while it was down.

To receive webhooks, set EVOMI_WEBHOOK_URL to the receiver's public HTTPS URL, such as https://hooks.example.com/webhooks/evomi, proxied to port 8080 or your PORT. Set EVOMI_WEBHOOK_SECRET to a long random string, such as one from Python's secrets.token_hex(32). Without a public URL, leave both unset, and the service polls each task locally.

Save the service code

The service stores its jobs in scrape.db and skips URLs that file already contains, so delete it between batches. It exits once every URL is done. Save it next to the wrapper as scrape_service.py, with one URL per line in urls.txt:

Python
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx>=0.28,<1", "aiohttp>=3.14,<4"]
# ///
import asyncio
import email.utils
import hashlib
import hmac
import json
import os
import sqlite3
import time
from urllib.parse import urlsplit

import httpx
from aiohttp import web

from scrape_with_retries import (
    BASE,
    CONCURRENCY,
    COST,
    HEADERS,
    STEPS,
    backoff,
    classify,
    has_text,
    save,
    target_headers,
)

# Leave EVOMI_WEBHOOK_URL unset to run locally: the poller reads tasks
HOOK_URL = os.environ.get("EVOMI_WEBHOOK_URL")  # Public HTTPS endpoint
SECRET = os.environ.get("EVOMI_WEBHOOK_SECRET", "")  # Long and random
if HOOK_URL and not SECRET:
    raise SystemExit("Set EVOMI_WEBHOOK_SECRET to sign webhook deliveries")
PORT = int(os.environ.get("PORT", "8080"))
CREDIT_BUDGET, MAX_RETRIES = 12, 2
PAUSE_AFTER_BLOCKS, PAUSE_SECONDS, LEARN_SECONDS = 3, 3600, 86400
STALE_SECONDS = 120 if HOOK_URL else 10  # Read tasks still open after this
TASK_DEADLINE = 900  # Stop waiting for a task that runs longer than this

db = sqlite3.connect("scrape.db", isolation_level=None)
db.executescript(
    """
CREATE TABLE IF NOT EXISTS jobs (url TEXT PRIMARY KEY, domain TEXT,
  state TEXT DEFAULT 'queued', step INT DEFAULT 0, retries INT DEFAULT 0,
  credits REAL DEFAULT 0, task_id TEXT, outcome TEXT,
  next_at REAL DEFAULT 0, updated REAL DEFAULT 0, started REAL DEFAULT 0);
CREATE TABLE IF NOT EXISTS domains (domain TEXT PRIMARY KEY,
  paused_until REAL DEFAULT 0, blocks INT DEFAULT 0,
  best_step INT DEFAULT 0, best_until REAL DEFAULT 0);
CREATE TABLE IF NOT EXISTS events (task_id TEXT, event TEXT,
  PRIMARY KEY (task_id, event));
"""
)


class RetryBudget:
    """Per-process token bucket: each first attempt earns 0.1 retry."""

    def __init__(self, ratio=0.1, cap=10.0):
        self.ratio, self.cap, self.tokens = ratio, cap, cap

    def earn(self):
        self.tokens = min(self.cap, self.tokens + self.ratio)

    def spend(self):
        if self.tokens < 1:
            return False
        self.tokens -= 1
        return True


budget = RetryBudget()


def log(outcome, mode, url):
    print(f"{outcome:<13} {mode:<8} {url}", flush=True)


def add_urls(urls):
    for url in urls:
        domain = urlsplit(url).hostname
        if not domain:
            log("skipped", "-", url)
            continue
        db.execute(
            "INSERT OR IGNORE INTO jobs (url, domain) VALUES (?, ?)", (url, domain)
        )
        db.execute("INSERT OR IGNORE INTO domains (domain) VALUES (?)", (domain,))


def retry_after_seconds(value, default=300, cap=3600):
    if not value:
        return default
    if value.strip().isdigit():
        return min(int(value), cap)
    try:
        when = email.utils.parsedate_to_datetime(value)  # The HTTP-date form
    except (TypeError, ValueError):
        return default
    return min(max(when.timestamp() - time.time(), 0), cap)


def record_result(url, job):
    """Turn a finished task into the job's next state."""
    domain, step, retries = db.execute(
        "SELECT domain, step, retries FROM jobs WHERE url=?", (url,)
    ).fetchone()
    outcome, now = classify(job, has_text), time.time()
    log(outcome, STEPS[step]["mode"], url)
    new = {"state": "done", "outcome": outcome, "task_id": None}
    if outcome == "ok":
        save(url, job["content"])
    if outcome in ("ok", "gone"):
        db.execute(
            "UPDATE domains SET blocks=0, best_step=?, best_until=?" " WHERE domain=?",
            (step, now + LEARN_SECONDS, domain),
        )
    elif outcome in ("blocked", "invalid"):
        if step + 1 < len(STEPS):
            new.update(state="queued", step=step + 1, retries=0)
        else:  # Only a block on the last step counts toward a pause
            db.execute("UPDATE domains SET blocks=blocks+1 WHERE domain=?", (domain,))
            db.execute(
                "UPDATE domains SET paused_until=?, blocks=0"
                " WHERE domain=? AND blocks>=?",
                (now + PAUSE_SECONDS, domain, PAUSE_AFTER_BLOCKS),
            )
    elif outcome == "throttled":
        wait = retry_after_seconds(target_headers(job).get("retry-after"))
        db.execute(
            "UPDATE domains SET paused_until=? WHERE domain=?", (now + wait, domain)
        )
        if retries < MAX_RETRIES:  # After that, keep the outcome
            new.update(state="queued", retries=retries + 1, next_at=now + wait)
    elif (
        outcome in ("api_error", "target_error")
        and retries < MAX_RETRIES
        and budget.spend()
    ):  # Over the retry budget: keep the outcome
        new.update(state="queued", retries=retries + 1, next_at=now + backoff(retries))
    sets = ", ".join(f"{column}=?" for column in new)  # Names from code only
    db.execute(
        f"UPDATE jobs SET {sets}, credits=credits+?, updated=?" " WHERE url=?",
        (*new.values(), float(job.get("credits_used") or 0), now, url),
    )


async def read_task(client, task_id):
    """Return ('done', job), ('running', None) or ('lost', None)."""
    try:
        r = await client.get(f"{BASE}/tasks/{task_id}", headers=HEADERS)
    except httpx.TransportError:
        return "running", None  # Try again on the next poll
    if r.headers.get("x-task-finished-at"):
        return "done", r.json()
    if r.status_code == 404:
        return "lost", None
    if r.status_code in (401, 402):
        r.raise_for_status()  # Same stop as a 401 or 402 on submit
    return "running", None  # 202 while processing, or a temporary error


async def finish(client, task_id):
    row = db.execute(
        "SELECT url, started FROM jobs WHERE task_id=? AND" " state='running'",
        (task_id,),
    ).fetchone()
    if not row:
        return  # Not running: processed already, or task_id not saved yet
    url, started = row
    status, job = await read_task(client, task_id)
    if not db.execute(
        "SELECT 1 FROM jobs WHERE task_id=? AND" " state='running'", (task_id,)
    ).fetchone():
        return  # Another reader processed it during the GET
    if status == "done":
        record_result(url, job)
    elif status == "lost":  # No record for this task: run the URL again
        db.execute(
            "UPDATE jobs SET state='queued', task_id=NULL" " WHERE url=?", (url,)
        )
    elif time.time() - started > TASK_DEADLINE:
        db.execute(
            "UPDATE jobs SET state='done', outcome='no_result',"
            " task_id=NULL WHERE url=?",
            (url,),
        )
    else:
        db.execute("UPDATE jobs SET updated=? WHERE url=?", (time.time(), url))


async def webhook(request):
    raw = await request.read()  # Verify the exact bytes that were signed
    sent = request.headers.get("X-Webhook-Signature") or request.headers.get(
        "X-Evomi-Signature", ""
    )
    good = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sent, good):
        return web.Response(status=401)
    event = json.loads(raw)
    task_id, kind = event["data"]["task_id"], event["event"]
    first = db.execute(
        "INSERT OR IGNORE INTO events VALUES (?, ?)", (task_id, kind)
    ).rowcount
    if first and kind in ("completed", "failed"):
        # Reply now, and read the task's content in the background
        task = asyncio.create_task(finish(request.app["client"], task_id))
        request.app["pending"].add(task)
        task.add_done_callback(request.app["pending"].discard)
    return web.Response(text="ok")


async def submit(client, url):
    domain, step, retries, credits = db.execute(
        "SELECT domain, step, retries, credits FROM jobs WHERE url=?", (url,)
    ).fetchone()
    best, until = db.execute(
        "SELECT best_step, best_until FROM domains" " WHERE domain=?", (domain,)
    ).fetchone()
    first = step == 0 and retries == 0 and credits == 0
    if first and until > time.time():
        step = best  # Start where this domain last succeeded
    mode = STEPS[step]["mode"]
    if credits + COST[mode] > CREDIT_BUDGET:
        db.execute(
            "UPDATE jobs SET state='done', outcome='over_budget'" " WHERE url=?", (url,)
        )
        log("over_budget", mode, url)
        return
    db.execute(
        "UPDATE jobs SET state='submitting', step=?, updated=?" " WHERE url=?",
        (step, time.time(), url),
    )
    body = {
        "url": url,
        **STEPS[step],
        "async": True,
        "delivery": "json",
        "include_content": True,
        "capture_headers": True,
    }
    if HOOK_URL:
        body["webhook"] = {
            "url": HOOK_URL,
            "type": "custom",
            "secret": SECRET,
            "events": ["completed", "failed"],
        }
    try:
        r = await client.post(f"{BASE}/realtime", headers=HEADERS, json=body)
    except (httpx.ConnectError, httpx.ConnectTimeout):  # It was never sent
        db.execute(
            "UPDATE jobs SET state='queued', next_at=? WHERE url=?",
            (time.time() + 5, url),
        )
        return
    except httpx.TransportError:
        return  # A task may exist, so the poller requeues it later
    if r.status_code in (429, 503):  # Rejected, so no task exists
        db.execute(
            "UPDATE jobs SET state='queued', next_at=? WHERE url=?",
            (time.time() + 5, url),
        )
        return
    if r.status_code in (400, 422):  # Includes a domain that doesn't exist
        db.execute(
            "UPDATE jobs SET state='done', outcome='rejected'" " WHERE url=?", (url,)
        )
        log("rejected", mode, url)
        return
    if r.status_code >= 500:
        return  # Unknown outcome: the poller requeues it later
    if r.status_code in (401, 402):  # No task exists: undo 'submitting'
        db.execute("UPDATE jobs SET state='queued' WHERE url=?", (url,))
    r.raise_for_status()  # A 401 or 402 stops the service on purpose
    now = time.time()
    db.execute(
        "UPDATE jobs SET state='running', task_id=?, updated=?,"
        " started=? WHERE url=?",
        (r.json()["task_id"], now, now, url),
    )
    if first:
        budget.earn()  # Each URL's first accepted attempt adds 0.1 retry


async def dispatcher(client):
    """Submit queued jobs inside the slot limit. Return when none are open."""
    while True:
        now = time.time()
        open_jobs = db.execute(
            "SELECT count(*) FROM jobs WHERE state IN"
            " ('queued', 'running', 'submitting')"
        ).fetchone()[0]
        if not open_jobs:
            for outcome, count in db.execute(
                "SELECT outcome, count(*) FROM" " jobs GROUP BY outcome"
            ):
                print(f"done: {count} x {outcome}", flush=True)
            return
        busy = db.execute(
            "SELECT count(*) FROM jobs WHERE state IN" " ('running', 'submitting')"
        ).fetchone()[0]
        rows = db.execute(
            """
            SELECT j.url FROM jobs j JOIN domains d USING (domain)
            WHERE j.state='queued' AND j.next_at<=? AND d.paused_until<=?
            ORDER BY j.next_at LIMIT ?""",
            (now, now, max(CONCURRENCY - busy, 0)),
        ).fetchall()
        for (url,) in rows:
            await submit(client, url)
        await asyncio.sleep(1)


async def poller(client):
    """Read tasks that are still open, and recover after a crash."""
    while True:
        stale = time.time() - STALE_SECONDS
        # A crash between the POST and saving task_id leaves 'submitting'.
        # Requeue those jobs as a retry, and print each URL.
        db.execute(
            "UPDATE jobs SET state='done', outcome='no_result'"
            " WHERE state='submitting' AND updated<? AND retries>=?",
            (stale, MAX_RETRIES),
        )
        for (url,) in db.execute(
            "SELECT url FROM jobs WHERE" " state='submitting' AND updated<?", (stale,)
        ).fetchall():
            log("resubmitted", "-", url)
        db.execute(
            "UPDATE jobs SET state='queued', retries=retries+1"
            " WHERE state='submitting' AND updated<?",
            (stale,),
        )
        rows = db.execute(
            "SELECT task_id FROM jobs WHERE state='running'" " AND updated<?", (stale,)
        ).fetchall()
        for (task_id,) in rows:
            await finish(client, task_id)
        await asyncio.sleep(30 if HOOK_URL else 5)


async def main(urls):
    add_urls(urls)
    async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
        if HOOK_URL:  # Serve the receiver behind your HTTPS reverse proxy
            app = web.Application()
            app["client"], app["pending"] = client, set()
            app.router.add_post("/webhooks/evomi", webhook)
            runner = web.AppRunner(app)
            await runner.setup()
            await web.TCPSite(runner, "127.0.0.1", PORT).start()
        tasks = {
            asyncio.create_task(poller(client)),
            asyncio.create_task(dispatcher(client)),
        }
        done, rest = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
        for task in rest:
            task.cancel()
        for task in done:
            task.result()  # Re-raise a 401 or 402 from either loop


if __name__ == "__main__":
    try:
        with open("urls.txt") as file:
            urls = file.read().split()
    except FileNotFoundError:
        raise SystemExit("Put the URLs to scrape in urls.txt, one per line") from None
    try:
        asyncio.run(main(urls))
    except httpx.HTTPStatusError as exc:  # A 401 or 402 stops the service
        raise SystemExit(
            f"Service stopped: {exc.response.status_code} " f"{exc.response.text[:200]}"
        ) from None

The production practices in the file:

  • The jobs table stores each URL's state, step, retries, credits, and task_id, so a restart reads saved tasks instead of resending them. For several workers, move it and the in-memory RetryBudget to Postgres, and claim jobs with SELECT … FOR UPDATE SKIP LOCKED.
  • record_result remembers, for each domain, which step worked, and keeps it for a day. It acts as a per-domain circuit breaker, pausing a domain for an hour after 3 blocked or invalid pages in the last step. After a 429 it pauses for the site's Retry-After value, or 5 minutes without one, and stops after 2 requeues.
  • RetryBudget earns 0.1 retry per URL's first accepted attempt and spends 1 per transient retry. Across a large batch, retries approach 10% of first attempts. An empty bucket ends the job with its last outcome.
  • submit marks a job submitting before the POST. If a crash or a lost response leaves it there, the poller resubmits it after 2 minutes, or 10 seconds locally. That way a batch still finishes every URL. After 2 requeues it ends the job as no_result.

A durable workflow engine such as Temporal or DBOS can replace the jobs table and the poller with its saved state and timers. The retry decisions stay the same.

Run the service and test a crash

Start the service from the folder that contains urls.txt. The uv run command reads aiohttp from the file's header, and pip users install "aiohttp>=3.14,<4" first:

Shell
uv run scrape_service.py

The API sent 1 webhook per task, and a test relay in front of the service sent each one twice to test deduplication:

Plain Text
blocked       request  https://www.walmart.com/search?q=coffee+maker
target_error  request  https://httpbin.io/status/503
gone          request  https://www.python.org/this-page-does-not-exist
blocked       request  https://www.walmart.com/search?q=espresso+machine
throttled     request  https://httpbin.io/status/429
target_error  request  https://httpbin.io/status/503
ok            browser  https://www.walmart.com/search?q=coffee+maker
ok            browser  https://www.walmart.com/search?q=espresso+machine
target_error  request  https://httpbin.io/status/503
throttled     request  https://httpbin.io/status/429
throttled     request  https://httpbin.io/status/429
done: 1 x gone
done: 2 x ok
done: 1 x target_error
done: 1 x throttled

Out of 22 replayed deliveries, the service processed each of the 11 tasks once and exited. The 5 URLs needed 11 tasks, because each retry and each mode switch is a new task.

Next, a third Walmart search and a test URL that responds after 20 seconds joined the queue. A kill -9 stopped the service 10 seconds after it started, with both tasks running, and it was restarted in local mode:

Plain Text
ok            browser  https://www.walmart.com/search?q=air+fryer
ok            request  https://webhook.site/583855e9-c9b5-4aa6-b8d4-c89e6b74aa93
done: 1 x gone
done: 4 x ok
done: 1 x target_error
done: 1 x throttled

The poller processed both tasks about 20 seconds after the restart, and neither URL was submitted again. The third Walmart search started at the browser step the domain had learned. It cost 5 credits in 1 attempt, while the first two searches each took 2 attempts and 7 credits. The totals also count the first run's jobs, which scrape.db kept.

What changes when you migrate from a synchronous scraper API

The failure table, the retry budget, and the per-domain rules work with most providers. The design also relies on things the API provides that you'd otherwise build yourself on many synchronous scraper APIs:

Situation

On a synchronous API that returns the target's status as its own

On the Evomi Scraper API

Your client times out

A retry sends a second fetch

With async=true, your retry reads the same task again

A 403 or 429 arrives

The body has to show whether the API or the site sent it

The API status and the target's status_code arrive separately

The site sends Retry-After or cf-mitigated

Your code often never sees it

capture_headers=true returns both

A plain request gets a challenge page

You resend with rendering enabled

Auto mode switches to a browser inside one call, and charges more only when it switches

The worker crashes mid-run

Results of running requests are lost

Saved task IDs let a quick restart collect them

A long task finishes

You keep the connection open or poll your own queue

A signed webhook reports each finished task

Results need to reach your storage

You download each page and upload it yourself

Cloud Storage writes each result to your S3, GCS, or Azure bucket

Measure retries as cost per usable record

A per-URL success rate hides retries, because a URL that succeeds on its third attempt counts as 1 success. Track these instead:

  • Credits per usable record. Divide the sum of credits_used, which every finished task reports, across all attempts by the number of records that passed your content validation. If you use AI enhancement, enable it only on the mode that returns usable pages for that host, so retries don't pay for it.
  • Attempts per usable record. If it rises while the success rate stays the same, a target may be getting stricter.
  • Outcome mix per domain. Track the share of blocked, throttled, and invalid results for each host. One host's mix can change while the overall average hardly changes.
  • Retry ratio per client. Keep retries under 10% of calls, the budget the Google SRE book describes. Count switches to browser mode separately, because they respond to blocks, not to overload.

Log each attempt with its task_id, attempt number, step, outcome, and credits_used, and record OpenTelemetry's http.request.resend_count as the attempt number minus 1. In the dashboard, Recent Requests lists each call's mode, duration, and credits, and Metrics shows credits used over 24 hours, 7 days, or 30 days. Our guide to monitoring scrapers with Prometheus and Grafana shows how to build per-target dashboards for these metrics.

Final thoughts

Scraper API retry handling works best when each layer has one job, with the API retrying transient errors and your wrapper classifying each result. Read both status codes separately, because an API 429 means waiting for a free slot, while a target's 429 means pausing that domain. Submit asynchronously and store every task ID, so a timeout or a quick restart means reading the task again, not paying for a second fetch.

Send blocked requests with a different mode or region, and move URLs that still fail to a dead-letter queue. Start a free trial, test a few of your hardest URLs with the wrapper, and check the mix of outcomes before you pick a plan.

FAQ

Is 429 a retryable error?

Yes, after a wait, and who sent it decides how long. If a website sends Retry-After with its 429, wait at least that long, though many sites send none. On the Evomi Scraper API, a 429 means all of your key's slots are busy, and the rejected call costs nothing. Resend it once one of your running requests finishes.

Is 400 error retryable?

Not until you fix it. A 400 means the request itself is wrong. The related 401, 402, and 422 mean a bad key, missing credits, or a failed validation. Resending any of them unchanged returns the same error. The Evomi Scraper API's 422 names the failed field, and it rejects a nonexistent domain before any fetch.

Should you retry on 500 errors?

Yes, a few times with exponential backoff and jitter, when the 500 is a transient error. On the Evomi Scraper API, a failed fetch returns an API 500 after the API has retried transient errors internally. Retry it up to 2 more times with an increasing, randomized delay, then log the URL for review.

Is HTTP 502 retryable?

Usually, yes. A 502 means a gateway got an invalid response from the upstream server, and that error can be temporary. Retry it up to 2 times with a randomized delay. The Evomi Scraper API retries gateway errors internally, and a 502 from the target site gets the same 2 retries.

How many times should you retry a failed request?

Usually 2 or 3 times, and only for transient errors such as 429 and 5xx responses. Don't resend other 4xx errors unchanged. The Evomi Scraper API already retries internally, so add at most 2 retries of your own per mode. Keep all retries under about 10% of your calls.

What is a retry interval?

A retry interval is the wait between a failed attempt and the next one. Production clients usually grow it exponentially and add jitter, a random spread, so many workers don't retry at the same moment. With full jitter, each wait is random, up to a limit that doubles with each attempt until a cap, such as 20 seconds.