Rate Limits, Retries & Caching#
Scrape so politely you never get banned, and so efficiently you never fetch the same page twice.
β± ~9 min read Β· ~15 min hands-on π needs: HTTP clients Β· Legal & Ethical Scraping
The difference between a scraper that runs for months and one that’s blocked on day one is rarely cleverness β it’s restraint. Three habits do almost all the work: cap your concurrency, back off when told to, and cache everything.
Try it in 5 minutes β get rate-limited on purpose#
httpbin.org will return any status you ask for, so you can practise handling a 429 without annoying a real site:
curl -s -o /dev/null -w "%{http_code}\n" https://httpbin.org/status/429Now handle it properly. This client backs off exponentially, adds jitter, and obeys Retry-After:
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28"]
# ///
"""Retry on 429/5xx with exponential backoff + jitter, honouring Retry-After.
Run: uv run polite_get.py https://httpbin.org/status/429
"""
import random
import sys
import time
import httpx
RETRY_ON = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 5
def get(client: httpx.Client, url: str) -> httpx.Response:
for attempt in range(MAX_ATTEMPTS):
r = client.get(url)
if r.status_code not in RETRY_ON:
return r
# The server may tell us exactly how long to wait β always prefer that.
retry_after = r.headers.get("Retry-After")
delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
delay += random.uniform(0, 0.5) # jitter stops clients retrying in lockstep
print(f" {r.status_code} β sleeping {delay:.1f}s (attempt {attempt + 1})")
time.sleep(delay)
raise RuntimeError(f"Gave up after {MAX_ATTEMPTS} attempts: {url}")
if __name__ == "__main__":
url = sys.argv[1] if len(sys.argv) > 1 else "https://httpbin.org/status/429"
with httpx.Client(timeout=10) as client:
try:
print("Final:", get(client, url).status_code)
except RuntimeError as e:
print(e)β
Watch the delays grow 1 β 2 β 4 β 8. That’s a scraper being a good citizen instead of a battering ram.
Why jitter, and why only some requests#
Exponential backoff doubles the wait after each failure, so a struggling server gets breathing room instead of a retry storm. Jitter β a small random addition β stops a thousand clients that all failed at the same instant from retrying at the same instant.
Retry only requests that are safe to repeat: GET, HEAD, and other reads. Retrying a POST can double-submit. And retry only transient failures:
| Status | Retry? | Why |
|---|---|---|
429 Too Many Requests | β | You’re going too fast β slow down and obey Retry-After |
500 / 502 / 503 / 504 | β | Server-side hiccup, usually temporary |
403 / 401 | β | Permission problem β retrying won’t fix it β Anti-bot Patterns |
404 | β | It isn’t there. It won’t be there next time either |
Cap concurrency#
Async makes it trivially easy to open 500 connections at once and take a small site down. Bound it in two places:
import asyncio
import httpx
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
sem = asyncio.Semaphore(5) # at most 5 in flight, regardless of how many tasks exist
async def fetch(client: httpx.AsyncClient, url: str) -> str:
async with sem:
r = await client.get(url)
return r.text
async def main(urls: list[str]) -> list[str]:
async with httpx.AsyncClient(limits=limits, timeout=10) as client:
return await asyncio.gather(*(fetch(client, u) for u in urls))If robots.txt specifies a Crawl-delay, honour it β the checker in Legal & Ethical Scraping prints it for you.
Cache: the politest optimisation#
Most re-runs re-request pages that haven’t changed. A cache makes your scraper faster and dramatically reduces load on the target β the rare win-win. hishel adds standards-compliant HTTP caching to httpx with almost no code:
# /// script
# requires-python = ">=3.12"
# dependencies = ["hishel>=0.1"]
# ///
"""Second run is served from disk β the site never sees the request.
Run twice: uv run cached.py
"""
import time
import hishel
with hishel.CacheClient(storage=hishel.FileStorage(ttl=3600)) as client:
start = time.perf_counter()
r = client.get("https://httpbin.org/cache/60")
print(f"{r.status_code} in {time.perf_counter() - start:.3f}s from_cache={r.extensions.get('from_cache')}")Run it twice: the second run returns in near-zero time with from_cache=True.
When it fails#
| Symptom | Cause | Fix |
|---|---|---|
Fine locally, 429 in CI | No delay + a datacenter IP | Add backoff; lower concurrency |
| Retries make it worse | Retrying non-transient errors, no jitter | Retry only 429/5xx; add jitter |
| Retry loop never ends | No attempt cap | Always bound MAX_ATTEMPTS |
| Cache never hits | Target sends no-store, or URL varies | Check response headers; strip volatile query params |
| Duplicate records after a retry | Retried a non-idempotent write | Retry reads only; make writes idempotent |
Your turn (β15 min)#
- Run
polite_get.pyand record the delay sequence. Change the base from2**attemptto1.5**attemptand compare. - Point it at
https://httpbin.org/delay/3withtimeout=1β watch a timeout fail differently from a429, and decide whether it should retry. - Run
cached.pytwice and confirm the second run reportsfrom_cache=True. - Take the paginate loop from Pagination & Infinite Scroll and add backoff between pages.
Checklist#
- I retry only safe, transient failures (429/5xx on reads), never 403/404.
- I use exponential backoff with jitter and honour
Retry-After. - I always cap retry attempts so a loop fails loudly instead of forever.
- I bound concurrency with
httpx.Limitsand/or anasyncio.Semaphore. - I cache responses so re-runs don’t re-hit the target.
Go deeper#
- HTTP 429 β MDN and Retry-After β MDN β what the server is actually telling you.
- hishel β HTTP caching for httpx β drop-in, standards-compliant caching.
- httpx β Limits & connection pooling β bounding connections properly.