Retry-After is a sentence, not a suggestion
When a site says "slow down", it often says how long to wait. Reading that line, backing off with jitter and knowing when to give up is most of polite scraping.
A 429 Too Many Requests is a site telling you that you are too fast. Often it also tells you how long to wait, in a Retry-After header. That header is a full sentence from the site to your scraper, and most code throws it away and retries on a timer of its own invention.
What the header can hold#
The value is one of two things, and a scraper has to read both:
- a number of seconds:
Retry-After: 30 - an HTTP date:
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT
A 503 Service Unavailable can carry it too.
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def retry_after_seconds(value: str | None) -> float | None:
"""Retry-After is either a number of seconds or an HTTP date."""
if not value:
return None
value = value.strip()
if value.isdigit():
return float(value)
try:
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def wait_time(attempt: int, header: str | None, cap: float = 120.0) -> float:
told = retry_after_seconds(header)
if told is not None:
return min(told, cap) # the site's own number beats our guess
return min(cap, 2 ** attempt) * random.uniform(0.5, 1.0) # exponential backoff with jitterA date in the past reads as zero seconds, garbage reads as nothing, and a huge number is capped so that one odd header cannot park a run for a day. The jitter matters when many workers wait at once: without it they all come back in the same second and the site gets the same spike again.
A hiccup becomes a wall#
The point of waiting is that the problem is temporary. So set the rule before the run, not during it:
- Wait as long as the site says, or back off if it says nothing.
- Retry a small, fixed number of times. Three is a fine number.
- Come back slower than before. If you were asked to slow down, going back at the same rate is not an answer.
- If the same status is still there after you waited as told, it is no longer a hiccup. It is a wall with good manners. Stop and say so in the run status.
Things that are never retried#
- A page that asks you to prove you are human.
- An "access denied" that has nothing to do with the URL you asked for.
- A redirect to a login page.
- A
robots.txtrule that says no. The request is not even sent.
Those are answers, not errors. Robots.txt first covers the wall side in more detail, and the proxy ladder covers why we do not answer a wall with a bigger hammer.
Eight sample responses, and your call on each, are in the lab. Some are judgement calls. That is the point.
robots.txt readers disagree, and the built-in one is the odd one out
Python's built-in reader takes the first matching rule and ignores wildcards. The standard takes the longest match. We ran both on 12,000 random files and listed where they split.
The price is a string: reading money off a web page
Shops print the same number a dozen ways. A careful parser reads what is clear, leaves the rest empty and never fills a gap with a plausible guess.
What Apify taught us about publishing scrapers
Seven things we learned about publishing on the Apify Store: the automated tester, identity checks, rate-limited visibility changes, pay per result and the first "users".