Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Scraping & Browser Automation (Python and JavaScript)  Module 5 – Politeness, caching and robust crawls

Rate limits, 429 and Retry-After

Pace a collector below a site's limit with a token bucket or a jittered pause, obey 429 and Retry-After, and retry only failures that can succeed.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Limit requests to one host with a token bucket of your own or a fixed pause with jitter
  • Calculate the wait that Retry-After asks for, in seconds or as an HTTP date counted from the answer's Date
  • Schedule retries with exponential backoff, full jitter and a cap when an answer gives no Retry-After
  • Separate temporary failures from permanent ones, and retry only safe requests within a budget

Before you start

What you’ll learn · Before you start

What you will learn

  • Limit requests to one host with a token bucket of your own or a fixed pause with jitter
  • Calculate the wait that Retry-After asks for, in seconds or as an HTTP date counted from the answer's Date
  • Schedule retries with exponential backoff, full jitter and a cap when an answer gives no Retry-After
  • Separate temporary failures from permanent ones, and retry only safe requests within a budget

Before you start

On this page

The practice shop’s price API answers 5 requests at once and then 1 a second. A collector that asks for its 20 pages as fast as it can gets 5 of them, and one that simply asks again after every refusal sends 301 requests for those 20 pages, 281 of them refused.

This lesson is about the client’s side of a rate limit. You will read 429 Too Many Requests and its Retry-After, stay under a limit with a pause or a token bucket of your own, back off with jitter when the server names no time, and decide which failures deserve a retry at all. Every measurement below comes from the lab shop’s own access log.

A rate limit is a budget, and 429 says it is spent

Send the price API twenty requests in a row, with no pause between them, and read what comes back. Each request takes 50 ms on the shop’s clock:

Twenty requests in a row to a limited API

limits/burst.py

"""Twenty requests to the price API, one after another and with no pause: what its limit lets through."""
import nimbu_lab as lab

lab.reset()
answers = []
for page in range(1, 21):
    status, headers = lab.get(f"/api/v1/prices?page={page}")
    answers.append((page, status, headers["Retry-After"]))

print("page  status  Retry-After")
for page, status, retry_after in answers:
    if page <= 6 or page == 20:
        print(f"{page:>4}  {status:>6}  {retry_after or '-'}")
    elif page == 7:
        print("   …       …  …")
ok = sum(status == 200 for _, status, _ in answers)
print(f"{ok} answered and {len(answers) - ok} refused in {lab.clock.now():.2f} s on the shop's clock")

Output

page  status  Retry-After
   1     200  -
   2     200  -
   3     200  -
   4     200  -
   5     200  -
   6     429  1
   …       …  …
  20     429  1
5 answered and 15 refused in 1.00 s on the shop's clock

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 burst.py

nimbu_lab.py (the shop) · limits/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track, with the pages of the rate-limit lesson. Every product, price and
person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers, and one change: the last step hands each request to app() instead of opening
a socket, so nothing leaves your program. Time is the shop's clock. Each request takes 50 ms of it, and sleep() moves
it on instead of waiting, so a run that spans minutes ends at once and prints the same numbers every time.

Two hosts answer:
  bazaar.nimbu.test  the price API, /api/v1/prices?page=1 to 20: a token bucket per client, 5 requests at once and
                     1 more each second, then 429 Too Many Requests with Retry-After
  flaky.nimbu.test   /board (closed for maintenance for the first 90 s, 503 with Retry-After as an HTTP date),
                     /stock?item=n (serves 5 requests in each second and answers the rest 503), /report/<name>
                     (each name fails in its own way, see REPORTS) and /catalogue?page=n (503 to everything while
                     OUTAGE["on"] is True)
"""
import email.utils
import http.client
import io
import json
import math
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

SHOP = "bazaar.nimbu.test"
FLAKY = "flaky.nimbu.test"
EPOCH = 1_800_000_000  # the clock reads 0 at this moment, in seconds since 1970 (what Date headers are made from)
LATENCY = 0.05  # seconds a request takes, there and back


class Clock:
    """The shop's clock, in seconds. sleep() moves it on instead of waiting."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += max(0.0, seconds)


clock = Clock()
LOG = []  # what the shop saw: one entry per request
SKEW = {"flaky": 0.0}  # how far the flaky host's clock is from yours: -40.0 means 40 s behind
COUNTS = {}  # attempts per /report/ path, so a page can fail twice and then work
OUTAGE = {"on": False}  # True: the flaky host's catalogue is down


class Bucket:
    """The API's limit for one client: at most `capacity` tokens, `rate` new tokens a second, one per request."""

    def __init__(self, capacity, rate, now):
        self.capacity, self.rate, self.tokens, self.at = capacity, rate, float(capacity), now

    def take(self, now):
        """(True, 0) when a token was taken; (False, seconds until the next one) when the bucket is empty."""
        self.tokens = min(self.capacity, self.tokens + (now - self.at) * self.rate)
        self.at = now
        if self.tokens >= 1 - 1e-9:
            self.tokens -= 1
            return True, 0.0
        return False, (1 - self.tokens) / self.rate


BUCKETS = {}  # one Bucket per client, told apart by User-Agent
PER_SECOND = {}  # /stock requests seen in each whole second


def reset(skew=0.0):
    """A fresh shop: clock at 0, empty log, full buckets; the flaky host's clock `skew` seconds from yours."""
    clock.seconds = 0.0
    for store in (LOG, BUCKETS, COUNTS, PER_SECOND):
        store.clear()
    SKEW["flaky"] = skew
    OUTAGE["on"] = False


def http_date(seconds):
    """An HTTP date for a moment on the shop's clock, as Date and Retry-After carry it."""
    return email.utils.formatdate(EPOCH + seconds, usegmt=True)


def answer(status, body="", kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def prices(query, who, now):
    page = int(query.get("page", "1"))
    if not 1 <= page <= 20:
        return answer("404 Not Found", "no such page\n")
    bucket = BUCKETS.setdefault(who, Bucket(5, 1.0, now))
    ok, wait = bucket.take(now)
    if not ok:  # RFC 6585: 429 may say how long to wait; delay-seconds is a whole number
        return answer("429 Too Many Requests", json.dumps({"error": "5 requests at once, then 1 a second"}),
                      "application/json", [("Retry-After", str(math.ceil(wait)))])
    rows = [{"item": f"NB-{(page - 1) * 3 + i + 1:04d}", "paise": 2500 + 75 * ((page * 3 + i) % 40)} for i in range(3)]
    return answer("200 OK", json.dumps({"page": page, "prices": rows}), "application/json")


REPORTS = {  # /report/<name>: the answers each one gives, attempt after attempt (the last one repeats)
    "daily": ["200 OK"],
    "weekly": ["503 Service Unavailable", "503 Service Unavailable", "200 OK"],
    "monthly": ["429 Too Many Requests", "200 OK"],
    "archive": ["404 Not Found"],
    "old": ["410 Gone"],
    "staff": ["403 Forbidden"],
    "broken": ["500 Internal Server Error"],
    "gateway": ["504 Gateway Timeout", "200 OK"],
    "bad-query": ["400 Bad Request"],
}


def flaky(method, path, query, now):
    here = now + SKEW["flaky"]  # what the flaky host's own clock says
    dated = [("Date", http_date(here))]
    if path == "/board":
        if now < 90:  # maintenance: back at 90 s on your clock, which its own clock calls 90 + skew
            return answer("503 Service Unavailable", "the price board is being rebuilt\n",
                          extra=[*dated, ("Retry-After", http_date(90 + SKEW["flaky"]))])
        return answer("200 OK", "price board: 40 rows\n", extra=dated)
    if path == "/stock":
        second = int(now)
        PER_SECOND[second] = PER_SECOND.get(second, 0) + 1
        if PER_SECOND[second] > 5:  # overloaded: no Retry-After, because it cannot know when it will recover
            return answer("503 Service Unavailable", "too busy\n", extra=dated)
        return answer("200 OK", f"stock of item {query.get('item', '?')}: 12\n", extra=dated)
    if path == "/catalogue":
        if OUTAGE["on"]:
            return answer("503 Service Unavailable", "the catalogue is down\n", extra=dated)
        return answer("200 OK", f"catalogue page {query.get('page', '1')}\n", extra=dated)
    if path == "/orders" and method == "POST":
        return answer("503 Service Unavailable", "orders are paused\n", extra=dated)
    name = path.removeprefix("/report/")
    if path.startswith("/report/") and name in REPORTS:
        n = COUNTS[path] = COUNTS.get(path, 0) + 1
        status = REPORTS[name][min(n, len(REPORTS[name])) - 1]
        extra = [("Retry-After", "2")] if status.startswith("429") else []
        return answer(status, f"{name} report\n" if status.startswith("200") else "", extra=[*dated, *extra])
    return answer("404 Not Found", "no such page\n", extra=dated)


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    now = clock.now()
    host, method, path = environ.get("HTTP_HOST", ""), environ["REQUEST_METHOD"], environ["PATH_INFO"]
    query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
    who = environ.get("HTTP_USER_AGENT", "")
    if host == SHOP and path == "/api/v1/prices":
        status, headers, body = prices(query, who, now)
    elif host == FLAKY:
        status, headers, body = flaky(method, path, query, now)
    elif host == SHOP:
        status, headers, body = answer("404 Not Found", "no such page\n")
    else:
        status, headers, body = answer("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    target = path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"t": round(now, 3), "host": host, "method": method, "target": target, "status": int(status[:3]),
                "retry_after": dict(headers).get("Retry-After"), "who": who})
    start_response(status, headers)
    return [body]


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data)}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
        got = {}
        body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
        clock.sleep(LATENCY)  # the answer arrives 50 ms after the request left
        raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
        response.reason = response.msg = got["status"][4:]
        return response

    http_open = https_open


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network."""
    director = urllib.request.OpenerDirector()
    for handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPRedirectHandler(),
                    urllib.request.HTTPErrorProcessor(), urllib.request.UnknownHandler(), *handlers]:
        director.add_handler(handler)
    return director


def get(path, host=SHOP, who="NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", method="GET"):
    """One request through urllib: (status, headers). urllib raises HTTPError for 4xx and 5xx; this returns them."""
    request = urllib.request.Request(f"https://{host}{path}", headers={"User-Agent": who}, method=method,
                                     data=b"{}" if method == "POST" else None)
    try:
        with OPENER.open(request, timeout=10) as response:
            response.read()
            return response.status, response.headers
    except urllib.error.HTTPError as error:
        error.read()
        return error.code, error.headers


OPENER = opener()

Five answers came back with data and fifteen with 429 and Retry-After: 1. RFC 6585 created 429 to tell a client it “has sent too many requests in a given amount of time”, and the answer may name, in Retry-After, how long to wait. The standard leaves the counting to each server: per resource, across a site or across a group of servers, with the client told apart by its credentials or a cookie. Every API has a rate limit: The number of requests an API allows a client in a time window, such as 100 an hour. Answers often report how many are left, and requests beyond it are refused, usually with 429. More in “Use the API first: JSON, keys and quotas” of its own, so read its documentation and its answers rather than guessing.

The shop counts with a token bucket: A way to limit a rate: a bucket holds up to a set number of tokens, each request spends one, and tokens come back at a steady rate, so a short burst passes and then a steady pace. per client. The bucket holds 5 tokens, every request spends one, and one token comes back each second, so a burst of 5 passes and after that 1 a second does. That is why requests 1 to 5 succeeded within 0.2 s and the next fifteen, sent before a token came back, did not. Try the same bucket yourself:

Interactive model The shop's limit: a bucket of 5 tokens, 1 more each second

Send requests one at a time or in a burst, let time pass, and change the bucket size and the refill rate. A request needs one token; without one it is rejected with 429.

At the start: The bucket holds 5 of 5 tokens and gains 1 token a second, so up to 5 requests pass at once and 1 a second keep passing after that. No requests yet.

Send a burst of ten and watch five pass; wait two seconds and two more pass. Over a minute that starts with a full bucket, at most 65 requests can succeed: the 5 of the burst and one for each of the 60 seconds. Asking faster does not raise that number, it only adds refusals.

Not every limit says 429

RFC 6585 does not require a server to answer every excess request with 429: under a flood it may simply drop connections. A burst of timeouts or reset connections from a host you are pressing hard means the same as 429: slow down.

Stay under the limit: a pause with jitter, or a bucket of your own

A refusal costs you a request and costs the server work. The next program fetches the same 20 pages four ways, each until every page has arrived, and counts what reached the shop:

The same 20 pages fetched four ways

limits/pace.py

"""The same 20 pages fetched four ways: retrying at once, obeying Retry-After, a pause with jitter, a token bucket.
Every page must arrive in the end; the shop's log counts what each way cost it."""
import random

import nimbu_lab as lab


class TokenBucket:
    """The collector's own limit: `burst` requests may start at once, then `rate` a second."""

    def __init__(self, rate, burst, now):
        self.rate, self.burst, self.tokens, self.at = rate, burst, float(burst), now

    def wait(self, now):
        """Seconds to wait before the next request may start (0 if a token is there); the token is then spent."""
        self.tokens = min(self.burst, self.tokens + (now - self.at) * self.rate)
        self.at = now
        wait = 0.0 if self.tokens >= 1 else (1 - self.tokens) / self.rate
        self.tokens += wait * self.rate - 1  # the tokens earned while waiting, minus the one this request spends
        self.at = now + wait
        return wait


def fetch_all(before_each=None, obey_retry_after=False):
    lab.reset()
    for page in range(1, 21):
        while True:
            if before_each:
                lab.clock.sleep(before_each())
            status, headers = lab.get(f"/api/v1/prices?page={page}")
            if status == 200:
                break
            if obey_retry_after:
                lab.clock.sleep(int(headers["Retry-After"]))
    refused = sum(entry["status"] == 429 for entry in lab.LOG)
    return len(lab.LOG), refused, lab.clock.now()


rng = random.Random(7)  # seeded, so the jitter is the same on every run
pause, spread = 1.1, 0.10  # a pause of 1.1 s, varied by up to 10 % either way
bucket = TokenBucket(rate=0.9, burst=3, now=0.0)
ways = [
    ("ask again at once", fetch_all()),
    ("wait for Retry-After", fetch_all(obey_retry_after=True)),
    (f"pause {pause:g} s, ±{spread * 100:g} % jitter",
     fetch_all(before_each=lambda: rng.uniform(pause * (1 - spread), pause * (1 + spread)))),
    (f"own bucket: {bucket.burst}, then {bucket.rate:g}/s",
     fetch_all(before_each=lambda: bucket.wait(lab.clock.now()))),
]
print(f"{'way':<28}{'requests':>9}{'refused':>9}{'done at':>9}")
for name, (requests, refused, done) in ways:
    print(f"{name:<28}{requests:>9}{refused:>9}{done:>8.1f}s")

Output

way                          requests  refused  done at
ask again at once                 301      281    15.1s
wait for Retry-After               34       14    15.7s
pause 1.1 s, ±10 % jitter          20        0    22.4s
own bucket: 3, then 0.9/s          20        0    18.9s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pace.py

nimbu_lab.py (the shop) · limits/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track, with the pages of the rate-limit lesson. Every product, price and
person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers, and one change: the last step hands each request to app() instead of opening
a socket, so nothing leaves your program. Time is the shop's clock. Each request takes 50 ms of it, and sleep() moves
it on instead of waiting, so a run that spans minutes ends at once and prints the same numbers every time.

Two hosts answer:
  bazaar.nimbu.test  the price API, /api/v1/prices?page=1 to 20: a token bucket per client, 5 requests at once and
                     1 more each second, then 429 Too Many Requests with Retry-After
  flaky.nimbu.test   /board (closed for maintenance for the first 90 s, 503 with Retry-After as an HTTP date),
                     /stock?item=n (serves 5 requests in each second and answers the rest 503), /report/<name>
                     (each name fails in its own way, see REPORTS) and /catalogue?page=n (503 to everything while
                     OUTAGE["on"] is True)
"""
import email.utils
import http.client
import io
import json
import math
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

SHOP = "bazaar.nimbu.test"
FLAKY = "flaky.nimbu.test"
EPOCH = 1_800_000_000  # the clock reads 0 at this moment, in seconds since 1970 (what Date headers are made from)
LATENCY = 0.05  # seconds a request takes, there and back


class Clock:
    """The shop's clock, in seconds. sleep() moves it on instead of waiting."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += max(0.0, seconds)


clock = Clock()
LOG = []  # what the shop saw: one entry per request
SKEW = {"flaky": 0.0}  # how far the flaky host's clock is from yours: -40.0 means 40 s behind
COUNTS = {}  # attempts per /report/ path, so a page can fail twice and then work
OUTAGE = {"on": False}  # True: the flaky host's catalogue is down


class Bucket:
    """The API's limit for one client: at most `capacity` tokens, `rate` new tokens a second, one per request."""

    def __init__(self, capacity, rate, now):
        self.capacity, self.rate, self.tokens, self.at = capacity, rate, float(capacity), now

    def take(self, now):
        """(True, 0) when a token was taken; (False, seconds until the next one) when the bucket is empty."""
        self.tokens = min(self.capacity, self.tokens + (now - self.at) * self.rate)
        self.at = now
        if self.tokens >= 1 - 1e-9:
            self.tokens -= 1
            return True, 0.0
        return False, (1 - self.tokens) / self.rate


BUCKETS = {}  # one Bucket per client, told apart by User-Agent
PER_SECOND = {}  # /stock requests seen in each whole second


def reset(skew=0.0):
    """A fresh shop: clock at 0, empty log, full buckets; the flaky host's clock `skew` seconds from yours."""
    clock.seconds = 0.0
    for store in (LOG, BUCKETS, COUNTS, PER_SECOND):
        store.clear()
    SKEW["flaky"] = skew
    OUTAGE["on"] = False


def http_date(seconds):
    """An HTTP date for a moment on the shop's clock, as Date and Retry-After carry it."""
    return email.utils.formatdate(EPOCH + seconds, usegmt=True)


