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.
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:
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 Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
- For
429or503withRetry-Afteras a number of seconds, that number. - For
429or503withRetry-Afteras an HTTP date, the seconds fromnowuntil that date, rounded up, and0if the date has passed.email.utils.parsedate_to_datetime(value).timestamp()turns the date into epoch seconds. - For
429or503without a usableRetry-After(missing, or neither a number nor a date),60. - For any other answer whose
X-RateLimit-Remainingis0, the seconds fromnowuntilX-RateLimit-Reset, which is in epoch seconds, rounded up, and0if the reset has passed. - 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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?
-
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?
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-Afteron a429, 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
- Rate limits for the REST API (GitHub)
- Best practices for using the REST API (GitHub)
- RFC 6585: Additional HTTP Status Codes (section 4, 429 Too Many Requests) (IETF)
- RFC 9110: HTTP Semantics (section 10.2.3, Retry-After) (IETF)
- RateLimit header fields for HTTP (Internet-Draft) (IETF HTTPAPI working group)
- Secrets Management Cheat Sheet (OWASP Foundation)
- Pydantic: getting started (Pydantic)
- What is JSON Schema? (JSON Schema project)
- urllib.parse: urlencode (Python 3.14) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress