Index Ai Academy
Phase 8 · APIs · Design at scale

Running APIs in production

Versioning, CORS, caching, request ids and health checks.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. CORS: browsers guarding their users
  2. Caching
  3. A 304 saves the whole body
  4. Request ids and structured logs
  5. Health checks
  6. Retiring old versions
  7. One request, traced
  8. 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: Origin tells 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.

Quick check
What does this print?
allowed = {"https://app.shop.test"}
origin = "https://evil.test"
print(origin in allowed)
  1. False
  2. True
  3. https://evil.test
  4. None
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.

Quick check
A curl command can call your API, but the same request from a web page on another domain fails. Why?
  1. The browser enforces CORS and your API didn't allow that origin
  2. The page is missing an API key
  3. Curl uses a different version of HTTP
  4. The API blocks JavaScript
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:

headermeaning
Cache-Control: public, max-age=300anyone may cache it for 5 minutes (a public product list)
Cache-Control: private, max-age=60only the user's own browser may cache it
Cache-Control: no-storenever 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.

Worked example

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))
It runs right here in your browser, with nothing to install.

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.

Quick check
Which response must never be marked Cache-Control: public?
  1. GET /me/orders, a logged-in user's own orders
  2. GET /products, the public catalog
  3. GET /status, a public status page
  4. GET /docs/shipping, a help article
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).
Try it now

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.

Worked example

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])
In the lesson you can change this and run it.

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.

Quick check
Why return the request id in error responses as well as logging it?
  1. It replaces authentication
  2. A user or support agent can quote it, and you can find that exact request in the logs
  3. It makes responses smaller
  4. Browsers need it for CORS
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

Create a free account

Try a quiz question

What is a CORS preflight?

  1. An OPTIONS request the browser sends first, asking whether the real cross-origin request is allowed
  2. A health check that the load balancer sends before it routes any traffic to a server
  3. A test that the server runs on itself before a new version is deployed to production
  4. The first request of every user session, used to set up cookies for those that follow

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson