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 2 – HTTP, sessions, pagination and APIs

Use the API first: JSON, keys and quotas

Call a JSON API with a key from the environment and its Link pagination, keep to its rate limits and Retry-After, and validate each answer before storing it.

  • Intermediate
  • 15 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: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Choose a site's API over its HTML pages when both offer the data, and say what each one costs
  • Call a JSON API with an API key from the environment, query parameters and pagination
  • Read rate-limit headers and Retry-After, and pause before the limit is hit rather than after
  • Validate every API response before storing it, so that a changed API fails loudly

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose a site's API over its HTML pages when both offer the data, and say what each one costs
  • Call a JSON API with an API key from the environment, query parameters and pagination
  • Read rate-limit headers and Retry-After, and pause before the limit is hit rather than after
  • Validate every API response before storing it, so that a changed API fails loudly

Before you start

On this page

The practice shop’s 240 products take 10 page requests as HTML and 3 from its API, and each API item arrives with its price as a whole number of paise and a currency code instead of text to parse. An API is usually the permitted route too, and it comes with rules of its own: a key, a quota and a format that can change under you.

Here you call the shop’s API the way you would call a real one: with a key read from the environment and sent in a header, query parameters built by urlencode, and the Link pagination of the last lesson. Then you keep to its rate limits with a pace, the quota headers and Retry-After, and check every answer’s shape before storing it, so that an API change stops the run instead of filling a table with empty prices.

An API gives the same data in fewer requests, with fields that keep their names

A page is written for people: its prices are text such as ₹25.00 in a table cell, placed wherever this year’s design puts them. An API: An application programming interface. For a collector: addresses a site provides for programs, which answer with data such as JSON in documented fields instead of pages written for people. is written for programs: it answers with data, usually JSON, in fields whose names and types are documented and kept within a version. The first step of the ladder in this track’s first lesson was an official API or export, for this reason: it is the steadiest source, and its terms usually say how you may use it.

The program below collects all 240 products from the shop’s API, 100 a page, and from its HTML pages, 24 a page, and prints what each cost and what one product looks like from the API:

240 products from the API and from the HTML pages

api/api_first.py

"""All 240 products from the shop's API and from its HTML pages: requests, bytes, and what one product looks like."""
import json
import os
import urllib.request
from pathlib import Path
from urllib.parse import urlencode

import nimbu_lab as lab

for line in Path("lab_api.env").read_text(encoding="utf-8").splitlines():  # in the lab only: the shell sets it
    if line and not line.startswith("#"):
        os.environ.setdefault(*line.split("=", 1))
HEADERS = {"User-Agent": "NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)",
           "X-Api-Key": os.environ["NIMBU_API_KEY"], "Accept": "application/json"}
shop = lab.opener()


def next_link(response):
    link = response.headers.get("Link", "")
    return link[1:link.index(">")] if 'rel="next"' in link else None


url, items = f"{lab.SITE}/api/v2/products?{urlencode({'page': 1, 'per_page': 100})}", []
before = len(lab.LOG)
received = 0
while url:
    with shop.open(urllib.request.Request(url, headers=HEADERS), timeout=10) as response:
        body = response.read()
        received += len(body)
        items += json.loads(body)["items"]
        url = next_link(response)
print(f"API:  {len(lab.LOG) - before:2} requests, {received:6,} bytes, {len(items)} products")

before, received, url = len(lab.LOG), 0, lab.SITE + "/products/?page=1"
while url:
    with shop.open(urllib.request.Request(url, headers=HEADERS), timeout=10) as response:
        received += len(response.read())
        url = lab.SITE + next_link(response) if next_link(response) else None
print(f"HTML: {len(lab.LOG) - before:2} requests, {received:6,} bytes, the same products as text in table cells")
print()
print("one product from the API:", items[0])

Output

API:   3 requests, 25,244 bytes, 240 products
HTML: 10 requests, 20,907 bytes, the same products as text in table cells

one product from the API: {'sku': 'NB-0001', 'name': 'Basmati rice 250 g', 'price_paise': 2500, 'currency': 'INR', 'in_stock': True}

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

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

"""Nimbu Bazaar, the practice shop of this track. 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 for redirects, errors and cookies, and one change: the last step hands each
request to app() instead of opening a socket. The client code is real, nothing leaves your program, and any host but
the shop's gets 421 Misdirected Request.
"""
import fnmatch
import http.client
import http.cookies
import io
import json
import math
import socket
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

HOST = "bazaar.nimbu.test"
SITE = f"https://{HOST}"
KINDS = ["Basmati rice", "Toor dal", "Green tea", "Jaggery", "Turmeric", "Mustard oil", "Poha", "Rock salt",
         "Cashews", "Cardamom", "Ragi flour", "Ghee", "Honey", "Peanuts", "Coffee", "Raisins", "Sesame seeds",
         "Tamarind", "Cumin", "Black pepper", "Rolled oats", "Cloves", "Fennel", "Moong dal", "Chana dal",
         "Wheat flour", "Semolina", "Coriander", "Red chilli", "Almonds", "Dates", "Coconut oil", "Brown rice",
         "Millet", "Flax seeds", "Cinnamon", "Ginger powder", "Vermicelli", "Masoor dal", "Sago"]
