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_codedescribes 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 | 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, | The page no longer exists | Stop |
API 200, | The site is rate-limiting you | Try that domain again later |
API 200, | The site refused this request | Send it again with a browser render |
API 200, | The site had an error | Retry the same mode up to 2 times |
API 200, any other non-2xx | 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:

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-Atheader, 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_idwith the URL. Or set up Cloud Storage and passuse_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, | 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_countrysends 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:

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:
export EVOMI_API_KEY=your-api-key
export EVOMI_CONCURRENCY=5 # your slot countOn 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:
# /// 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:
submitresends 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.classifyreads the target's status and page title, and sends unknown statuses tounexpected. 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 asexample.comswitches too. Replace it with a check of the fields your parser needs, such as a price and a title.scrapegoes throughSTEPS, the list of modes to try in order. It movesblockedandinvalidresults to the next step and retries transient errors in the same step with full-jitter backoff.COSTlists the credits per call with the default residential proxies, 2 in request mode and 5 in browser mode.CREDIT_BUDGETstops any attempt whose price would take a URL over the budget.mainruns 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:
uv run scrape_with_retries.pyThe run printed:
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 creditsWalmart'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
completedevent 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-Signaturecontains an HMAC-SHA256 of the exact request bytes, keyed with thesecretyou send with the job, and prefixed withsha256=. 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_idand 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:
# /// 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 NoneThe production practices in the file:
- The
jobstable stores each URL's state, step, retries, credits, andtask_id, so a restart reads saved tasks instead of resending them. For several workers, move it and the in-memoryRetryBudgetto Postgres, and claim jobs withSELECT … FOR UPDATE SKIP LOCKED. record_resultremembers, 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'sRetry-Aftervalue, or 5 minutes without one, and stops after 2 requeues.RetryBudgetearns 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.submitmarks a jobsubmittingbefore 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 asno_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:
uv run scrape_service.pyThe API sent 1 webhook per task, and a test relay in front of the service sent each one twice to test deduplication:
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 throttledOut 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:
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 throttledThe 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 |
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 |
The site sends | Your code often never sees it |
|
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, andinvalidresults 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.