Part 6 · Professional Python · Module 45

Web & APIs

Most professional Python either calls web APIs or serves one. This module takes HTTP apart until it is no longer magic, then shows how to consume APIs robustly (timeouts, retries, pagination, auth) with the standard library and httpx, how to test that code without a network, and how to design and build a REST API with FastAPI.

⏱ ~3 hoursPrerequisites: M38, M44, M17

How HTTP works

HTTP (Hypertext Transfer Protocol) is a request/response protocol. A client (a browser, your script, another service) opens a connection to a server and sends a request; the server sends back exactly one response. Underneath, HTTP/1.1 runs over a TCP connection (usually wrapped in TLS, which makes it HTTPS); HTTP/2 and HTTP/3 change the wire format for speed but keep the same model of methods, headers, and status codes.

sequenceDiagram
  autonumber
  participant C as Client (your code)
  participant D as DNS
  participant S as API server
  C->>D: Where is api.example.com?
  D-->>C: 203.0.113.7
  C->>S: TCP + TLS handshake
  C->>S: GET /v1/notes?page=2 + headers (Accept, Authorization)
  S->>S: route, authenticate, query database
  S-->>C: 200 OK + headers (Content-Type: application/json) + JSON body
  C->>S: POST /v1/notes + JSON body
  S-->>C: 201 Created + Location: /v1/notes/42
    

An HTTP/1.1 message is plain text: a start line, header lines, a blank line, and an optional body. Here is a real request and its response:

POST /v1/notes HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
Authorization: Bearer eyJhbGciOi...
Content-Length: 35

{"title": "Buy milk", "done": false}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/notes/42

{"id": 42, "title": "Buy milk", "done": false}

HTTP is stateless: each request carries everything the server needs to handle it, and the server does not remember the previous request on that connection. State that must persist between requests (who you are, what is in your cart) is sent every time, as a cookie or an Authorization header, or stored server-side in a database keyed by something the client sends. Statelessness is what lets you run ten copies of a server behind a load balancer: any copy can answer any request.

Methods and status codes

The method says what the client wants to do with the resource at the path. Two properties matter for correctness. A safe method does not change server state. An idempotent method has the same effect whether you send it once or five times, which is exactly what makes it safe to retry after a timeout.

MethodMeaningSafeIdempotentTypical success
GETRead a resource or collectionyesyes200
HEADLike GET, headers onlyyesyes200
POSTCreate a child resource, or run an actionnono201 (+ Location) or 200
PUTReplace the resource with this representationnoyes200 or 204
PATCHPartially updatenonot guaranteed200
DELETERemovenoyes204
OPTIONSAsk what is allowed (used by CORS preflight)yesyes204

The status code is a three-digit number; the first digit is its class:

1xx informational

Rare in application code (101 Switching Protocols for WebSockets).

2xx success

200 OK, 201 Created, 202 Accepted (queued), 204 No Content.

3xx redirection

301/308 moved permanently, 302/307 temporarily, 304 Not Modified (use your cache).

4xx client error

400 Bad Request, 401 Unauthenticated, 403 Forbidden, 404 Not Found, 405 Method Not Allowed, 409 Conflict, 422 Unprocessable Content (validation), 429 Too Many Requests.

5xx server error

500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.

The distinction between 4xx and 5xx drives client behavior: a 4xx means your request is wrong, so sending it again unchanged will fail again; a 5xx (and 429) may be temporary, so a retry can succeed. The standard library has every code as an enum:

Headers and JSON

Headers are metadata about the message. The ones you will meet constantly:

JSON (JavaScript Object Notation) is the lingua franca of APIs. It has objects, arrays, strings, numbers, booleans, and null, which map to dict, list, str, int/float, bool, and None. It has no dates, no sets, no tuples, no bytes, and object keys are always strings, so serializing real data needs choices.

URLs and urllib.parse

A URL has parts, and each part has its own encoding rules:

https://ada:secret@api.example.com:8443/v1/search?q=caf%C3%A9+au+lait&page=2#results
└─┬─┘   └───┬────┘ └──────┬──────┘ └┬─┘└───┬───┘ └──────────┬───────────────┘ └──┬──┘
scheme  userinfo        host      port   path              query              fragment

Characters with special meaning (/ ? & = # +, spaces, non-ASCII) must be percent-encoded when they appear inside a value. Building URLs with string concatenation is how a search for "R&D" silently becomes two parameters. Use urllib.parse:

urljoin follows browser rules: without a trailing slash, the last path segment is treated as a "file" and replaced. Many bugs in API clients come from exactly this.

Consuming APIs: urllib.request and httpx

The browser sandbox that runs this page blocks real network calls from Python, so the cells in this section are marked to run locally. Everything else in the module (URL handling, retries, routing, validation) runs here.

The standard library: urllib.request

urllib.request needs no installation and is fine for scripts, but it is low-level: no connection pooling across calls, awkward JSON and form handling, and no async. For real projects, use httpx.

httpx: sync and async

With httpx.AsyncClient the same calls become awaitable, so you can run many requests concurrently on one thread. This is where the event loop from M38 pays off: while one request waits for the network, the loop runs the others.

Pagination, simulated

APIs return big collections in pages. Offset pagination (?page=3&per_page=50) is simple but can skip or repeat items when data changes between requests. Cursor pagination returns an opaque token pointing at "after the last item you saw", which is stable and fast; GitHub-style APIs put the next URL in a Link header. A generator hides the paging from the caller:

Retries, backoff, and idempotency

Networks fail in transient ways: a dropped connection, a 503 during a deploy, a 429 because you are going too fast. The standard remedy is to retry with exponential backoff: wait 0.5 s, then 1 s, 2 s, 4 s..., up to a cap and a maximum number of attempts. Add jitter (a random fraction of the delay) so that a thousand clients that failed at the same moment do not all retry at the same moment too (the "thundering herd").

Retry logic is a perfect example of code that should take its clock as a parameter: then tests run instantly and deterministically, with no real sleeping.

Testing HTTP code without a network

Tests that hit real APIs are slow, flaky, rate-limited, and may cost money. Instead, cut the network out at a seam. The cleanest seam is dependency injection: your code receives the client (or a function that performs requests) instead of creating it, so a test can pass a fake.

With httpx you get this seam for free: every client accepts a transport, and httpx.MockTransport(handler) calls your function instead of the network. The respx library builds on that with a route-matching API and pytest fixtures:

Designing a REST API

REST (Representational State Transfer) is an architectural style for HTTP APIs. In practice it means: model your domain as resources identified by URLs (nouns, not verbs), use the HTTP methods for operations, use status codes honestly, and keep requests stateless.

OperationRequestSuccessCommon errors
List notesGET /v1/notes?done=false&limit=20&cursor=...200 + page400 bad filter
CreatePOST /v1/notes + JSON201 + Location + body422 invalid body
Read oneGET /v1/notes/42200404
ReplacePUT /v1/notes/42 + full JSON200404, 422
Update partPATCH /v1/notes/42 + partial JSON200404, 409 conflict, 422
DeleteDELETE /v1/notes/42204404
Sub-resourceGET /v1/users/7/notes200404 unknown user

Under every web framework is the same small idea: match the method and path against a table of routes, extract path parameters, validate the body, call a handler, and turn its result (or its error) into a status code and JSON. Here it is in plain Python, so nothing about FastAPI will feel like magic:

Building an API with FastAPI

FastAPI is a modern ASGI framework built on two libraries: Starlette (routing, requests, responses) and Pydantic (data validation from type hints). You declare what a handler needs with ordinary type annotations, and FastAPI parses, validates, converts, documents, and serializes for you.

What FastAPI did for you from those annotations:

Testing uses TestClient, which calls the app in-process (no server, no network) through httpx:

WSGI vs ASGI

How does a server such as uvicorn talk to your framework? Through a standard interface, so any server works with any framework.

Both are just functions, so you can call them directly with fake inputs:

A first look at API security

Security deserves its own course; here are the ideas every API author must know on day one (see also M43).

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://notes.example.com"],
    allow_methods=["GET", "POST", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

Practice

These exercises use only the standard library, so they run in the browser and are graded against real Python.

Write build_url(base, path, params). Join path onto base (assume base ends with / and path does not start with one), then append the query string encoded with urlencode, keeping the dict's order and dropping parameters whose value is None. With no remaining parameters, return the URL without a ?.

Hint

Filter with a dict comprehension {k: v for k, v in params.items() if v is not None}, then add "?" + urlencode(...) only if it is non-empty.

Show solution
from urllib.parse import urlencode, urljoin

def build_url(base, path, params):
    url = urljoin(base, path)
    kept = {key: value for key, value in params.items() if value is not None}
    return f"{url}?{urlencode(kept)}" if kept else url

Write classify(status) returning one of "informational", "success", "redirect", "client error", "server error" (raise ValueError for codes outside 100–599). Then write should_retry(method, status): retry only for 429, 502, 503, 504, and only if the method is idempotent (GET, HEAD, PUT, DELETE, OPTIONS; compare case-insensitively).

Hint

status // 100 gives the class digit. Keep the retryable codes and idempotent methods in sets.

Show solution
CLASSES = {1: "informational", 2: "success", 3: "redirect", 4: "client error", 5: "server error"}
RETRYABLE = {429, 502, 503, 504}
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}

def classify(status):
    if not 100 <= status <= 599:
        raise ValueError(f"not an HTTP status: {status}")
    return CLASSES[status // 100]

def should_retry(method, status):
    return method.upper() in IDEMPOTENT and status in RETRYABLE

Write call_with_retry(call, attempts, base, sleep). Call call(); if it raises TransientError (already defined for you), sleep base * 2**i seconds (i = 0, 1, 2...) using the injected sleep function and try again, up to attempts calls in total. If the last attempt fails, re-raise its error. Other exceptions propagate immediately. Return the call's result.

Hint

Loop for i in range(attempts); in except TransientError, re-raise if i == attempts - 1, otherwise sleep.

Show solution
class TransientError(Exception):
    pass

def call_with_retry(call, attempts, base, sleep):
    for i in range(attempts):
        try:
            return call()
        except TransientError:
            if i == attempts - 1:
                raise
            sleep(base * 2 ** i)

An API returns pages shaped like {"items": [...], "next": "abc" or None}. Write fetch_all(get_page, max_pages=100) that calls get_page(None) first, then get_page(next) until next is None, and returns all items in order. If more than max_pages pages would be fetched, raise RuntimeError (a buggy server could loop forever).

Hint

Loop for _ in range(max_pages), extend the result, and return as soon as next is None. If the loop finishes, raise.

Show solution
def fetch_all(get_page, max_pages=100):
    items, cursor = [], None
    for _ in range(max_pages):
        page = get_page(cursor)
        items.extend(page["items"])
        cursor = page["next"]
        if cursor is None:
            return items
    raise RuntimeError(f"gave up after {max_pages} pages")

Write a Router class. router.add(method, pattern, handler) registers a route where the pattern may contain {name} segments that match one path segment (no /). router.dispatch(method, path) returns handler(**params) for the first matching route. If the path matches no route, return (404, "not found"); if it matches only with other methods, return (405, "method not allowed").

Hint

Turn {name} into the regex group (?P<name>[^/]+) with re.sub, escape the rest, and use re.fullmatch. Track whether any pattern matched the path while you loop over routes.

Show solution
import re

class Router:
    def __init__(self):
        self.routes = []

    def add(self, method, pattern, handler):
        parts = re.split(r"(\{\w+\})", pattern)
        regex = "".join(
            f"(?P<{part[1:-1]}>[^/]+)" if part.startswith("{") else re.escape(part) for part in parts
        )
        self.routes.append((method.upper(), re.compile(regex), handler))

    def dispatch(self, method, path):
        path_matched = False
        for route_method, regex, handler in self.routes:
            match = regex.fullmatch(path)
            if match is None:
                continue
            path_matched = True
            if route_method == method.upper():
                return handler(**match.groupdict())
        return (405, "method not allowed") if path_matched else (404, "not found")

Mini-project: a notes REST API with FastAPI

Mini-projectNotes API with tests

Build a small but properly engineered notes service: full CRUD, filtering and pagination, consistent errors, SQLite persistence from M44, and a pytest suite that never touches the network.

  1. pixi init notes-api, then pixi add fastapi uvicorn structlog and pixi add --feature dev pytest httpx ruff mypy. Add tasks: serve = "uvicorn notes_api.app:app --reload" and test = "pytest".
  2. Model NoteIn, NotePatch (all fields optional), and Note with Pydantic, including created_at as a timezone-aware datetime.
  3. Write a NoteRepository Protocol with an in-memory and a sqlite3 implementation; provide it to routes through Depends.
  4. Implement GET /v1/notes (filters done and q, cursor pagination with limit ≤ 100), POST (201 + Location), GET/PATCH/DELETE /v1/notes/{id}.
  5. Return RFC 9457-style problem details for every error via an exception handler.
  6. Log one structlog event per request (method, path, status, duration_ms) from a middleware.
  7. Run it with pixi run serve and explore /docs.
  • All six endpoints work and appear correctly in the OpenAPI docs
  • Status codes are right: 200, 201 with Location, 204, 404, 422
  • Pagination returns a next cursor and never more than limit items
  • Tests override the repository dependency so each test starts empty
  • At least ten pytest tests, including validation and not-found paths, pass with pixi run test
  • Logs are structured key=value or JSON; there is no print()

Stretch goals: add bearer-token authentication with a dependency and per-user notes (and a test proving user A cannot read user B's note); add ETags with If-None-Match returning 304; write a small httpx client for your own API and test it with respx.

Quiz

Recap

Going further