SIZES = [250, 500, 1000, 2000, 5000, 10000]  # grams
LOG = []  # what the shop saw: one entry per request


class Clock:
    """The shop's clock, in seconds. A slow page moves it on instead of waiting, so every example runs at once."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

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


clock = Clock()


def products():
    """The 240 products: 40 kinds in 6 pack sizes, each price in paise (100 paise = 1 rupee)."""
    out = []
    for k, kind in enumerate(KINDS):
        per_kg = 60 + (k * 37) % 900  # rupees a kilogram
        for grams in SIZES:
            size = f"{grams // 1000} kg" if grams >= 1000 else f"{grams} g"
            out.append({"sku": f"NB-{len(out) + 1:04d}", "name": f"{kind} {size}",
                        "paise": per_kg * grams // 10 + 1000})
    return out


def rupees(paise):
    return f"₹{paise // 100:,}.{paise % 100:02d}"


class Asked:
    """One request as the shop's pages see it."""

    def __init__(self, environ):
        self.method, self.path = environ["REQUEST_METHOD"], environ["PATH_INFO"]
        self.query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
        self.headers = {k[5:].replace("_", "-").title(): v for k, v in environ.items() if k.startswith("HTTP_")}
        size = int(environ.get("CONTENT_LENGTH") or 0)
        self.form = {k: v[-1] for k, v in parse_qs(environ["wsgi.input"].read(size).decode()).items()}
        self.cookies = {k: m.value for k, m in http.cookies.SimpleCookie(environ.get("HTTP_COOKIE", "")).items()}
        self.wait = environ.get("lab.wait", clock.sleep)  # the shop takes this long before its next bytes


PAGES = {}  # path pattern ("/products/NB-*/" matches every product) -> function(asked) -> (status, headers, body)


def page(pattern):
    def register(fn):
        PAGES[pattern] = fn
        return fn
    return register


def find_page(path):
    exact = PAGES.get(path)
    return exact or next((fn for p, fn in PAGES.items() if "*" in p and fnmatch.fnmatchcase(path, p)), None)


def text(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 not_found(asked):
    return text("404 Not Found", "no such page\n")


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    asked = Asked(environ)
    if environ.get("HTTP_HOST") != HOST:
        status, headers, body = text("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    else:
        status, headers, body = (find_page(asked.path) or not_found)(asked)
    target = asked.path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"line": f"{asked.method} {target} HTTP/1.1", "headers": environ.get("lab.sent", []),
                "status": int(status[:3])})
    start_response(status, headers)
    return [body] if isinstance(body, bytes) else body


class Stream(io.RawIOBase):
    """A response body read chunk by chunk, as from a socket: a slow chunk can time out in the middle of read()."""

    def __init__(self, chunks):
        self.chunks, self.rest = iter(chunks), b""

    def readable(self):
        return True

    def readinto(self, buffer):
        while not self.rest:
            self.rest = next(self.chunks, None)
            if self.rest is None:
                self.rest = b""
                return 0
        n = min(len(buffer), len(self.rest))
        buffer[:n], self.rest = self.rest[:n], self.rest[n:]
        return n


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):
        # The headers urllib's do_open() writes to the socket, and the one http.client adds by itself.
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        headers["Connection"] = "close"
        sent = [(k.title(), v) for k, v in headers.items()]
        if "Accept-encoding" not in headers:
            sent.insert(0, ("Accept-Encoding", "identity"))
        timeout = socket.getdefaulttimeout() if req.timeout is socket._GLOBAL_DEFAULT_TIMEOUT else req.timeout

        def wait(seconds):
            """A socket in timeout mode gives up when one wait for bytes lasts longer than the timeout."""
            if timeout is not None and seconds > timeout:
                clock.sleep(timeout)
                raise TimeoutError("timed out")
            clock.sleep(seconds)

        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),
                   "CONTENT_TYPE": headers.get("Content-type", ""), "CONTENT_LENGTH": str(len(data)),
                   "lab.wait": wait, "lab.sent": sent}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in sent
                        if k not in ("Content-Type", "Content-Length")})
        answer = {}
        body = app(environ, lambda status, hdrs, exc_info=None: answer.update(status=status, headers=hdrs))
        raw = "".join(f"{k}: {v}\r\n" for k, v in answer["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BufferedReader(Stream(body)), message, req.full_url,
                                              int(answer["status"][:3]))
        response.reason = response.msg = answer["status"][4:]  # as urllib's own responses carry them
        return response

    http_open = https_open


DEFAULTS = [urllib.request.HTTPDefaultErrorHandler, urllib.request.HTTPRedirectHandler,
            urllib.request.HTTPErrorProcessor, urllib.request.UnknownHandler]


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network: urllib's usual handlers,
    except those a handler you pass replaces (a subclass of HTTPRedirectHandler, say)."""
    director = urllib.request.OpenerDirector()
    kept = [d for d in DEFAULTS if not any(isinstance(h, d) for h in handlers)]
    for handler in [ShopTransport(), *(d() for d in kept), *handlers]:
        director.add_handler(handler)
    return director


# ---- The pages this lesson's examples ask for ----------------------------------------------------------------------
KEYS = {"nb_test_5f1c9a7e2d4b8c60": "learner@example.org"}  # the lab's one test key: invented, and worthless elsewhere
HOURLY = 100  # requests a key may make in each hour of the shop's clock
BURST = 5  # requests a key may make within one second of the shop's clock
USAGE = {}  # key -> {"window": hour number, "used": requests this hour, "recent": times of the last requests}
SCHEMA = {"version": "2"}  # an example may set "3" to see what an API change does to a collector


def listing(n):
    """Page n of the catalogue as HTML, as a browser would get it (24 products a page)."""
    rows = "".join(f'<tr><td><a href="/products/{p["sku"]}/">{p["name"]}</a></td><td>{rupees(p["paise"])}</td></tr>\n'
                   for p in products()[(n - 1) * 24:n * 24])
    return f"<!doctype html>\n<title>Products, page {n}</title>\n<table>\n{rows}</table>\n"


@page("/products/")
def catalogue(asked):
    n = int(asked.query.get("page", "1"))
    link = [("Link", f'</products/?page={n + 1}>; rel="next"')] if n < 10 else []
    return text("200 OK", listing(n), "text/html; charset=utf-8", link)


def item(p):
    if SCHEMA["version"] == "3":  # the shop's new release: a renamed field, and the price as text in rupees
        return {"sku": p["sku"], "name": p["name"], "price": f'{p["paise"] / 100:.2f}', "currency": "INR"}
    return {"sku": p["sku"], "name": p["name"], "price_paise": p["paise"], "currency": "INR", "in_stock": True}


def problem(status, detail, extra=()):
    return text(status, json.dumps({"error": detail}), "application/json", extra)


@page("/api/v2/products")
def api_products(asked):
    key = asked.headers.get("X-Api-Key") or asked.query.get("api_key")
    if not key:
        return problem("401 Unauthorized", "send your API key in the X-Api-Key header")
    if key not in KEYS:
        return problem("401 Unauthorized", "this API key is not valid")
    now = clock.now()
    use = USAGE.setdefault(key, {"window": -1, "used": 0, "recent": []})
    window = int(now // 3600)
    if window != use["window"]:
        use.update(window=window, used=0)
    reset = (window + 1) * 3600
    use["recent"] = [t for t in use["recent"] if now - t < 1] + [now]
    if len(use["recent"]) > BURST:
        return problem("429 Too Many Requests", "too many requests in one second", [("Retry-After", "2")])
    if use["used"] >= HOURLY:
        return problem("429 Too Many Requests", "hourly limit reached", [("Retry-After", str(math.ceil(reset - now)))])
    use["used"] += 1
    limits = [("X-RateLimit-Limit", str(HOURLY)), ("X-RateLimit-Remaining", str(HOURLY - use["used"])),
              ("X-RateLimit-Reset", str(reset))]
    n, size = int(asked.query.get("page", "1")), min(int(asked.query.get("per_page", "50")), 100)
    last = math.ceil(len(products()) / size)
    link = [("Link", f'<{SITE}/api/v2/products?page={n + 1}&per_page={size}>; rel="next"')] if n < last else []
    body = json.dumps({"items": [item(p) for p in products()[(n - 1) * size:n * size]]})
    return text("200 OK", body, "application/json", limits + link)

lab_api.env (the lab's test key) · api/lab_api.env

# The practice shop's test API key. Your real .env file holds your own key and is listed in .gitignore.
NIMBU_API_KEY=nb_test_5f1c9a7e2d4b8c60

Three requests against ten. In this lab the API even sent more bytes, 25,244 against 20,907, because JSON repeats its field names for every item; on real sites the HTML of a page usually outweighs its data many times, with its layout, scripts and styles. The real gain is in the last line: price_paise is the integer 2500 and currency is INR, so nothing has to be parsed out of a string, and no redesign can move it.

Compared on The HTML pages The API
Requests for 240 products 10 3
A price text in a table cell, to parse price_paise: 2500, currency: "INR"
When the design changes the parser breaks nothing changes within a version
Permission robots.txt and the terms the API’s own terms, often with a key
Limits your own politeness a stated quota, in headers

Build query strings with urlencode

urlencode({"page": 1, "per_page": 100}) joins the parameters with & and percent-encodes every value, so a search term with a space or an ampersand cannot break the address. A query built by joining strings by hand breaks on the first such value.

API keys live in the environment, never in the address

Most APIs want to know who is calling. An API key: A secret string an API provider issues to identify a client, sent with every request, usually in a header. Whoever holds it can use the API as you, so it is kept like a password. is a secret the provider issues to you, sent with every request. Keep it out of the code: read it from an environment variable, set by your shell or by a .env file that git ignores. OWASP’s secrets management cheat sheet adds the next rules: no secret is ever written to a log, and an exposed key is revoked straight away and replaced. It also notes that other processes can often read the environment, and that its values can turn up in logs and system dumps, so on a server a secrets store is better still. Step through where a key goes when it is sent well, and when it is sent badly:

Interactive · step by step Where an API key lives, and where it leaks Step through api_key.py and see which copies of the key exist after each request.
Where an API key lives, and where it leaks, step 5 of 5. Showing The environment, Your collector (logs nb_test_…60), The shop's API, The access log (the whole key, in plain text). In focus: Your collector.The environmentNIMBU_API_KEY, from .envYour collectorlogs nb_test_…60The shop's APIchecks X-Api-KeyThe access logthe whole key, in plain text

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

Every step as text
  1. Outside the code. The key sits in an environment variable, filled from a .env file that git ignores. The collector's code holds only the name NIMBU_API_KEY, so it can be shared and committed safely.
  2. In a header. The collector sends the key in the X-Api-Key header. The shop checks it: no key or a wrong one gets 401 with a message, the right one 200.
  3. What the log keeps. The shop writes the request line into its access log: GET /api/v2/products?per_page=2. Headers are not in that line, so the key is not there.
  4. In the address. Send the key as ?api_key= instead and it becomes part of the address. The same log line now holds the whole key, as do proxies, browser histories and error reports that record addresses.
  5. Masked in your own logs. Your collector logs the key only masked, as nb_test_…60: enough to tell keys apart, never enough to use one. A key that leaked anyway is revoked and replaced at once.

The program sends four requests: without a key, with a wrong key, with the lab’s test key in the X-Api-Key header, and with the same key in the address. Then it prints what the shop’s access log kept of the last two:

A key in a header, a key in the address, and the access log Python · api/api_key.py
"""An API key from the environment, sent in a header and never printed; and what a key in the address leaks."""
import json
import os
import urllib.error
import urllib.request
from pathlib import Path

import nimbu_lab as lab

for line in Path("lab_api.env").read_text(encoding="utf-8").splitlines():  # in the lab only: the shell sets it
    if line and not line.startswith("#"):
        os.environ.setdefault(*line.split("=", 1))
shop = lab.opener()


def masked(key):
    return key[:8] + "…" + key[-2:]


def call(headers=None, query=""):
    request = urllib.request.Request(f"{lab.SITE}/api/v2/products?per_page=2{query}", headers=headers or {})
    try:
        with shop.open(request, timeout=10) as response:
            return response.status, len(json.loads(response.read())["items"])
    except urllib.error.HTTPError as error:
        with error:
            return error.code, json.loads(error.read())["error"]


key = os.environ["NIMBU_API_KEY"]
print("no key:          ", call())
print("a wrong key:     ", call({"X-Api-Key": "nb_test_0000000000000000"}))
print("your key, header:", call({"X-Api-Key": key}), "| logged as", masked(key))
print("your key, query: ", call(query=f"&api_key={key}"))
print()
print("what the shop's access log kept of the last two requests:")
for entry in lab.LOG[-2:]:
    print(" ", entry["line"])

Output

no key:           (401, 'send your API key in the X-Api-Key header')
a wrong key:      (401, 'this API key is not valid')
your key, header: (200, 2) | logged as nb_test_…60
your key, query:  (200, 2)

what the shop's access log kept of the last two requests:
  GET /api/v2/products?per_page=2 HTTP/1.1
  GET /api/v2/products?per_page=2&api_key=nb_test_5f1c9a7e2d4b8c60 HTTP/1.1

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

Both missing and wrong keys got 401 with a message that said what was wrong; the right key got data. The log line for the header request held only the path, while the one for ?api_key= held the whole key in plain text. Every system that records addresses, proxies and browser histories included, does the same, which is why a key belongs in a header, and in your own logs only masked, as nb_test_…60.

A key in a notebook or a commit

A key pasted into code, a notebook cell or a configuration file in the repository is published the moment the repository is shared, and it stays in the history after you delete the line. Read it from the environment from the first line you write, and if a key ever was committed, revoke it and issue a new one: removing the commit is not enough.

Checkpoint

Check: Why an API comes first · API keys kept secret

  1. Question 1 of 2 In the lab, the API sent more bytes than the HTML pages for the same 240 products. Why is it still the better source?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It needed 3 requests instead of 10, and its fields, such as price_paise and currency, need no parsing and survive redesigns

    An API gives data in documented fields within a version. Real HTML pages also carry far more than the lab's bare tables, so the byte comparison usually favours the API as well.

  2. Question 2 of 2 An API accepts the key either as an X-Api-Key header or as ?api_key= in the address. Why choose the header?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Addresses are written to access logs, proxies and histories, so a key in the address ends up stored in plain text in many places

    Send keys in headers, log them only masked, and treat any key that reached a log as exposed: revoke it and issue a new one.

Rate-limit headers say how much is left; Retry-After says how long to wait

An API’s 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., or quota, is a number of requests in a time window, and good APIs tell you where you stand in every answer. GitHub’s REST API, a well-documented public example, allows 60 requests an hour without authentication and 5,000 with a personal access token, and reports x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-used and x-ratelimit-reset, the time the window resets in UTC epoch seconds. When the quota is spent, its documentation says not to retry until after that time. For other limits it may answer with retry-after, a number of seconds to wait, and otherwise asks clients to wait at least a minute and to back off further on repeated failures. Its best practices add one more rule: make requests serially rather than at the same time.

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.

The standards give the general shape. RFC 6585 defines 429 Too Many Requests for a client that sent too many requests in a given time, and lets the answer carry Retry-After; RFC 9110 lets Retry-After be either a number of seconds or an HTTP date. The IETF’s HTTPAPI working group is drafting standard RateLimit-Policy and RateLimit headers; until those are final, each API names its own, so read its documentation. The shop allows one key 100 requests an hour and 5 in any one second. Step through one hour of that quota:

Interactive · message by message One hour of a key's quota, and the two ways to be refused Step through the hour quota.py spends, and see what each header tells the client to do.
One hour of a key's quota, and the two ways to be refused, step 5 of 5. Messages so far: The shop's API to Your collector: Remaining: 99, Reset: 3600; The shop's API to Your collector: 429, Retry-After: 2; Your collector to The shop's API: GET, every 0.25 s; The shop's API to Your collector: Remaining: 0; The shop's API to Your collector: Remaining: 99.Your collector130 done, about an hourThe shop's API100 an hour, 5 a secondRemaining: 99, Reset: 3600429, Retry-After: 2GET, every 0.25 sRemaining: 0Remaining: 99

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

Every step as text
  1. The first request. The first answer carries the quota: X-RateLimit-Limit 100, X-RateLimit-Remaining 99, and X-RateLimit-Reset, the time the hour turns.
  2. Too fast. Without a pause, the sixth request inside one second gets 429 Too Many Requests with Retry-After: 2. The client waits those 2 seconds before it sends anything else to this API.
  3. A steady pace. With 0.25 seconds before each request, no second ever holds more than 4, and no request is refused: 130 requests instead of 154 for the same three jobs.
  4. The quota runs out. The hundredth answer says Remaining: 0. Asking again before the reset would only be refused, so the client sleeps until the time in X-RateLimit-Reset.
  5. A new hour. After the reset the next answer says Remaining: 99 again, and the last 30 requests go through. The pause cost nothing: both clients finished about an hour in.

The program runs three jobs, 130 requests in all, through one client that waits for the reset when the quota is spent and obeys Retry-After, once without a pause between requests and once with a pause of 0.25 seconds. The times are on the shop’s clock, which moves on instead of waiting, so the hour passes at once:

Three jobs, one key, 100 requests an hour Python · api/quota.py
"""Three jobs share one key's 100 requests an hour: pace the requests, wait for the reset, obey Retry-After.
Times are on the shop's clock, which moves on instead of waiting, so the example runs at once."""
import json
import os
import urllib.error
import urllib.request
from pathlib import Path

import nimbu_lab as lab

for line in Path("lab_api.env").read_text(encoding="utf-8").splitlines():  # in the lab only: the shell sets it
    if line and not line.startswith("#"):
        os.environ.setdefault(*line.split("=", 1))
shop = lab.opener()


class Api:
    """One client per key: a pause before each request, a wait for the reset when the hour's quota is spent, and
    Retry-After obeyed on 429. A real collector passes time.sleep and time.time instead of the shop's clock."""

    def __init__(self, key, pause, sleep=lab.clock.sleep, now=lab.clock.now):
        self.key, self.pause, self.sleep, self.now = key, pause, sleep, now
        self.sent = self.limited = 0
        self.waited = []

    def get(self, path):
        while True:
            self.sleep(self.pause)
            request = urllib.request.Request(lab.SITE + path, headers={"X-Api-Key": self.key})
            self.sent += 1
            try:
                with shop.open(request, timeout=10) as response:
                    data = json.loads(response.read())
                    remaining = int(response.headers["X-RateLimit-Remaining"])
                    reset = float(response.headers["X-RateLimit-Reset"])
            except urllib.error.HTTPError as error:
                if error.code != 429:
                    raise
                self.limited += 1
                self.sleep(int(error.headers["Retry-After"]))  # never retry inside the window the server named
                continue
            if remaining == 0:  # the next request would be refused: wait for the hour to turn
                self.waited.append(round(reset - self.now()))
                self.sleep(reset - self.now())
            return data


jobs = {"prices": 60, "stock": 30, "new products": 40}
for label, pause in [("no pause", 0), ("0.25 s pause", 0.25)]:
    lab.USAGE.clear()
    lab.clock.seconds = 0.0
    api, start = Api(os.environ["NIMBU_API_KEY"], pause), lab.clock.now()
    queue = [name for name, n in jobs.items() for _ in range(n)]
    for name in queue:
        api.get("/api/v2/products?per_page=1")
    print(f"{label}: {len(queue)} pages for 3 jobs, {api.sent} requests sent, {api.limited} refused with 429")
    print(f"{'':{len(label) + 2}}{len(api.waited)} wait for the hourly reset ({api.waited[0]:,} s), "
          f"finished after {lab.clock.now() - start:,.0f} s")

Output

no pause: 130 pages for 3 jobs, 154 requests sent, 24 refused with 429
          1 wait for the hourly reset (3,562 s), finished after 3,610 s
0.25 s pause: 130 pages for 3 jobs, 130 requests sent, 0 refused with 429
              1 wait for the hourly reset (3,575 s), finished after 3,608 s

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

Without a pause, the client was refused 24 times for sending more than 5 requests in a second, and sent 154 requests for 130 pages. With 0.25 seconds before each request it was never refused, and it finished at the same time, about an hour in, because the hourly quota was the real limit: the pause cost nothing. That is the general lesson. Pace your requests below the limit, and treat a 429 as a mistake in your pacing rather than as normal traffic.

Retrying a 429 at once

A client that answers 429 by trying again immediately sends its next request inside the very window the server asked it to wait, and is refused again; done in a loop, it turns a short limit into a stream of refused requests. Wait the full Retry-After, or until the reset, and slow down afterwards.

Validate every response before you store it

An API’s fields keep their names within a version, until the provider releases a new one or makes a mistake. A collector that reads item.get("price_paise") and stores the result keeps running when that field disappears: it stores None, row after row, and nothing fails until someone needs the prices. Check every item’s shape before you store it: the fields you need, their types and their ranges. Libraries do this with less code: Pydantic, which calls itself “the most widely used data validation library for Python”, checks data against classes with type hints, and JSON Schema describes itself as “a declarative language” for stating the structure and the limits a JSON document must meet. The lab uses a plain function, so you see every rule. Follow the shop’s API through the checks before and after a format change:

Interactive · stage by stage Every item checked before it is stored Follow the shop's API before and after its format changes, and see where the items end up.
Every item checked before it is stored, step 4 of 4. Showing A page of items arrives, Check each item's shape (100 of 100 pass), More than 2 % bad?, Store the good items (100 stored), Keep the bad ones aside (100 items, 3 reasons each), Stop and alert a person (stopped: format changed). In focus: More than 2 % bad?, Stop and alert a person.an item failsyesA page of items arrives100 items, JSONCheck each item's shape100 of 100 passMore than 2 % bad?one bad row or a changed APIStore the good items100 storedKeep the bad ones aside100 items, 3 reasons eachStop and alert a personstopped: format changed100 %

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

Every step as text
  1. Format 2. Every item has its five fields with the right types: sku and name as text, price_paise as a whole number, currency as text, in_stock as true or false.
  2. Stored. None of the 100 is bad, far below 2 %, so all of them are stored. A single bad item would go aside with its reason and the rest would be stored.
  3. Format 3. The shop releases a new format: price_paise is gone, in_stock too, and a new field price holds the price as text. Every item now fails three checks.
  4. A changed API, not bad rows. All 100 items are bad, far above 2 %. That is the signature of a changed API, so the run stores nothing and a person looks, instead of 100 rows without a price.

The program collects 100 items in the shop’s current format, then switches the shop to a new format, as a provider’s release would, and collects again:

The same collector before and after a format change Python · api/validate.py
"""Check every item's shape before storing it: the same collector, before and after the API changes its format."""
import json
import os
import urllib.request
from collections import Counter
from pathlib import Path

import nimbu_lab as lab

for line in Path("lab_api.env").read_text(encoding="utf-8").splitlines():  # in the lab only: the shell sets it
    if line and not line.startswith("#"):
        os.environ.setdefault(*line.split("=", 1))
shop = lab.opener()
FIELDS = {"sku": str, "name": str, "price_paise": int, "currency": str, "in_stock": bool}


def problems(item):
    """What is wrong with one item, as short sentences; an empty list means it may be stored."""
    out = [f"{name} is missing" for name in FIELDS if name not in item]
    out += [f"{name} is {type(item[name]).__name__}, not {kind.__name__}" for name, kind in FIELDS.items()
            if name in item and type(item[name]) is not kind]  # type(...) is, so True never passes for an int
    out += [f"unexpected field {name}" for name in item if name not in FIELDS]
    if type(item.get("price_paise")) is int and not 100 <= item["price_paise"] <= 10_000_000:
        out.append("price_paise out of range")
    return out


def collect():
    request = urllib.request.Request(lab.SITE + "/api/v2/products?per_page=100",
                                     headers={"X-Api-Key": os.environ["NIMBU_API_KEY"]})
    with shop.open(request, timeout=10) as response:
        return json.loads(response.read())["items"]


for version in ["2", "3"]:
    lab.SCHEMA["version"] = version
    items = collect()
    found = Counter(p for item in items for p in problems(item))
    bad = sum(1 for item in items if problems(item))
    print(f"format {version}: {len(items)} items, {bad} with problems")
    for text, count in found.most_common():
        print(f"  {count:3} x {text}")
    if bad > len(items) * 0.02:  # more than 2 % bad: the API changed, so store nothing and alert a person
        print("  stopped before storing; a careless collector would have stored",
              sum(1 for item in items if item.get("price_paise") is None), "rows without a price")

Output

format 2: 100 items, 0 with problems
format 3: 100 items, 100 with problems
  100 x price_paise is missing
  100 x in_stock is missing
  100 x unexpected field price
  stopped before storing; a careless collector would have stored 100 rows without a price

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

Format 2 passed. In format 3 every item lacked price_paise and in_stock and carried an unexpected price, a string, so the run stopped before storing anything. A careless collector would have stored 100 rows without a price. Note the type check: it compares type(...) exactly, because in Python True is also an int, and a looser check would accept "price_paise": true as a price.

Key idea

Prefer the API, read its key from the environment and send it in a header, pace your requests below its limits and wait as long as its headers say, and check every item before you store it, so that a changed API stops the run.

Checkpoint

Check: Rate limits and Retry-After · Checking items before storing them

  1. Question 1 of 2 An answer arrives with X-RateLimit-Remaining: 0 and X-RateLimit-Reset: 1800003600, a time in epoch seconds. Your clock says 1800002700. How many seconds should the client wait before its next request?

    Type a number.

    A hint

    The reset is a moment in time, not a countdown.

    Show the answer: Question 1 of 2

    Answer: 900 seconds

    1800003600 - 1800002700 = 900 seconds, 15 minutes. A request before then would be refused; the shop's client slept until its reset in the same way.

  2. Question 2 of 2 In one run, 3 of 1,000 items fail validation; in another, 1,000 of 1,000 fail. What should the collector do in each?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Keep the 3 bad items aside and store the rest; for 1,000 bad items, store nothing and alert a person, because the API changed

    Quarantine single failures with their reasons, and set a threshold, 2 % in the example, above which the run stops before storing anything.

Practise: how long to wait

The exercise asks for the decision every careful client makes after each answer: wait_seconds(status, headers, now), from Retry-After in seconds or as an HTTP date, the quota headers and a minute’s default. The sample tests cover a quota left and spent, both forms of Retry-After, a value that is neither, and a 429 whose headers disagree:

Exercise · Easy · Python

Decide how long to wait after an API's answer

Write wait_seconds(status, headers, now) in ratelimit.py. It gets an API answer's status code, its headers as a dictionary whose names may come in any case, and the current time now in epoch seconds. It returns how many whole seconds the client should wait before its next request to that API, 0 when the next request may go at once:

  1. For 429 or 503 with Retry-After as a number of seconds, that number.
  2. For 429 or 503 with Retry-After as an HTTP date, the seconds from now until that date, rounded up, and 0 if the date has passed. email.utils.parsedate_to_datetime(value).timestamp() turns the date into epoch seconds.
  3. For 429 or 503 without a usable Retry-After (missing, or neither a number nor a date), 60.
  4. For any other answer whose X-RateLimit-Remaining is 0, the seconds from now until X-RateLimit-Reset, which is in epoch seconds, rounded up, and 0 if the reset has passed.
  5. Otherwise 0.

A 429 follows rules 1 to 3 even when it also carries quota headers. The sample tests cover every rule.

Starter code · ratelimit.py

def wait_seconds(status, headers, now):
    """Seconds to wait before the next request to this API, 0 when it may go at once. now is in epoch seconds."""
    # Replace this: a client that never waits turns every 429 into another 429.
    return 0
The sample tests · test_ratelimit.py
from email.utils import formatdate

from ratelimit import wait_seconds

NOW = 1_800_000_000  # epoch seconds, the moment the answer arrives


def test_no_wait_while_quota_is_left():
    """a 200 with requests remaining: go on"""
    assert wait_seconds(200, {"X-RateLimit-Remaining": "57", "X-RateLimit-Reset": str(NOW + 1800)}, NOW) == 0
    assert wait_seconds(200, {}, NOW) == 0, "no rate-limit headers at all: no reason to wait"


def test_wait_for_the_reset_when_the_quota_is_spent():
    """remaining 0: wait until the reset time, which is in epoch seconds"""
    assert wait_seconds(200, {"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(NOW + 1800)}, NOW) == 1800
    assert wait_seconds(200, {"x-ratelimit-remaining": "0", "x-ratelimit-reset": str(NOW + 7)}, NOW) == 7, "header names come in any case"
    assert wait_seconds(200, {"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(NOW - 5)}, NOW) == 0, "a reset already past: no wait"


def test_retry_after_in_seconds():
    """429 or 503 with Retry-After as a number of seconds"""
    assert wait_seconds(429, {"Retry-After": "30"}, NOW) == 30
    assert wait_seconds(503, {"retry-after": "120"}, NOW) == 120


def test_retry_after_as_an_http_date():
    """Retry-After may also be a date: wait until then"""
    assert wait_seconds(503, {"Retry-After": formatdate(NOW + 120, usegmt=True)}, NOW) == 120, "RFC 9110 allows an HTTP-date"
    assert wait_seconds(429, {"Retry-After": formatdate(NOW - 30, usegmt=True)}, NOW) == 0, "a date already past: no wait"


def test_a_minute_when_the_server_gives_no_usable_time():
    """429 or 503 without a usable Retry-After: wait a minute"""
    assert wait_seconds(429, {}, NOW) == 60
    assert wait_seconds(503, {"Retry-After": "soon"}, NOW) == 60, "a value that is neither seconds nor a date"


def test_retry_after_beats_the_quota_headers():
    """on a 429, Retry-After decides even when the quota headers say otherwise"""
    headers = {"Retry-After": "2", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(NOW + 900)}
    assert wait_seconds(429, headers, NOW) == 2
A hint

Lower-case the names first: h = {k.lower(): v.strip() for k, v in headers.items()}. For rule 2, wrap the parsing in try: and catch TypeError and ValueError, which parsedate_to_datetime raises for a value that is not a date; that is also how rule 3's "neither a number nor a date" is found. math.ceil rounds the seconds up.

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 asks about sources, keys, limits and validation, starting from your shakiest answers:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 9 What does an API usually give a collector that HTML pages do not?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: Data in documented fields, with terms that say how it may be used

    The API is the first step of the source ladder because it is made for programs and comes with explicit rules.

  2. Question 2 of 9 A site offers an API whose terms allow only non-commercial use, and HTML pages whose robots.txt allows bots. Your project earns money. Which source may you use?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: Neither, until you check the site's general terms or ask; robots.txt allowing bots does not permit a use the API's terms forbid

    Choose by what is offered and allowed for your purpose. When the API's terms say no to your use, the pages are not a way around them: ask the owner, or find another source.

  3. Question 3 of 9 Where should a collector's API key come from?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: An environment variable, set by the shell or a .env file that git ignores, or a secrets store on a server

    Read keys at start-up from the environment, and never write them anywhere that is shared: code, logs, tickets or screenshots.

  4. Question 4 of 9 You find that a teammate committed an API key to a repository a week ago, and deleted it in the next commit. What comes first?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Revoke the key at the provider and issue a new one, then check the provider's usage logs for the week

    Exposed keys are revoked at once and replaced, as OWASP's cheat sheet says; cleaning the history and adding a secret scanner come after.

  5. Question 5 of 9 An API answers 429 Too Many Requests with Retry-After: 30. What does the client do?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: Send nothing to that API for 30 seconds, then retry once and slow its pace

    Retry-After is either seconds or an HTTP date. Wait it out, and treat a 429 as a sign your pace was too fast.

  6. Question 6 of 9 In quota.py, both clients finished about an hour in. Why did the 0.25-second pause cost no time?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: The hourly quota of 100 requests was the real limit; both clients spent most of the hour waiting for the reset

    When a quota caps the work per hour, sending faster only gets you refused sooner. Pace below the limit and plan the job around the quota.

  7. Question 7 of 9 A collector runs 20 workers that call one API with one key at the same time, and keeps getting 429 answers. What is the best first change?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: Send the requests through one paced queue for the key, serially, as GitHub's best practices advise

    The limit belongs to the key, so the pacing must too: one queue per key, a pause between requests, and waits for Retry-After and the reset.

  8. Question 8 of 9 What happens when a collector stores item.get("price_paise") after the API renames that field?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: It stores None for every item and raises no error, so the problem is found only when someone needs the prices

    Check fields, types and ranges before storing, and let a changed API stop the run loudly instead of filling a table with empty values.

  9. Question 9 of 9 Why does validate.py check type(item[name]) is not kind instead of isinstance(item[name], kind) for price_paise?

    Read the code, then choose one answer.

    >>> isinstance(True, int)
    True
    >>> type(True) is int
    False
    Show the answer: Question 9 of 9

    Answer: In Python bool is a subclass of int, so isinstance would accept "price_paise": true as a price

    Validation rules should reject what the field can never hold. A price of true is a broken answer, and the exact check says so.

Interview questions

Warm-up (fresher): Why prefer an API to scraping HTML when a site offers both?

An API returns data made for programs: fields with documented names and types, such as a price as a number with a currency code, that stay the same within a version, so a collector does not break when the page’s design changes. It usually needs fewer requests for the same data, since pages carry layout around a little data, and its terms say how you may use it, often with a key that identifies you and a stated quota you can plan for. A scraper reads text placed for people, has to parse it, and depends on every class name and table staying where it was. Use the API when it offers the data you need for your purpose, and keep to its rules; scrape only what no API offers, where the site allows it.

Harder questions grow out of this lesson too: three jobs that share one key and keep being refused, and an API key found in a public repository. The question page lists a first step for each, and the follow-ups an interviewer would ask next:

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

  1. Mid to senior

    Three jobs share one API key with a limit of 5,000 requests an hour. Each runs on its own schedule, and all three keep getting 429 answers in the busy hours. How do you redesign them?

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

  2. Fresher to mid

    You discover that your collector's API key has been in a public repository for a week, pushed by a teammate in a configuration file. What do you do in the first hour, and what do you change afterwards?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Take the API when it offers your data: fewer requests, typed fields that survive redesigns, and terms that say what you may do; in the lab, 3 requests against 10 and a price as an integer instead of text.
  • Read an API key from the environment, send it in a header, log it only masked, and revoke and replace a key that was ever exposed; a key in the address lands in access logs in plain text.
  • Read the quota headers on every answer, wait for the reset when the quota is spent and for the full Retry-After on a 429, and pace requests below the limit: in the lab the pause avoided 24 refusals and cost no time.
  • Check every item’s fields, types and ranges before storing it, and stop the run when many fail, because that means the API changed.

Terms in this lesson

API
An application programming interface. For a collector: addresses a site provides for programs, which answer with data such as JSON in documented fields instead of pages written for people.
API key
A secret string an API provider issues to identify a client, sent with every request, usually in a header. Whoever holds it can use the API as you, so it is kept like a password.
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.

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.