def answer(status, body="", kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def prices(query, who, now):
    page = int(query.get("page", "1"))
    if not 1 <= page <= 20:
        return answer("404 Not Found", "no such page\n")
    bucket = BUCKETS.setdefault(who, Bucket(5, 1.0, now))
    ok, wait = bucket.take(now)
    if not ok:  # RFC 6585: 429 may say how long to wait; delay-seconds is a whole number
        return answer("429 Too Many Requests", json.dumps({"error": "5 requests at once, then 1 a second"}),
                      "application/json", [("Retry-After", str(math.ceil(wait)))])
    rows = [{"item": f"NB-{(page - 1) * 3 + i + 1:04d}", "paise": 2500 + 75 * ((page * 3 + i) % 40)} for i in range(3)]
    return answer("200 OK", json.dumps({"page": page, "prices": rows}), "application/json")


REPORTS = {  # /report/<name>: the answers each one gives, attempt after attempt (the last one repeats)
    "daily": ["200 OK"],
    "weekly": ["503 Service Unavailable", "503 Service Unavailable", "200 OK"],
    "monthly": ["429 Too Many Requests", "200 OK"],
    "archive": ["404 Not Found"],
    "old": ["410 Gone"],
    "staff": ["403 Forbidden"],
    "broken": ["500 Internal Server Error"],
    "gateway": ["504 Gateway Timeout", "200 OK"],
    "bad-query": ["400 Bad Request"],
}


def flaky(method, path, query, now):
    here = now + SKEW["flaky"]  # what the flaky host's own clock says
    dated = [("Date", http_date(here))]
    if path == "/board":
        if now < 90:  # maintenance: back at 90 s on your clock, which its own clock calls 90 + skew
            return answer("503 Service Unavailable", "the price board is being rebuilt\n",
                          extra=[*dated, ("Retry-After", http_date(90 + SKEW["flaky"]))])
        return answer("200 OK", "price board: 40 rows\n", extra=dated)
    if path == "/stock":
        second = int(now)
        PER_SECOND[second] = PER_SECOND.get(second, 0) + 1
        if PER_SECOND[second] > 5:  # overloaded: no Retry-After, because it cannot know when it will recover
            return answer("503 Service Unavailable", "too busy\n", extra=dated)
        return answer("200 OK", f"stock of item {query.get('item', '?')}: 12\n", extra=dated)
    if path == "/catalogue":
        if OUTAGE["on"]:
            return answer("503 Service Unavailable", "the catalogue is down\n", extra=dated)
        return answer("200 OK", f"catalogue page {query.get('page', '1')}\n", extra=dated)
    if path == "/orders" and method == "POST":
        return answer("503 Service Unavailable", "orders are paused\n", extra=dated)
    name = path.removeprefix("/report/")
    if path.startswith("/report/") and name in REPORTS:
        n = COUNTS[path] = COUNTS.get(path, 0) + 1
        status = REPORTS[name][min(n, len(REPORTS[name])) - 1]
        extra = [("Retry-After", "2")] if status.startswith("429") else []
        return answer(status, f"{name} report\n" if status.startswith("200") else "", extra=[*dated, *extra])
    return answer("404 Not Found", "no such page\n", extra=dated)


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    now = clock.now()
    host, method, path = environ.get("HTTP_HOST", ""), environ["REQUEST_METHOD"], environ["PATH_INFO"]
    query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
    who = environ.get("HTTP_USER_AGENT", "")
    if host == SHOP and path == "/api/v1/prices":
        status, headers, body = prices(query, who, now)
    elif host == FLAKY:
        status, headers, body = flaky(method, path, query, now)
    elif host == SHOP:
        status, headers, body = answer("404 Not Found", "no such page\n")
    else:
        status, headers, body = answer("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    target = path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"t": round(now, 3), "host": host, "method": method, "target": target, "status": int(status[:3]),
                "retry_after": dict(headers).get("Retry-After"), "who": who})
    start_response(status, headers)
    return [body]


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data)}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
        got = {}
        body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
        clock.sleep(LATENCY)  # the answer arrives 50 ms after the request left
        raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
        response.reason = response.msg = got["status"][4:]
        return response

    http_open = https_open


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network."""
    director = urllib.request.OpenerDirector()
    for handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPRedirectHandler(),
                    urllib.request.HTTPErrorProcessor(), urllib.request.UnknownHandler(), *handlers]:
        director.add_handler(handler)
    return director


def get(path, host=SHOP, who="NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", method="GET"):
    """One request through urllib: (status, headers). urllib raises HTTPError for 4xx and 5xx; this returns them."""
    request = urllib.request.Request(f"https://{host}{path}", headers={"User-Agent": who}, method=method,
                                     data=b"{}" if method == "POST" else None)
    try:
        with OPENER.open(request, timeout=10) as response:
            response.read()
            return response.status, response.headers
    except urllib.error.HTTPError as error:
        error.read()
        return error.code, error.headers


OPENER = opener()

Asking again at once finished soonest, in 15.1 s, with 281 refused requests. Waiting for Retry-After cut the refusals to 14, one for each page that came too early, and took 15.7 s. The two ways that stay under the limit were never refused and sent exactly 20 requests. That is the aim: a collector paced below the limit never sees a 429, and its first 429 says its pace is wrong.

A fixed pause is the simplest pace: wait a little longer than the limit allows between requests, here 1.1 s against the shop’s 1 a second. Add jitter: A random part added to a wait, such as a pause of 1.1 s varied by up to 10 %, so that clients started at the same moment stop sending their requests in step., a random part of the pause, here ±10 %. Many collectors start at the same second, because a scheduler starts them at the top of a minute; the Amazon Builders’ Library describes clients that line up this way, and adds jitter to timers and periodic jobs to spread them apart. Scrapy’s DOWNLOAD_DELAY is such a pause between two requests to one domain, and Scrapy 2.19 varies it by ±50 % by default (DOWNLOAD_DELAY_JITTER).

A token bucket of your own paces the starts of requests instead of the gaps between them. With 3 tokens and 0.9 a second it sent three pages at once, then one every 1.11 s, and finished in 18.9 s against the fixed pause’s 22.4 s. Step through where its waiting happens:

Interactive · message by message Your own bucket keeps you under the shop's Step through the first requests of pace.py's last way and see where the waiting happens, and where it never does.
Your own bucket keeps you under the shop's, step 6 of 6. Messages so far: Your collector to Price API: pages 1 to 3; Price API to Your collector: 200, 200, 200; Your collector to Price API: page 4 at 1.11 s; Your collector to Price API: pages 5, 6, 7 ….Your collector20 sent, 0 refusedPrice API3.89 tokens to sparepages 1 to 3200, 200, 200page 4 at 1.11 spages 5, 6, 7 …

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Three at once. Your bucket starts with 3 tokens, so pages 1, 2 and 3 leave at once, 50 ms apart. The API spends a token of its own on each and has about 2 of its 5 left.
  2. Three answers. All three are answered with 200. Nothing has been refused, and nothing will be: the next decision is taken on your side.
  3. Wait on your side. Page 4 needs a token your bucket does not have yet. It waits 0.96 s before sending, so the API never sees a request it would have to refuse.
  4. Page 4 at 1.11 s. Page 4 leaves at 1.11 s. The API has earned a token a second in the meantime and still has 2 left after it.
  5. One every 1.11 s. From now on a page leaves every 1.11 s, your rate of 0.9 a second. The API earns a token every second, a little faster than you spend them, so its bucket fills up instead of running dry.
  6. Twenty pages, none refused. Page 20 leaves at 18.89 s. The run sent 20 requests for 20 pages and none was refused; the API still had 3.89 tokens to spare.

Pace at the API's own numbers, then above them

Press Edit on pace.py and set the bucket to rate=1.0, burst=5, the API’s own numbers: in the lab it finishes in 15.1 s with no refusal, because nothing else spends the key. Then set rate=1.2: three requests are refused. A real key is shared with other jobs and retries, so a real collector keeps a margin, as 0.9 does here.

Checkpoint

Check: A rate limit as a budget of tokens · Pacing below the limit

  1. Question 1 of 2 The price API's bucket holds 5 tokens and gains 1 a second. Starting full, how many requests can it answer in the first 60 seconds at most, however fast a client asks?

    Type a number.

    A hint

    Count the burst, then the tokens that come back.

    Show the answer: Question 1 of 2

    Answer: 65 requests

    The full bucket lets a burst of 5 through, and each of the 60 seconds brings one token back: 5 + 60 = 65. Asking faster changes nothing but the number of refusals, which is why the client that asked again at once sent 301 requests for 20 pages.

  2. Question 2 of 2 Forty collectors on forty machines start at the top of every hour and pause exactly 1.1 s between their requests to the same API. What does adding ±10 % jitter to the pause change?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Their requests drift apart instead of arriving in step, so the API sees a steady flow rather than forty at once

    Collectors started by a scheduler at the same second, with the same fixed pause, send their requests at the same moments for as long as they run. Jitter, here a pause drawn between 0.99 and 1.21 s, breaks that lockstep.

Retry-After says how long; count a date by the server’s clock

Retry-After comes in two forms, and RFC 9110 defines both: a whole number of seconds, such as 120, or an HTTP date, the moment when the server expects to be ready again. Sent with 503 Service Unavailable it says how long the service is expected to be unavailable. A number of seconds is counted from when the answer arrived, so it means the same on every computer. A date is a moment on the server’s clock, and your clock is not the server’s.

The flaky host closes its price board for 90 s and says until when as a date, by a clock that runs 40 s behind yours. Two collectors wait for the board: one counts the date from its own clock, the other from the Date header of the same answer:

One date, two clocks

limits/retry_after.py

"""Retry-After in its two forms. The board is closed for maintenance for 90 s and says until when as an HTTP date,
by a clock that runs 40 s behind yours. Two clients wait for it: one counts the date from its own clock, the other
from the Date header of the same answer."""
import email.utils

import nimbu_lab as lab


def seconds_to_wait(headers, own_clock):
    """How long Retry-After asks for: a number of seconds, or an HTTP date counted from `own_clock` (epoch seconds)
    or, when own_clock is None, from the Date header of the same answer."""
    value = headers["Retry-After"].strip()
    if value.isdigit():
        return int(value)
    when = email.utils.parsedate_to_datetime(value).timestamp()
    base = own_clock if own_clock is not None else email.utils.parsedate_to_datetime(headers["Date"]).timestamp()
    return when - base


def wait_for_board(use_date_header):
    lab.reset(skew=-40.0)  # the flaky host's clock is 40 s behind yours
    waits = []
    while True:
        status, headers = lab.get("/board", host=lab.FLAKY)
        if status == 200:
            return len(lab.LOG), waits
        own = None if use_date_header else lab.EPOCH + lab.clock.now()
        wait = max(1.0, seconds_to_wait(headers, own))  # never ask again sooner than a second
        waits.append(round(wait, 2))
        lab.clock.sleep(wait)


lab.reset(skew=-40.0)
status, headers = lab.get("/board", host=lab.FLAKY)
print("Your clock:", lab.http_date(0))
print("The answer:", status, "| Date:", headers["Date"], "| Retry-After:", headers["Retry-After"])
print()
for label, use_date in [("counted from your own clock", False), ("counted from its Date header", True)]:
    requests, waits = wait_for_board(use_date)
    shown = ", ".join(f"{w:g}" for w in waits[:4]) + (", …" if len(waits) > 4 else "")
    print(f"{label}: {requests} requests, waits of {shown} s, open at {lab.clock.now():.1f} s")

Output

Your clock: Fri, 15 Jan 2027 08:00:00 GMT
The answer: 503 | Date: Fri, 15 Jan 2027 07:59:20 GMT | Retry-After: Fri, 15 Jan 2027 08:00:50 GMT

counted from your own clock: 41 requests, waits of 49.95, 1, 1, 1, … s, open at 91.0 s
counted from its Date header: 2 requests, waits of 90 s, open at 90.1 s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry_after.py

nimbu_lab.py (the shop) · limits/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track, with the pages of the rate-limit lesson. Every product, price and
person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers, and one change: the last step hands each request to app() instead of opening
a socket, so nothing leaves your program. Time is the shop's clock. Each request takes 50 ms of it, and sleep() moves
it on instead of waiting, so a run that spans minutes ends at once and prints the same numbers every time.

Two hosts answer:
  bazaar.nimbu.test  the price API, /api/v1/prices?page=1 to 20: a token bucket per client, 5 requests at once and
                     1 more each second, then 429 Too Many Requests with Retry-After
  flaky.nimbu.test   /board (closed for maintenance for the first 90 s, 503 with Retry-After as an HTTP date),
                     /stock?item=n (serves 5 requests in each second and answers the rest 503), /report/<name>
                     (each name fails in its own way, see REPORTS) and /catalogue?page=n (503 to everything while
                     OUTAGE["on"] is True)
"""
import email.utils
import http.client
import io
import json
import math
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

SHOP = "bazaar.nimbu.test"
FLAKY = "flaky.nimbu.test"
EPOCH = 1_800_000_000  # the clock reads 0 at this moment, in seconds since 1970 (what Date headers are made from)
LATENCY = 0.05  # seconds a request takes, there and back


class Clock:
    """The shop's clock, in seconds. sleep() moves it on instead of waiting."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += max(0.0, seconds)


clock = Clock()
LOG = []  # what the shop saw: one entry per request
SKEW = {"flaky": 0.0}  # how far the flaky host's clock is from yours: -40.0 means 40 s behind
COUNTS = {}  # attempts per /report/ path, so a page can fail twice and then work
OUTAGE = {"on": False}  # True: the flaky host's catalogue is down


class Bucket:
    """The API's limit for one client: at most `capacity` tokens, `rate` new tokens a second, one per request."""

    def __init__(self, capacity, rate, now):
        self.capacity, self.rate, self.tokens, self.at = capacity, rate, float(capacity), now

    def take(self, now):
        """(True, 0) when a token was taken; (False, seconds until the next one) when the bucket is empty."""
        self.tokens = min(self.capacity, self.tokens + (now - self.at) * self.rate)
        self.at = now
        if self.tokens >= 1 - 1e-9:
            self.tokens -= 1
            return True, 0.0
        return False, (1 - self.tokens) / self.rate


BUCKETS = {}  # one Bucket per client, told apart by User-Agent
PER_SECOND = {}  # /stock requests seen in each whole second


def reset(skew=0.0):
    """A fresh shop: clock at 0, empty log, full buckets; the flaky host's clock `skew` seconds from yours."""
    clock.seconds = 0.0
    for store in (LOG, BUCKETS, COUNTS, PER_SECOND):
        store.clear()
    SKEW["flaky"] = skew
    OUTAGE["on"] = False


def http_date(seconds):
    """An HTTP date for a moment on the shop's clock, as Date and Retry-After carry it."""
    return email.utils.formatdate(EPOCH + seconds, usegmt=True)


def answer(status, body="", kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def prices(query, who, now):
    page = int(query.get("page", "1"))
    if not 1 <= page <= 20:
        return answer("404 Not Found", "no such page\n")
    bucket = BUCKETS.setdefault(who, Bucket(5, 1.0, now))
    ok, wait = bucket.take(now)
    if not ok:  # RFC 6585: 429 may say how long to wait; delay-seconds is a whole number
        return answer("429 Too Many Requests", json.dumps({"error": "5 requests at once, then 1 a second"}),
                      "application/json", [("Retry-After", str(math.ceil(wait)))])
    rows = [{"item": f"NB-{(page - 1) * 3 + i + 1:04d}", "paise": 2500 + 75 * ((page * 3 + i) % 40)} for i in range(3)]
    return answer("200 OK", json.dumps({"page": page, "prices": rows}), "application/json")


REPORTS = {  # /report/<name>: the answers each one gives, attempt after attempt (the last one repeats)
    "daily": ["200 OK"],
    "weekly": ["503 Service Unavailable", "503 Service Unavailable", "200 OK"],
    "monthly": ["429 Too Many Requests", "200 OK"],
    "archive": ["404 Not Found"],
    "old": ["410 Gone"],
    "staff": ["403 Forbidden"],
    "broken": ["500 Internal Server Error"],
    "gateway": ["504 Gateway Timeout", "200 OK"],
    "bad-query": ["400 Bad Request"],
}


def flaky(method, path, query, now):
    here = now + SKEW["flaky"]  # what the flaky host's own clock says
    dated = [("Date", http_date(here))]
    if path == "/board":
        if now < 90:  # maintenance: back at 90 s on your clock, which its own clock calls 90 + skew
            return answer("503 Service Unavailable", "the price board is being rebuilt\n",
                          extra=[*dated, ("Retry-After", http_date(90 + SKEW["flaky"]))])
        return answer("200 OK", "price board: 40 rows\n", extra=dated)
    if path == "/stock":
        second = int(now)
        PER_SECOND[second] = PER_SECOND.get(second, 0) + 1
        if PER_SECOND[second] > 5:  # overloaded: no Retry-After, because it cannot know when it will recover
            return answer("503 Service Unavailable", "too busy\n", extra=dated)
        return answer("200 OK", f"stock of item {query.get('item', '?')}: 12\n", extra=dated)
    if path == "/catalogue":
        if OUTAGE["on"]:
            return answer("503 Service Unavailable", "the catalogue is down\n", extra=dated)
        return answer("200 OK", f"catalogue page {query.get('page', '1')}\n", extra=dated)
    if path == "/orders" and method == "POST":
        return answer("503 Service Unavailable", "orders are paused\n", extra=dated)
    name = path.removeprefix("/report/")
    if path.startswith("/report/") and name in REPORTS:
        n = COUNTS[path] = COUNTS.get(path, 0) + 1
        status = REPORTS[name][min(n, len(REPORTS[name])) - 1]
        extra = [("Retry-After", "2")] if status.startswith("429") else []
        return answer(status, f"{name} report\n" if status.startswith("200") else "", extra=[*dated, *extra])
    return answer("404 Not Found", "no such page\n", extra=dated)


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    now = clock.now()
    host, method, path = environ.get("HTTP_HOST", ""), environ["REQUEST_METHOD"], environ["PATH_INFO"]
    query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
    who = environ.get("HTTP_USER_AGENT", "")
    if host == SHOP and path == "/api/v1/prices":
        status, headers, body = prices(query, who, now)
    elif host == FLAKY:
        status, headers, body = flaky(method, path, query, now)
    elif host == SHOP:
        status, headers, body = answer("404 Not Found", "no such page\n")
    else:
        status, headers, body = answer("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    target = path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"t": round(now, 3), "host": host, "method": method, "target": target, "status": int(status[:3]),
                "retry_after": dict(headers).get("Retry-After"), "who": who})
    start_response(status, headers)
    return [body]


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data)}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
        got = {}
        body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
        clock.sleep(LATENCY)  # the answer arrives 50 ms after the request left
        raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
        response.reason = response.msg = got["status"][4:]
        return response

    http_open = https_open


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network."""
    director = urllib.request.OpenerDirector()
    for handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPRedirectHandler(),
                    urllib.request.HTTPErrorProcessor(), urllib.request.UnknownHandler(), *handlers]:
        director.add_handler(handler)
    return director


def get(path, host=SHOP, who="NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", method="GET"):
    """One request through urllib: (status, headers). urllib raises HTTPError for 4xx and 5xx; this returns them."""
    request = urllib.request.Request(f"https://{host}{path}", headers={"User-Agent": who}, method=method,
                                     data=b"{}" if method == "POST" else None)
    try:
        with OPENER.open(request, timeout=10) as response:
            response.read()
            return response.status, response.headers
    except urllib.error.HTTPError as error:
        error.read()
        return error.code, error.headers


OPENER = opener()

Counted from your own clock, the date was 50 s away, so the first collector came back 40 s early; after that the date looked past, and with a floor of one second it asked again and again: 41 requests. The second subtracted the answer’s Date from its Retry-After, both read from the same clock, got exactly 90 s and needed 2 requests. Step through both:

Interactive · message by message A Retry-After date, read by the wrong clock and the right one Step through two collectors waiting for the same closed board, one counting the date from its own clock, one from the answer's Date header.
A Retry-After date, read by the wrong clock and the right one, step 6 of 6. Messages so far: Your collector to flaky.nimbu.test: GET /board; flaky.nimbu.test to Your collector: 503, Retry-After 08:00:50; Your collector to flaky.nimbu.test: 40 more requests; flaky.nimbu.test to Your collector: 200 OK.Your collector2 requestsflaky.nimbu.testboard openGET /board503, Retry-After 08:00:5040 more requests200 OK

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The board is closed. You ask for /board at 08:00:00 by your clock. The host is rebuilding it for 90 s, until 08:01:30 by your clock.
  2. 503 with a date. The host answers 503 with Date 07:59:20 and Retry-After 08:00:50, both read from its own clock, which runs 40 s behind yours.
  3. Counted from your clock. 08:00:50 minus your 08:00:00 is 50 s. The collector comes back after 50 s, when the host is 50 s into its 90 s of work.
  4. The date looks past. The second 503 names the same 08:00:50, which your clock has already passed, so the wait comes out negative. With a floor of 1 s, the collector asks every second until 90 s: 41 requests in all.
  5. Counted from its Date. A second collector subtracts the answer's own Date instead: 08:00:50 minus 07:59:20 is 90 s, the length of the maintenance, whatever either clock says.
  6. Open at 90 s. It asks once more at 90 s and gets the board: 2 requests in all, the second one at the right moment.

The idea is not new: RFC 9111 computes how long a cached response stays fresh as Expires minus Date for the same reason, “to reduce clock skew” by using the origin server’s own clock. One caution: RFC 9110 lets a server leave out Date on 5xx answers, so fall back to your own clock when it is missing.

A long Retry-After is a reason to move on

urllib3’s Retry obeys Retry-After on 413, 429 and 503 answers by default and caps it at 21,600 s, six hours. In a crawl of many hosts, do not sleep through a wait of an hour: park that host’s queue until the time it named, as the breaker of the blocks lesson parks a host, and keep working on the others.

Without Retry-After, back off exponentially and add jitter

An overloaded host often cannot say when it will recover, so it answers 503 with no Retry-After. Fifty collectors start in the same second against such a host, which serves 5 requests a second and refuses the rest, and each retries until it has its page:

Fifty collectors, three ways to wait

limits/backoff.py

"""Fifty collectors start in the same second against a host that serves 5 requests a second and answers the rest
503 without Retry-After. Each retries until it gets its page: after a fixed second, with exponential backoff from
2 s, or with exponential backoff and full jitter. The shop's log says what each way cost the host."""
import heapq
import random

import nimbu_lab as lab

CLIENTS, BASE, CAP = 50, 2.0, 30.0


def fixed(failures, rng):
    return 1.0


def exponential(failures, rng):
    return min(CAP, BASE * 2 ** (failures - 1))  # 2, 4, 8, 16, 30, 30 … seconds


def full_jitter(failures, rng):
    return rng.uniform(0, min(CAP, BASE * 2 ** (failures - 1)))  # anywhere from 0 to that window


def run(delay):
    lab.reset()
    rng = random.Random(42)  # seeded, so every run draws the same waits
    queue = [(0.0, client, 0) for client in range(CLIENTS)]  # (when it asks, which collector, failures so far)
    heapq.heapify(queue)
    done_at = 0.0
    while queue:
        when, client, failures = heapq.heappop(queue)
        lab.clock.seconds = when  # the collectors share the shop's clock
        status, _ = lab.get(f"/stock?item={client}", host=lab.FLAKY, who=f"collector-{client}")
        if status == 200:
            done_at = max(done_at, when + lab.LATENCY)
        else:
            heapq.heappush(queue, (when + lab.LATENCY + delay(failures + 1, rng), client, failures + 1))
    per_second = {}
    for entry in lab.LOG:
        per_second[int(entry["t"])] = per_second.get(int(entry["t"]), 0) + 1
    return len(lab.LOG), done_at, per_second


results = {name: run(way) for name, way in [("fixed 1 s", fixed), ("exponential", exponential),
                                             ("full jitter", full_jitter)]}
print(f"{'retry wait':<13}{'requests':>9}{'refused':>9}{'all served at':>15}")
for name, (requests, done_at, _) in results.items():
    print(f"{name:<13}{requests:>9}{requests - CLIENTS:>9}{done_at:>14.1f}s")
print()
print("Requests that reached the host in each of the first 16 seconds (it serves 5):")
print(f"{'second':<12}", " ".join(f"{s:>2}" for s in range(16)))
for name, (_, _, per_second) in results.items():
    print(f"{name:<12}", " ".join(f"{per_second.get(s, 0):>2}" for s in range(16)))

Output

retry wait    requests  refused  all served at
fixed 1 s          275      225           9.5s
exponential        275      225         180.5s
full jitter        168      118          19.5s

Requests that reached the host in each of the first 16 seconds (it serves 5):
second        0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
fixed 1 s    50 45 40 35 30 25 20 15 10  5  0  0  0  0  0  0
exponential  50  0 45  0  0  0 40  0  0  0  0  0  0  0 35  0
full jitter  72 34 15 14  5  7  1  5  6  1  2  2  0  0  1  0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 backoff.py

nimbu_lab.py (the shop) · limits/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track, with the pages of the rate-limit lesson. Every product, price and
person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers, and one change: the last step hands each request to app() instead of opening
a socket, so nothing leaves your program. Time is the shop's clock. Each request takes 50 ms of it, and sleep() moves
it on instead of waiting, so a run that spans minutes ends at once and prints the same numbers every time.

Two hosts answer:
  bazaar.nimbu.test  the price API, /api/v1/prices?page=1 to 20: a token bucket per client, 5 requests at once and
                     1 more each second, then 429 Too Many Requests with Retry-After
  flaky.nimbu.test   /board (closed for maintenance for the first 90 s, 503 with Retry-After as an HTTP date),
                     /stock?item=n (serves 5 requests in each second and answers the rest 503), /report/<name>
                     (each name fails in its own way, see REPORTS) and /catalogue?page=n (503 to everything while
                     OUTAGE["on"] is True)
"""
import email.utils
import http.client
import io
import json
import math
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

SHOP = "bazaar.nimbu.test"
FLAKY = "flaky.nimbu.test"
EPOCH = 1_800_000_000  # the clock reads 0 at this moment, in seconds since 1970 (what Date headers are made from)
LATENCY = 0.05  # seconds a request takes, there and back


class Clock:
    """The shop's clock, in seconds. sleep() moves it on instead of waiting."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += max(0.0, seconds)


clock = Clock()
LOG = []  # what the shop saw: one entry per request
SKEW = {"flaky": 0.0}  # how far the flaky host's clock is from yours: -40.0 means 40 s behind
COUNTS = {}  # attempts per /report/ path, so a page can fail twice and then work
OUTAGE = {"on": False}  # True: the flaky host's catalogue is down


class Bucket:
    """The API's limit for one client: at most `capacity` tokens, `rate` new tokens a second, one per request."""

    def __init__(self, capacity, rate, now):
        self.capacity, self.rate, self.tokens, self.at = capacity, rate, float(capacity), now

    def take(self, now):
        """(True, 0) when a token was taken; (False, seconds until the next one) when the bucket is empty."""
        self.tokens = min(self.capacity, self.tokens + (now - self.at) * self.rate)
        self.at = now
        if self.tokens >= 1 - 1e-9:
            self.tokens -= 1
            return True, 0.0
        return False, (1 - self.tokens) / self.rate


BUCKETS = {}  # one Bucket per client, told apart by User-Agent
PER_SECOND = {}  # /stock requests seen in each whole second


def reset(skew=0.0):
    """A fresh shop: clock at 0, empty log, full buckets; the flaky host's clock `skew` seconds from yours."""
    clock.seconds = 0.0
    for store in (LOG, BUCKETS, COUNTS, PER_SECOND):
        store.clear()
    SKEW["flaky"] = skew
    OUTAGE["on"] = False


def http_date(seconds):
    """An HTTP date for a moment on the shop's clock, as Date and Retry-After carry it."""
    return email.utils.formatdate(EPOCH + seconds, usegmt=True)


def answer(status, body="", kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def prices(query, who, now):
    page = int(query.get("page", "1"))
    if not 1 <= page <= 20:
        return answer("404 Not Found", "no such page\n")
    bucket = BUCKETS.setdefault(who, Bucket(5, 1.0, now))
    ok, wait = bucket.take(now)
    if not ok:  # RFC 6585: 429 may say how long to wait; delay-seconds is a whole number
        return answer("429 Too Many Requests", json.dumps({"error": "5 requests at once, then 1 a second"}),
                      "application/json", [("Retry-After", str(math.ceil(wait)))])
    rows = [{"item": f"NB-{(page - 1) * 3 + i + 1:04d}", "paise": 2500 + 75 * ((page * 3 + i) % 40)} for i in range(3)]
    return answer("200 OK", json.dumps({"page": page, "prices": rows}), "application/json")


REPORTS = {  # /report/<name>: the answers each one gives, attempt after attempt (the last one repeats)
    "daily": ["200 OK"],
    "weekly": ["503 Service Unavailable", "503 Service Unavailable", "200 OK"],
    "monthly": ["429 Too Many Requests", "200 OK"],
    "archive": ["404 Not Found"],
    "old": ["410 Gone"],
    "staff": ["403 Forbidden"],
    "broken": ["500 Internal Server Error"],
    "gateway": ["504 Gateway Timeout", "200 OK"],
    "bad-query": ["400 Bad Request"],
}


def flaky(method, path, query, now):
    here = now + SKEW["flaky"]  # what the flaky host's own clock says
    dated = [("Date", http_date(here))]
    if path == "/board":
        if now < 90:  # maintenance: back at 90 s on your clock, which its own clock calls 90 + skew
            return answer("503 Service Unavailable", "the price board is being rebuilt\n",
                          extra=[*dated, ("Retry-After", http_date(90 + SKEW["flaky"]))])
        return answer("200 OK", "price board: 40 rows\n", extra=dated)
    if path == "/stock":
        second = int(now)
        PER_SECOND[second] = PER_SECOND.get(second, 0) + 1
        if PER_SECOND[second] > 5:  # overloaded: no Retry-After, because it cannot know when it will recover
            return answer("503 Service Unavailable", "too busy\n", extra=dated)
        return answer("200 OK", f"stock of item {query.get('item', '?')}: 12\n", extra=dated)
    if path == "/catalogue":
        if OUTAGE["on"]:
            return answer("503 Service Unavailable", "the catalogue is down\n", extra=dated)
        return answer("200 OK", f"catalogue page {query.get('page', '1')}\n", extra=dated)
    if path == "/orders" and method == "POST":
        return answer("503 Service Unavailable", "orders are paused\n", extra=dated)
    name = path.removeprefix("/report/")
    if path.startswith("/report/") and name in REPORTS:
        n = COUNTS[path] = COUNTS.get(path, 0) + 1
        status = REPORTS[name][min(n, len(REPORTS[name])) - 1]
        extra = [("Retry-After", "2")] if status.startswith("429") else []
        return answer(status, f"{name} report\n" if status.startswith("200") else "", extra=[*dated, *extra])
    return answer("404 Not Found", "no such page\n", extra=dated)


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    now = clock.now()
    host, method, path = environ.get("HTTP_HOST", ""), environ["REQUEST_METHOD"], environ["PATH_INFO"]
    query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
    who = environ.get("HTTP_USER_AGENT", "")
    if host == SHOP and path == "/api/v1/prices":
        status, headers, body = prices(query, who, now)
    elif host == FLAKY:
        status, headers, body = flaky(method, path, query, now)
    elif host == SHOP:
        status, headers, body = answer("404 Not Found", "no such page\n")
    else:
        status, headers, body = answer("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    target = path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"t": round(now, 3), "host": host, "method": method, "target": target, "status": int(status[:3]),
                "retry_after": dict(headers).get("Retry-After"), "who": who})
    start_response(status, headers)
    return [body]


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data)}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
        got = {}
        body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
        clock.sleep(LATENCY)  # the answer arrives 50 ms after the request left
        raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
        response.reason = response.msg = got["status"][4:]
        return response

    http_open = https_open


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network."""
    director = urllib.request.OpenerDirector()
    for handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPRedirectHandler(),
                    urllib.request.HTTPErrorProcessor(), urllib.request.UnknownHandler(), *handlers]:
        director.add_handler(handler)
    return director


def get(path, host=SHOP, who="NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", method="GET"):
    """One request through urllib: (status, headers). urllib raises HTTPError for 4xx and 5xx; this returns them."""
    request = urllib.request.Request(f"https://{host}{path}", headers={"User-Agent": who}, method=method,
                                     data=b"{}" if method == "POST" else None)
    try:
        with OPENER.open(request, timeout=10) as response:
            response.read()
            return response.status, response.headers
    except urllib.error.HTTPError as error:
        error.read()
        return error.code, error.headers


OPENER = opener()

Retrying every second finished first, at 9.5 s, by keeping the host full: it refused 225 requests while it was already overloaded. exponential backoff: Waiting longer after each failure before trying again, the wait doubling each time up to a cap, such as 2, 4, 8 and 16 s up to 30 s. doubles the wait after every failure, 2, 4, 8 and 16 s up to a cap of 30 s, yet it sent just as many requests and took 180.5 s. The table of seconds shows why: collectors that failed together waited the same time and came back together, in waves of 45, 40 and 35 at seconds 2, 6 and 14.

Full jitter breaks the waves. Each wait is drawn at random between 0 and the doubling window, so the first retry falls anywhere in the next 2 s, the second anywhere in the next 4 s:

wait=random(0, min⁡(cap, base×2n−1))\text{wait} = \text{random}\left(0,\ \min\left(\text{cap},\ \text{base} \times 2^{\,n-1}\right)\right)

Here n is the number of failures so far, base 2 s and cap 30 s: after the third failure the wait is somewhere between 0 and 8 s. With it the host received 168 requests instead of 275, and every collector had its page by 19.5 s. This is the algorithm the AWS Architecture Blog named Full Jitter, and Google’s SRE book gives the rule plainly: always use randomized exponential backoff when scheduling retries.

Libraries offer both kinds, so check which one you configured. Tenacity’s wait_random_exponential is Full Jitter, and its documentation says that wait_exponential, which has no jitter, is not suitable for contention between processes for a shared resource. urllib3’s Retry waits backoff_factor × 2 ** (retries so far), capped by backoff_max (120 s by default), and adds jitter only if you set backoff_jitter.

A retry decorator with no stop and no wait

Tenacity’s documentation says it: its default behaviour is to retry forever without waiting. @retry on its own turns one failure into an endless stream of requests. Give every retry a wait and a stop, for example wait_random_exponential(multiplier=2, max=30) and stop_after_attempt(3).

Checkpoint

Check: Retry-After in seconds or as a date · Exponential backoff with full jitter

  1. Question 1 of 2 A 503 answer carries Date: 10:00:00 and a Retry-After date of 10:02:00, both by the server's clock and on the same day. Your own clock reads 10:00:30 when it arrives. How many seconds should you wait?

    Type a number.

    A hint

    Subtract two times that were read from the same clock.

    Show the answer: Question 1 of 2

    Answer: 120 seconds

    The date names a moment on the server's clock, so count it from the Date header of the same answer: 10:02:00 minus 10:00:00 is 120 s. Counted from your clock you would wait 90 s and come back 30 s early, as the first collector of retry_after.py came back 40 s early.

  2. Question 2 of 2 Thirty collectors are refused at the same moment by an overloaded host and back off exponentially, 2, 4 and 8 s, with no jitter. What does the host see?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Waves: the thirty come back together after 2 s, the ones refused again together after 4 s more, and so on

    Without jitter, exponential backoff only spaces the waves out. Drawing each wait at random between 0 and the window breaks the waves, which sent 168 requests instead of 275 and served everyone by 19.5 s instead of 180.5 s.

Retry only what can succeed, and give the whole run a retry budget

Some failures change if you ask again later and some never will. Ten requests to the flaky host, each failing in its own way, go through one policy: retry only temporary answers, only for requests that are safe to send twice, at most three tries each:

Ten failures, one policy, and an outage with and without a budget

limits/retry_policy.py

"""What to retry, how often, and how many retries a whole run may spend. First, nine reports of the flaky host and
one POST, each failing in its own way, through one policy (the refusal comes last, because it stops the host).
Then an outage: 100 catalogue pages while the host answers 503 to everything, with retries capped per request,
then also by a budget for the whole run."""
import random

import nimbu_lab as lab

RETRY = {408, 429, 500, 502, 503, 504}  # may work if asked again later
STOP = {401, 403}  # the site refused: stop asking this host (the blocks lesson's breaker)
SAFE = {"GET", "HEAD"}  # methods a client may repeat by itself (RFC 9110, section 9.2.2)


class Policy:
    """At most `attempts` tries a request; with `budget`, retries at most that share of the run's first tries."""

    def __init__(self, attempts=3, budget=None):
        self.attempts, self.budget, self.rng = attempts, budget, random.Random(3)
        self.first = self.retries = 0

    def wait(self, headers, attempt):
        value = (headers.get("Retry-After") or "").strip()
        if value.isdigit():
            return int(value)
        return self.rng.uniform(0, min(30.0, 2 ** attempt))  # full jitter: 0-2 s, 0-4 s, 0-8 s …

    def may_retry(self, method, status, attempt):
        if method not in SAFE or status not in RETRY or attempt >= self.attempts:
            return False
        return self.budget is None or self.retries + 1 <= self.budget * self.first


def fetch(path, policy, method="GET"):
    policy.first += 1
    seen = []
    for attempt in range(1, policy.attempts + 1):
        status, headers = lab.get(path, host=lab.FLAKY, method=method)
        seen.append(status)
        if 200 <= status < 300:
            return seen, "saved"
        if status in STOP:
            return seen, "stop the host"
        if status in (404, 410):
            return seen, "skip: nothing there"
        if status not in RETRY:
            return seen, "skip: fix the request"
        if not policy.may_retry(method, status, attempt):
            return seen, f"give up for now{' (a POST is never retried)' if method not in SAFE else ''}"
        policy.retries += 1
        lab.clock.sleep(policy.wait(headers, attempt))
    return seen, "give up for now"


lab.reset()
policy = Policy()
print(f"{'request':<23}{'answers':<14}what the collector does")
for method, path in [*(("GET", f"/report/{name}") for name in lab.REPORTS if name != "staff"),
                     ("POST", "/orders"), ("GET", "/report/staff")]:
    seen, outcome = fetch(path, policy, method)
    print(f"{method + ' ' + path:<23}{' '.join(map(str, seen)):<14}{outcome}")
print(f"{policy.first} first tries and {policy.retries} retries: {len(lab.LOG)} requests in all")
print()
for label, budget in [("3 tries a page", None), ("3 tries a page, retries within 10 %", 0.10)]:
    lab.reset()
    lab.OUTAGE["on"] = True
    policy = Policy(budget=budget)
    outcomes = [fetch(f"/catalogue?page={page}", policy)[1] for page in range(1, 101)]
    print(f"Outage, {label}: the host got {len(lab.LOG)} requests ({policy.retries} retries) "
          f"for 100 pages; {outcomes.count('give up for now')} left for a later run")

Output

request                answers       what the collector does
GET /report/daily      200           saved
GET /report/weekly     503 503 200   saved
GET /report/monthly    429 200       saved
GET /report/archive    404           skip: nothing there
GET /report/old        410           skip: nothing there
GET /report/broken     500 500 500   give up for now
GET /report/gateway    504 200       saved
GET /report/bad-query  400           skip: fix the request
POST /orders           503           give up for now (a POST is never retried)
GET /report/staff      403           stop the host
10 first tries and 6 retries: 16 requests in all

Outage, 3 tries a page: the host got 300 requests (200 retries) for 100 pages; 100 left for a later run
Outage, 3 tries a page, retries within 10 %: the host got 110 requests (10 retries) for 100 pages; 100 left for a later run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry_policy.py

nimbu_lab.py (the shop) · limits/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track, with the pages of the rate-limit lesson. Every product, price and
person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers, and one change: the last step hands each request to app() instead of opening
a socket, so nothing leaves your program. Time is the shop's clock. Each request takes 50 ms of it, and sleep() moves
it on instead of waiting, so a run that spans minutes ends at once and prints the same numbers every time.

Two hosts answer:
  bazaar.nimbu.test  the price API, /api/v1/prices?page=1 to 20: a token bucket per client, 5 requests at once and
                     1 more each second, then 429 Too Many Requests with Retry-After
  flaky.nimbu.test   /board (closed for maintenance for the first 90 s, 503 with Retry-After as an HTTP date),
                     /stock?item=n (serves 5 requests in each second and answers the rest 503), /report/<name>
                     (each name fails in its own way, see REPORTS) and /catalogue?page=n (503 to everything while
                     OUTAGE["on"] is True)
"""
import email.utils
import http.client
import io
import json
import math
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

SHOP = "bazaar.nimbu.test"
FLAKY = "flaky.nimbu.test"
EPOCH = 1_800_000_000  # the clock reads 0 at this moment, in seconds since 1970 (what Date headers are made from)
LATENCY = 0.05  # seconds a request takes, there and back


class Clock:
    """The shop's clock, in seconds. sleep() moves it on instead of waiting."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += max(0.0, seconds)


clock = Clock()
LOG = []  # what the shop saw: one entry per request
SKEW = {"flaky": 0.0}  # how far the flaky host's clock is from yours: -40.0 means 40 s behind
COUNTS = {}  # attempts per /report/ path, so a page can fail twice and then work
OUTAGE = {"on": False}  # True: the flaky host's catalogue is down


class Bucket:
    """The API's limit for one client: at most `capacity` tokens, `rate` new tokens a second, one per request."""

    def __init__(self, capacity, rate, now):
        self.capacity, self.rate, self.tokens, self.at = capacity, rate, float(capacity), now

    def take(self, now):
        """(True, 0) when a token was taken; (False, seconds until the next one) when the bucket is empty."""
        self.tokens = min(self.capacity, self.tokens + (now - self.at) * self.rate)
        self.at = now
        if self.tokens >= 1 - 1e-9:
            self.tokens -= 1
            return True, 0.0
        return False, (1 - self.tokens) / self.rate


BUCKETS = {}  # one Bucket per client, told apart by User-Agent
PER_SECOND = {}  # /stock requests seen in each whole second


def reset(skew=0.0):
    """A fresh shop: clock at 0, empty log, full buckets; the flaky host's clock `skew` seconds from yours."""
    clock.seconds = 0.0
    for store in (LOG, BUCKETS, COUNTS, PER_SECOND):
        store.clear()
    SKEW["flaky"] = skew
    OUTAGE["on"] = False


def http_date(seconds):
    """An HTTP date for a moment on the shop's clock, as Date and Retry-After carry it."""
    return email.utils.formatdate(EPOCH + seconds, usegmt=True)


def answer(status, body="", kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def prices(query, who, now):
    page = int(query.get("page", "1"))
    if not 1 <= page <= 20:
        return answer("404 Not Found", "no such page\n")
    bucket = BUCKETS.setdefault(who, Bucket(5, 1.0, now))
    ok, wait = bucket.take(now)
    if not ok:  # RFC 6585: 429 may say how long to wait; delay-seconds is a whole number
        return answer("429 Too Many Requests", json.dumps({"error": "5 requests at once, then 1 a second"}),
                      "application/json", [("Retry-After", str(math.ceil(wait)))])
    rows = [{"item": f"NB-{(page - 1) * 3 + i + 1:04d}", "paise": 2500 + 75 * ((page * 3 + i) % 40)} for i in range(3)]
    return answer("200 OK", json.dumps({"page": page, "prices": rows}), "application/json")


REPORTS = {  # /report/<name>: the answers each one gives, attempt after attempt (the last one repeats)
    "daily": ["200 OK"],
    "weekly": ["503 Service Unavailable", "503 Service Unavailable", "200 OK"],
    "monthly": ["429 Too Many Requests", "200 OK"],
    "archive": ["404 Not Found"],
    "old": ["410 Gone"],
    "staff": ["403 Forbidden"],
    "broken": ["500 Internal Server Error"],
    "gateway": ["504 Gateway Timeout", "200 OK"],
    "bad-query": ["400 Bad Request"],
}


def flaky(method, path, query, now):
    here = now + SKEW["flaky"]  # what the flaky host's own clock says
    dated = [("Date", http_date(here))]
    if path == "/board":
        if now < 90:  # maintenance: back at 90 s on your clock, which its own clock calls 90 + skew
            return answer("503 Service Unavailable", "the price board is being rebuilt\n",
                          extra=[*dated, ("Retry-After", http_date(90 + SKEW["flaky"]))])
        return answer("200 OK", "price board: 40 rows\n", extra=dated)
    if path == "/stock":
        second = int(now)
        PER_SECOND[second] = PER_SECOND.get(second, 0) + 1
        if PER_SECOND[second] > 5:  # overloaded: no Retry-After, because it cannot know when it will recover
            return answer("503 Service Unavailable", "too busy\n", extra=dated)
        return answer("200 OK", f"stock of item {query.get('item', '?')}: 12\n", extra=dated)
    if path == "/catalogue":
        if OUTAGE["on"]:
            return answer("503 Service Unavailable", "the catalogue is down\n", extra=dated)
        return answer("200 OK", f"catalogue page {query.get('page', '1')}\n", extra=dated)
    if path == "/orders" and method == "POST":
        return answer("503 Service Unavailable", "orders are paused\n", extra=dated)
    name = path.removeprefix("/report/")
    if path.startswith("/report/") and name in REPORTS:
        n = COUNTS[path] = COUNTS.get(path, 0) + 1
        status = REPORTS[name][min(n, len(REPORTS[name])) - 1]
        extra = [("Retry-After", "2")] if status.startswith("429") else []
        return answer(status, f"{name} report\n" if status.startswith("200") else "", extra=[*dated, *extra])
    return answer("404 Not Found", "no such page\n", extra=dated)


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    now = clock.now()
    host, method, path = environ.get("HTTP_HOST", ""), environ["REQUEST_METHOD"], environ["PATH_INFO"]
    query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
    who = environ.get("HTTP_USER_AGENT", "")
    if host == SHOP and path == "/api/v1/prices":
        status, headers, body = prices(query, who, now)
    elif host == FLAKY:
        status, headers, body = flaky(method, path, query, now)
    elif host == SHOP:
        status, headers, body = answer("404 Not Found", "no such page\n")
    else:
        status, headers, body = answer("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    target = path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"t": round(now, 3), "host": host, "method": method, "target": target, "status": int(status[:3]),
                "retry_after": dict(headers).get("Retry-After"), "who": who})
    start_response(status, headers)
    return [body]


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data)}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
        got = {}
        body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
        clock.sleep(LATENCY)  # the answer arrives 50 ms after the request left
        raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
        response.reason = response.msg = got["status"][4:]
        return response

    http_open = https_open


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network."""
    director = urllib.request.OpenerDirector()
    for handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPRedirectHandler(),
                    urllib.request.HTTPErrorProcessor(), urllib.request.UnknownHandler(), *handlers]:
        director.add_handler(handler)
    return director


def get(path, host=SHOP, who="NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", method="GET"):
    """One request through urllib: (status, headers). urllib raises HTTPError for 4xx and 5xx; this returns them."""
    request = urllib.request.Request(f"https://{host}{path}", headers={"User-Agent": who}, method=method,
                                     data=b"{}" if method == "POST" else None)
    try:
        with OPENER.open(request, timeout=10) as response:
            response.read()
            return response.status, response.headers
    except urllib.error.HTTPError as error:
        error.read()
        return error.code, error.headers


OPENER = opener()

The temporary answers are 408, 429, 500, 502, 503 and 504: a busy, broken or unreachable server may answer the same request later, and RFC 9110 says a client that got 408 may repeat its request. Scrapy retries the same codes, plus two that some proxies send, RETRY_TIMES (2) times after the first download. The other answers are final. 404 and 410 say there is nothing at the address, 400 says the request itself is wrong, and 401 and 403 are a refusal that stops the host. A request that is not safe to repeat is not retried by the client at all: RFC 9110 says a client should not automatically retry a non-idempotent method such as POST, because it may place a second order. GET and HEAD are idempotent method: A request method whose intended effect is the same whether a request is sent once or several times, such as GET, HEAD, PUT and DELETE in RFC 9110, so a client may repeat it after a failure. POST is not one.. Follow four of the requests through the policy:

Interactive · step by step Four answers through one retry policy Follow four requests of retry_policy.py through the policy's questions and see where each one leaves it.
Four answers through one retry policy, step 7 of 7. Showing An answer arrives (weekly: 503), Save, skip or stop (staff: stop the host), Can it change later? (503: yes), Safe to send twice? (POST: no), Give up for now (100 pages for later), Tries and budget left? (budget spent), Wait and ask again (jitter: 0 to 2 s). In focus: Tries and budget left?, Give up for now.final answernot finalno: 400yesno: a POSTyesnone leftyesAn answer arrivesweekly: 503Save, skip or stopstaff: stop the hostCan it change later?503: yesSafe to send twice?POST: noGive up for now100 pages for laterTries and budget left?budget spentWait and ask againjitter: 0 to 2 snone left

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. weekly answers 503. GET /report/weekly answers 503. That is not a final answer: a busy server may answer the same request later.
  2. Temporary, and safe. 503 is one of the answers that can change, and a GET may be sent twice without doing anything twice, so the policy looks at what it has left.
  3. A try to spare. This was try 1 of 3 and the run's budget has retries left. No Retry-After came, so the policy draws a wait between 0 and 2 s.
  4. Saved on the third try. Try 2 gets 503 again and waits between 0 and 4 s; try 3 gets 200, a final answer, and the page is saved.
  5. A POST is not repeated. POST /orders also answers 503, but a POST may place an order each time it is sent, so the client does not repeat it by itself: it is kept for later.
  6. A refusal stops the host. GET /report/staff answers 403, a refusal. That is a final answer, and the breaker of the blocks lesson stops every request to this host.
  7. The budget runs out. In the outage every page fails. Once retries reach 10 % of the run's first tries, a failing page gets no retry and waits for the next run: 110 requests reach the host instead of 300.

Limits per request are not enough when a whole host fails. In the outage every page failed, and three tries a page sent 300 requests for 100 pages, tripling the load on a host that was already down. Google’s SRE book describes a per-request limit of three tries together with a retry budget: A limit on retries for a whole run or client rather than per request, such as retries no more than 10 % of first tries, so that a failing host gets little extra load. per client: retry only while retries stay below 10 % of requests, which it reports cut the growth from nearly 3 times to 1.1 times. The same budget sent 110 requests. Retries also multiply across layers: if three layers each try 4 times, one action can reach the bottom layer 64 times, so retry in one place only.

Not affiliated

Based on public information about GitHub. MySmartCoPilot is not affiliated with GitHub, and GitHub’s real REST API limits may differ from what this lesson describes.

Read the answer before you classify it. GitHub’s REST API, for example, reports its secondary rate limits as 403 or 429 with a message, and asks for the full retry-after, or at least a minute without one, before trying again. Log every retry with its address, status, try number and wait: a rising count of retries is the first sign that a pace or a policy is wrong.

Key idea

Retry only answers that can change, only for requests that are safe to repeat, a few times each and within a budget for the whole run, and wait between tries as long as Retry-After asks or, without it, a jittered and growing time.

Checkpoint

Check: What to retry, and a budget for retries

  1. Question 1 of 2 Which of these answers should a collector retry by itself, after a suitable wait? Every request is a GET unless it says otherwise.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • 429 Too Many Requests with Retry-After: 30
    • 503 Service Unavailable
    • 504 Gateway Timeout

    Retry 408, 429, 500, 502, 503 and 504 for safe methods such as GET, within a limit of tries. Final answers (400, 404, 410) are skipped, refusals (401, 403) stop the host, and a POST is never repeated by the client on its own.

  2. Question 2 of 2 A crawl of 2,000 pages meets an outage in which every request fails. Each page gets up to 3 tries, and the run's retries may never exceed 10 % of its first tries. How many requests reach the host?

    Type a number.

    A hint

    The budget caps the retries, not the first tries.

    Show the answer: Question 2 of 2

    Answer: 2200 requests

    Each page is tried once, 2,000 requests, and the budget allows retries up to 10 % of that, 200 more: 2,200. Without the budget the 3 tries a page would send 6,000, three times the load on a host that is already down, as the lab's outage showed with 300 requests for 100 pages against 110.

Practise: build a client that waits its turn

The lab builds the client of this lesson in two steps. First the decision after a failure: how long to wait, from either form of Retry-After or from full jitter, or not to retry at all. Then the client around it, which paces every request with its own bucket and stops after a set number of tries. The sample tests read the moment each request reached a host whose clock never waits:

Code lab · 2 steps

A polite client in two steps

Write the wait that follows a failure, then a client that paces every request and retries within limits.

  1. Decide how long to wait before a retry, or not to retry To do
  2. A client that paces itself and retries within limits To do

Exercise · Medium · Python

Decide how long to wait before a retry, or not to retry

Write retry_delay(status, headers, attempt, now, rng, base=2.0, cap=30.0, tries=3) in retrydelay.py. It gets the answer to a GET request that just failed and returns how many seconds to wait before the next try, or None when the request should not be tried again now:

  • status is the status code and headers a dictionary of the answer's headers, whose names may come in any case;
  • attempt is the try that just failed, 1 for the first; tries is how many tries a request gets in all;
  • now is your own clock in epoch seconds, and rng a random.Random to draw the jitter from.

The rules, in this order:

  1. None for a status that cannot change by asking again later: anything but 408, 429, 500, 502, 503 and 504.
  2. None when attempt has reached tries.
  3. With a Retry-After of whole seconds, that number.
  4. With a Retry-After that is an HTTP date, the seconds until that date counted from the answer's own Date header, or from now when there is no Date; 0 when the date has passed.
  5. Otherwise, or when Retry-After is neither, full jitter: rng.uniform(0, min(cap, base * 2 ** (attempt - 1))).

email.utils.parsedate_to_datetime(value).timestamp() turns an HTTP date into epoch seconds and raises ValueError or TypeError for a value that is not one. The sample tests cover every rule, the clock of a host that runs 40 s behind yours among them.

Starter code · retrydelay.py

import email.utils

RETRYABLE = {408, 429, 500, 502, 503, 504}


def retry_delay(status, headers, attempt, now, rng, base=2.0, cap=30.0, tries=3):
    """Seconds to wait before trying a failed GET again, or None when it should not be tried again now."""
    # Replace this: a client that retries everything after one second hammers a host that asked it to wait.
    return 1.0
The sample tests · test_retrydelay.py
import random
from email.utils import formatdate

from retrydelay import retry_delay

NOW = 1_800_000_000  # your clock, in epoch seconds


def test_answers_that_cannot_change_are_not_retried():
    """only 408, 429 and the 5xx answers 500, 502, 503 and 504 can change by asking later"""
    for status in (400, 401, 403, 404, 410, 501):
        assert retry_delay(status, {}, 1, NOW, random.Random(1)) is None, f"{status} will not change by asking again"


def test_no_retry_once_the_tries_are_used_up():
    """attempt 3 of 3 failed: give up for now"""
    assert retry_delay(503, {}, 3, NOW, random.Random(1)) is None, "the third of three tries just failed"
    assert retry_delay(503, {}, 2, NOW, random.Random(1)) is not None, "try 2 of 3 failed: one try is left"
    assert retry_delay(503, {}, 4, NOW, random.Random(1), tries=5) is not None, "tries says how many a request gets"


def test_retry_after_in_seconds():
    """Retry-After: 30 means wait 30 s, whatever the clocks say"""
    assert retry_delay(429, {"Retry-After": "30"}, 1, NOW, random.Random(1)) == 30
    assert retry_delay(503, {"retry-after": " 120 "}, 1, NOW, random.Random(1)) == 120, "header names come in any case"


def test_retry_after_date_counted_from_the_answers_date():
    """the host's clock is 40 s behind yours: count from its Date header, not from now"""
    headers = {"Date": formatdate(NOW - 40, usegmt=True), "Retry-After": formatdate(NOW + 50, usegmt=True)}
    assert retry_delay(503, headers, 1, NOW, random.Random(1)) == 90, "from its Date to its Retry-After is 90 s"
    no_date = {"Retry-After": formatdate(NOW + 50, usegmt=True)}
    assert retry_delay(503, no_date, 1, NOW, random.Random(1)) == 50, "without a Date header, count from now"
    past = {"Retry-After": formatdate(NOW - 10, usegmt=True)}
    assert retry_delay(503, past, 1, NOW, random.Random(1)) == 0, "a date that has passed: no wait"


def test_full_jitter_without_retry_after():
    """no usable Retry-After: a random wait from 0 up to a window that doubles and stops at the cap"""
    assert retry_delay(503, {}, 1, NOW, random.Random(5)) == random.Random(5).uniform(0, 2.0), "window 2 s"
    assert retry_delay(500, {}, 3, NOW, random.Random(5), tries=9) == random.Random(5).uniform(0, 8.0), "window 8 s"
    assert retry_delay(502, {}, 8, NOW, random.Random(5), tries=9) == random.Random(5).uniform(0, 30.0), "capped at 30 s"
    assert retry_delay(504, {"Retry-After": "soon"}, 1, NOW, random.Random(5)) == random.Random(5).uniform(0, 2.0), \
        "a Retry-After that is neither seconds nor a date counts as missing"
A hint

Lower-case the header names first, then follow the rules in their order and return as soon as one applies. For Retry-After, value.strip().isdigit() tells whole seconds from a date; put the date parsing inside try and fall through to the jitter when it fails.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

A client that paces itself and retries within limits

Finish PoliteClient in politeclient.py. It fetches pages from one host through send(path), which returns (status, headers), on a clock with now() and sleep(seconds). get(path) returns the last status it received.

  1. Pace every request, retries included, with a token bucket of your own: burst requests may start at once, then rate a second. Before each request, sleep as long as the bucket needs to hold a token, then spend it.
  2. **Retry with delay**: after an answer that is not 2xx, call delay(status, headers, attempt), where attempt is the try that just failed (1 for the first). It returns the seconds to wait, or None to stop trying. Sleep that long, then try again, paced like any other request.
  3. **Stop after tries tries** of one path, whatever delay says.

The starter's simple_delay is a stand-in for the retry_delay of the lab's first step: Retry-After in seconds, else 1 s, for 408, 429 and the 5xx answers, and None for every other status. The sample tests give the client a host whose clock never waits, and check the moment every request reached it.

Starter code · politeclient.py

def simple_delay(status, headers, attempt):
    """A stand-in for retry_delay: Retry-After in seconds, else 1 s, for answers that may change; None for the rest."""
    if status not in (408, 429, 500, 502, 503, 504):
        return None
    value = {k.lower(): v for k, v in headers.items()}.get("retry-after", "").strip()
    return int(value) if value.isdigit() else 1.0


class PoliteClient:
    def __init__(self, send, clock, rate=1.0, burst=2, tries=3, delay=simple_delay):
        self.send, self.clock, self.rate, self.burst, self.tries, self.delay = send, clock, rate, burst, tries, delay

    def get(self, path):
        """Fetch path, pacing every request with the bucket and retrying with delay; return the last status."""
        # Replace this: one request with no pacing and no retry.
        status, headers = self.send(path)
        return status
The sample tests · test_politeclient.py
from politeclient import PoliteClient


class Clock:
    def __init__(self):
        self.t = 0.0

    def now(self):
        return self.t

    def sleep(self, seconds):
        assert seconds >= 0, "sleep() was asked to wait a negative time"
        self.t += seconds


class Host:
    """Answers each path from a script of (status, headers) pairs (the last one repeats) and logs when each came."""

    def __init__(self, clock, scripts):
        self.clock, self.scripts, self.log = clock, scripts, []

    def send(self, path):
        self.log.append((round(self.clock.now(), 6), path))
        script = self.scripts.get(path, [(200, {})])
        n = sum(1 for _, p in self.log if p == path)
        return script[min(n, len(script)) - 1]


def setup(scripts=None, **options):
    clock = Clock()
    host = Host(clock, scripts or {})
    return clock, host, PoliteClient(host.send, clock, **options)


def test_a_burst_then_the_rate():
    """burst=2, rate=1: two requests at once, then one a second"""
    clock, host, client = setup(rate=1.0, burst=2)
    for page in range(5):
        assert client.get(f"/p{page}") == 200
    assert [t for t, _ in host.log] == [0, 0, 1, 2, 3], "the third request waits for a token"


def test_the_bucket_refills_while_idle():
    """an idle client earns tokens again, never more than burst"""
    clock, host, client = setup(rate=0.5, burst=2)
    client.get("/a")
    client.get("/b")
    clock.sleep(100)
    for page in "cde":
        client.get(f"/{page}")
    assert [t for t, _ in host.log] == [0, 0, 100, 100, 102], "100 s idle refills the 2 tokens, no more"


def test_a_part_refilled_bucket_waits_for_the_rest():
    """rate=0.5: after 1 s idle the bucket holds half a token, so /c waits 1 s and /d 2 s more"""
    clock, host, client = setup(rate=0.5, burst=2)
    client.get("/a")
    client.get("/b")
    clock.sleep(1)
    client.get("/c")
    client.get("/d")
    assert [t for t, _ in host.log] == [0, 0, 2, 4], "half a token is not a token: /c waits until 2 s, /d until 4 s"


def test_retry_after_is_obeyed():
    """429 with Retry-After: 5, then 200: the second try comes 5 s later"""
    clock, host, client = setup({"/x": [(429, {"Retry-After": "5"}), (200, {})]}, rate=1.0, burst=2)
    assert client.get("/x") == 200
    assert [t for t, _ in host.log] == [0, 5], "nothing may reach the host inside the 5 s it asked for"


def test_retries_are_paced_too():
    """a retry spends a token like any other request"""
    clock, host, client = setup({"/y": [(503, {}), (503, {}), (200, {})]}, rate=0.25, burst=1)
    assert client.get("/y") == 200
    assert [t for t, _ in host.log] == [0, 4, 8], "rate 0.25 means 4 s between starts, even after a 1 s delay"


def test_final_answers_are_not_retried():
    """404 and 403 do not change by asking again"""
    clock, host, client = setup({"/gone": [(404, {})], "/no": [(403, {})]})
    assert client.get("/gone") == 404
    assert client.get("/no") == 403
    assert len(host.log) == 2, "one request each"


def test_a_success_is_never_retried():
    """a 2xx ends the request, even with a delay function that would retry anything"""
    clock, host, client = setup({"/ok": [(204, {})]}, delay=lambda status, headers, attempt: 1.0)
    assert client.get("/ok") == 204
    assert len(host.log) == 1, "delay is asked only after an answer that is not 2xx"


def test_tries_cap_the_retries():
    """503 forever: three tries, then the last status is returned"""
    clock, host, client = setup({"/z": [(503, {})]}, tries=3)
    assert client.get("/z") == 503
    assert len(host.log) == 3, "tries=3 means at most three requests for one path"
A hint

Keep the bucket in two numbers, the tokens and the time you last counted them. Before a request, add (now - then) * rate tokens (never more than burst); if fewer than 1 are left, sleep (1 - tokens) / rate and count again. A loop of for attempt in range(1, self.tries + 1): around one request makes the third rule easy.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The practice session starts with the idea your checkpoint answers left weakest:

Practice that adapts to you

Practise this lesson

14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 What does a 429 Too Many Requests answer tell a collector?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: It asked more often in a stretch of time than the server is willing to answer

    429 means the client sent too many requests in a given amount of time. It may carry Retry-After with how long to wait, and it is a sign that the collector's pace is above the limit.

  2. Question 2 of 14 An API's bucket holds 10 tokens and gains 2 a second. A collector empties it with a burst, waits 20 seconds, then sends 30 requests at once. How many of those 30 are answered?

    Type a number.

    A hint

    A bucket never holds more than its size.

    Show the answer: Question 2 of 14

    Answer: 10 requests

    In 20 seconds the bucket would earn 40 tokens, but it holds at most 10, so 10 of the 30 pass and 20 are refused. Waiting longer than size divided by rate, here 5 s, earns nothing more.

  3. Question 3 of 14 You crawl a small site hard. It starts timing out and resetting connections, and it never answers 429. What does that most likely mean, and what do you do?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: The site is overloaded or shedding load, so slow down as you would after a 429

    A 429 is only one way a server says slow down. Under a flood it may drop connections or time out instead, so a collector treats a burst of those from one host as a reason to pace itself further down.

  4. Question 4 of 14 In a token bucket on the collector's own side, what does the burst size decide?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: How many requests may start at once after an idle spell

    A token bucket has two numbers: the burst, how many tokens it can hold and so how many requests may start at once, and the rate, how many tokens come back each second, which sets the pace after the burst.

  5. Question 5 of 14 An API allows 10 requests a second per key, and you run two collector jobs with the same key. Which pace for each job keeps the key under the limit with a margin?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: 4.5 requests a second each

    A limit applies to whatever the server counts by, here the key. Split it between the jobs that share it and stay a margin below the total, as the lab's bucket of 0.9 a second stayed below the shop's 1 a second.

  6. Question 6 of 14 Which of these are valid Retry-After values under RFC 9110?

    Choose every answer that is right.

    Show the answer: Question 6 of 14

    Answer:

    • An HTTP date, such as the one a Date header carries
    • 0
    • 120

    RFC 9110 defines Retry-After as an HTTP-date or delay-seconds, one or more digits. Anything else is not a valid value, and a careful client falls back to its own backoff rather than guessing.

  7. Question 7 of 14 A 503 answer gives Retry-After as an HTTP date but has no Date header. What do you count the wait from?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Your own clock, the moment the answer arrived

    Count a Retry-After date from the answer's own Date header, which comes from the same clock, and fall back to your clock when Date is missing, as it may be on 503.

  8. Question 8 of 14 In the middle of a crawl of 200 hosts, one host answers 429 with Retry-After of 3,600 seconds. What should the collector do?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Park that host's queue until the time it named and keep crawling the other hosts

    A long Retry-After is a reason to move on, not to stop. Park the host, as the blocks lesson's breaker parks a host it must leave alone, and resume it at the time it gave.

  9. Question 9 of 14 What does full jitter add to exponential backoff?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Each wait is drawn at random between 0 and the doubling window

    Exponential backoff grows the window, 2, 4, 8 s and so on up to a cap, and full jitter draws the wait anywhere in it, so collectors that failed together come back at different moments.

  10. Question 10 of 14 With full jitter, a base of 2 s and a cap of 30 s, what is the longest wait that can be drawn after the 5th failure?

    Type a number.

    A hint

    Work out base × 2 to the power of failures minus one, then apply the cap.

    Show the answer: Question 10 of 14

    Answer: 30 seconds

    2 × 2 to the 4th power is 32 s, more than the cap, so the window is 30 s and the wait falls anywhere from 0 to 30 s.

  11. Question 11 of 14 A teammate decorates the fetch function with tenacity's @retry and nothing else. During a partner's outage, what does it do?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: It retries forever, without waiting between tries

    Give every retry a wait and a stop. With tenacity, for example, wait_random_exponential(multiplier=2, max=30) and stop_after_attempt(3); with urllib3, a backoff_factor and a total.

  12. Question 12 of 14 Which request methods does RFC 9110 call idempotent, so that a client may repeat them after a failure?

    Choose every answer that is right.

    Show the answer: Question 12 of 14

    Answer:

    • GET
    • HEAD
    • PUT

    RFC 9110 calls PUT, DELETE and the safe methods idempotent, and says a client should not automatically retry a request with a non-idempotent method unless it knows the request is safe to repeat.

  13. Question 13 of 14 A collector calls a gateway, which calls a price service, which calls a database. Each of the three layers makes up to 3 tries of its call, with no budget. How many calls can one request of the collector cause at the database at most?

    Type a number.

    A hint

    Every try of one layer can cause every try of the layer below it.

    Show the answer: Question 13 of 14

    Answer: 27 calls

    3 × 3 × 3 = 27. Retries multiply across layers: Google's SRE book counts 4 tries at three layers as 64 attempts on the database. Retry in one layer only, and give that layer a budget.

  14. Question 14 of 14 An API's documentation says it reports some rate limits as 403 or 429, with a message and sometimes retry-after. Your collector stops a host on every 403. What do you change?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Read the answer's message and headers, and handle a documented rate-limit 403 like a 429

    Classify by everything the answer says. GitHub's REST API, for example, documents secondary rate limits as 403 or 429 with a message, and asks clients to wait for retry-after, or at least a minute, before trying again.

Interview questions

Warm-up (fresher): What does HTTP 429 mean, and what should a collector do when it gets one?

429 Too Many Requests, defined in RFC 6585, says the client has asked more often in a stretch of time than the server is willing to answer. It is about your pace, not about the page, so retrying at once only adds refusals. Read Retry-After if the answer has one, as a number of seconds or as an HTTP date counted from the answer’s own Date header, and send nothing to that host until then; without it, wait with jittered exponential backoff. Then slow the collector down, for example with a token bucket a margin below the limit, because a well-paced collector never sees a 429. Retry only safe requests such as GET, a few times, and log every 429 as a sign the pace needs fixing.

Two harder questions grow out of this lesson: a nightly job of 30,000 prices that fails on some nights, and a partner’s outage that your retries made worse. The question page gives each one’s first step and the follow-ups an interviewer would ask:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A nightly job collects 30,000 prices from a partner's API, which allows 10 requests a second per key. Some nights it finishes in under two hours; on others it stops with hundreds of 429 errors. How do you make it finish every night, and how long should it take?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    During a partner's outage, their API saw your collector's traffic jump to about four times its normal rate, and they asked you to stop. Your client retries every failed request three times at once, and four workers run it. What went wrong, and what do you change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A rate limit is a budget, often a token bucket: a burst passes and then a steady rate, and a 429 with Retry-After says the budget is spent; in the lab, 20 requests in a row got 5 answers and 15 refusals.
  • Pace below the limit with a fixed pause plus jitter or with a token bucket of your own a margin under the server’s; both sent exactly 20 requests for 20 pages, against 301 for a client that asked again at once.
  • Wait the full Retry-After: count a number of seconds from the answer and a date from the answer’s own Date header, because a clock 40 s off turned 2 requests into 41.
  • Without Retry-After, back off exponentially with full jitter and a cap: it sent 168 requests where waits without jitter sent 275 in waves, and every collector was served by 19.5 s instead of 180.5 s.
  • Retry only 408, 429 and the temporary 5xx answers, only for safe methods such as GET, a few times each and within a run-wide budget; in an outage the budget held 100 failing pages to 110 requests instead of 300.

Terms in this lesson

exponential backoff
Waiting longer after each failure before trying again, the wait doubling each time up to a cap, such as 2, 4, 8 and 16 s up to 30 s.
idempotent method
A request method whose intended effect is the same whether a request is sent once or several times, such as GET, HEAD, PUT and DELETE in RFC 9110, so a client may repeat it after a failure. POST is not one.
jitter
A random part added to a wait, such as a pause of 1.1 s varied by up to 10 %, so that clients started at the same moment stop sending their requests in step.
retry budget
A limit on retries for a whole run or client rather than per request, such as retries no more than 10 % of first tries, so that a failing host gets little extra load.
token bucket
A way to limit a rate: a bucket holds up to a set number of tokens, each request spends one, and tokens come back at a steady rate, so a short burst passes and then a steady pace.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.