Running APIs in production
Versioning, CORS, caching, request ids and health checks.
Create a free account Try part of it here
Why it matters
An API that works on your laptop meets a harsher world in production, the way a home cook's recipe meets a restaurant on a Saturday night. Browsers enforce rules your tests never saw. Users report "it failed" with no detail. Load balancers ask whether a server is healthy, and old clients call versions you'd like to retire. These are the habits that make an API operable: when something goes wrong at 2 a.m., you can find out what and why.
What you'll be able to do
- Explain CORS, answer browser preflight requests, and allow only trusted origins
- Use HTTP caching headers and know what must never be cached
- Trace every request with a request id and structured logs
- Run health checks, deprecate versions, and scale stateless servers
What it covers
- CORS: browsers guarding their users
- Caching
- A 304 saves the whole body
- Request ids and structured logs
- Health checks
- Retiring old versions
- One request, traced
- What CORS doesn't do
CORS: browsers guarding their users
Your API at api.shop.test works fine from Python. Then a web page at app.shop.test calls it with fetch, and the browser blocks it. That's the same-origin policy: a page may only read responses from its own origin (scheme, host and port). Think of the browser as a bodyguard. It stops a malicious site from reading your bank account through your logged-in browser.
CORS (cross-origin resource sharing) is the guest list. It's how a server says "these other origins may read my responses". The browser sends an Origin header, and the server answers with:
Access-Control-Allow-Origin: https://app.shop.test Vary: Origin
For requests that aren't "simple", such as a JSON POST or any request with an Authorization header, the browser phones ahead. It first sends a preflight: an OPTIONS request asking permission. The server answers it with the allowed methods and headers:
HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.shop.test Access-Control-Allow-Methods: GET, POST, PATCH, DELETE Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 600 Vary: Origin
Rules of thumb:
- Keep an allowlist of origins and echo back only a matching one.
- Never combine
Access-Control-Allow-Origin: *with cookies or credentials. Vary: Origintells caches the answer depends on the Origin header.- CORS is enforced by browsers only. A script or curl ignores it entirely, so CORS is not a substitute for authentication.
In plain words: CORS decides which websites may read your API's answers in a visitor's browser. It doesn't stop anyone else from calling your API.
allowed = {"https://app.shop.test"}
origin = "https://evil.test"
print(origin in allowed)Show the answer
False
https://evil.test isn't in the set, so in gives False. The browser only lets that page read the response if the server names its origin.
Show the answer
The browser enforces CORS and your API didn't allow that origin
Only browsers apply the same-origin policy. The server must send Access-Control-Allow-Origin for that page's origin.
Caching
The fastest request is one that never reaches you. A newsstand on your street saves you a trip to the printing press, and a cached response works the same way. Cache-Control tells browsers, CDNs (content delivery networks, servers around the world that keep copies near users) and proxies what they may keep:
| header | meaning |
|---|---|
Cache-Control: public, max-age=300 | anyone may cache it for 5 minutes (a public product list) |
Cache-Control: private, max-age=60 | only the user's own browser may cache it |
Cache-Control: no-store | never store it anywhere (account pages, tokens, personal data) |
Pair it with ETags: when a copy expires, the client asks with If-None-Match, and a 304 Not Modified saves resending the body.
The classic disaster is caching a personal response as public. A CDN then serves one person's account page to everyone, like a shop handing the same customer's receipt to every person in line. Anything that depends on who is asking must be private or no-store.
A 304 saves the whole body
An ETag is a fingerprint of a response. The client sends it back next time, and if nothing changed, the server answers 304 with no body at all.
import hashlib, json def respond(data, if_none_match=None): body = json.dumps(data) etag = hashlib.sha256(body.encode()).hexdigest()[:12] if if_none_match == etag: return 304, etag, "" return 200, etag, body catalog = {"items": ["tent", "stove", "lantern"] * 100} status, etag, body = respond(catalog) print(status, "bytes sent:", len(body)) status, etag, body = respond(catalog, if_none_match=etag) print(status, "bytes sent:", len(body)) catalog["items"].append("kettle") status, etag, body = respond(catalog, if_none_match=etag) print(status, "bytes sent:", len(body))
The second request sends nothing, because the fingerprint still matches. After the catalog changes the fingerprint changes too, so the client gets the new data without anyone having to guess how long to cache it.
Request ids and structured logs
A user reports "checkout failed at 2:14". Your servers handled 40,000 requests that minute. Which log lines were theirs?
A parcel carries a tracking number so anyone can find it among millions. Give every request a request id for the same reason. Generate one at the edge, or accept one from the caller or load balancer. Then put it in every log line for that request, return it in an X-Request-Id response header, and include it in error bodies. Support can then ask for that id and find the exact trail in seconds. Anthropic's API does this: every response carries a request-id header.
Write logs as structured JSON, one object per line. It's the difference between a spreadsheet and a diary, because each fact sits in its own named column:
{"ts": "2026-10-30T14:14:03Z", "request_id": "9f2c1e", "method": "POST", "path": "/v1/checkout", "status": 502, "ms": 1840, "user": "u_42"}
Log tools can then filter, count and graph them: "all 5xx errors on /checkout in the last hour". Never log secrets, tokens, passwords or full card numbers, and keep personal data to what you need. Across many services, the W3C traceparent header carries the id from one service to the next, so a distributed trace shows the whole journey.
Cache-Control: public?Show the answer
GET /me/orders, a logged-in user's own orders
A shared cache could hand that user's orders to someone else. Personal responses are private or no-store.
Health checks
Load balancers and orchestrators such as Kubernetes poll your servers, and they ask two different questions. "Is anyone home?" and "Is the shop open?" have different answers when the owner is inside, waiting for a delivery.
- Liveness (
/healthz): is the process running at all? If not, restart it. Keep it trivial. - Readiness (
/readyz): can it serve traffic right now? Is the database reachable, the startup work done? If not, stop sending it requests until it recovers.
Mixing them up hurts. A liveness check that tests the database restarts every server when the database blips, which turns a small outage into a big one.
Scaling and shutting down
- Stateless servers keep nothing between requests. Any checkout lane can serve you, because your loyalty points live in a central system. In the same way, sessions, caches and rate-limit buckets live in a shared database or Redis. Then a load balancer can send any request to any copy, and you add copies to handle more traffic (horizontal scaling).
- Graceful shutdown: a restaurant at closing time stops seating people and lets the diners finish. On a deploy, stop accepting new connections, finish the requests already running, then exit. Otherwise every deploy cuts off some users mid-request.
- Limits: cap body sizes (answer 413 Content Too Large), set timeouts, and compress large responses (
Content-Encoding: gzip).
This is how you deploy ten times a day and nobody notices.
Retiring old versions
A shop that's closing puts a sign in the window months ahead. When an old version must go, warn first. Two standard headers say so on every response. Deprecation says it's on the way out, and Sunset says when it stops working:
Deprecation: @1788134400 Sunset: Wed, 01 Jul 2026 00:00:00 GMT Link: <https://docs.shop.test/migrate-v2>; rel="deprecation"
Announce it in the changelog and by email, watch which keys still call the old version, and contact them before the sunset date.
One request, traced
Middleware gives each request an id and logs it as one JSON line. The id goes back to the client too.
import json, time, uuid LOG = [] def handler(request): return {"status": 200, "headers": {}, "body": {"ok": True}} def traced(request): rid = request["headers"].get("x-request-id") or uuid.uuid4().hex[:12] start = time.perf_counter() response = handler(request) response["headers"]["X-Request-Id"] = rid LOG.append(json.dumps({"request_id": rid, "method": request["method"], "path": request["path"], "status": response["status"], "ms": round((time.perf_counter() - start) * 1000, 2)})) return response print(traced({"method": "GET", "path": "/v1/orders", "headers": {"x-request-id": "edge-7f3a"}})["headers"]) print(LOG[-1])
An id that arrives from an upstream proxy is reused, so the trail continues across services. Otherwise a new one is made. The client gets it back in X-Request-Id, and the log line is JSON, so it's searchable. The exercise turns this into proper middleware, with CORS alongside.
Show the answer
A user or support agent can quote it, and you can find that exact request in the logs
"Request req_7f3 failed" turns a vague complaint into one log search.
What CORS doesn't do
A guest list at the front door does nothing about the back door. Only browsers enforce CORS. A script can still call your API freely, so CORS is no substitute for authentication.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What is a CORS preflight?