AI ENGINEER00-04 · APIs & services
20/90
Sign in

Layer 00 · Foundations · Ch 04

APIs & services

The contract between your code and everyone else's — and the parts of it that quietly cause double charges, leaked tokens, and hammered servers when nobody designs for failure.

42 min readdifficulty beginnerdepth build

An application programming interface, or API, is a set of rules that lets two programs talk. One program sends a request asking for something. Another program sends back a response with the result or a clear error.

Lesson details

Module
AI engineering foundations
Lesson
4 · APIs & services
Difficulty
Beginner
Learning time
42 minutes

Before you start

  • Python functions, classes, and JSON
  • Async Python and await

Skills you will gain

  • Explain how an API request becomes a response
  • Validate incoming JSON with FastAPI and Pydantic
  • Compare polling, Server-Sent Events, and WebSockets
  • Retry transient failures without overwhelming a service
  • Prevent duplicate side effects with an idempotency key

Why this matters

AI applications rarely contain only a model. A website may call an AI service, an AI service may call a model provider, and both may read data from other systems. APIs are the contracts that keep these programs working together.

They also protect real users. A careful API rejects invalid data, limits overload, retries temporary failures safely, and avoids charging or processing the same request twice.

The intuition

A client is the program making the request. A server is the program receiving it. An endpoint is one available destination, such as POST /orders. A schema describes the fields and data types that are allowed.

A status code is a three-digit result label. For example, 200 means the request succeeded, 422 means the submitted data did not match the schema, and 429 means the client sent too many requests.

FIG 00-04.1 · THE REQUEST/RESPONSE CONTRACTCLIENTsends JSONPydanticrequest modelfields + typesmatchesschema?yeshandleryour functionPydanticresponse model200 + JSONback to the client, as validated JSONno422 Unprocessable Entityfield-level error, handler never runs
FIG 00-04.1— a Pydantic model checks the incoming JSON before your handler ever sees it. A mismatch never reaches your code: it short-circuits straight to a 422 with a field-level error. Only a request that matches the schema reaches the handler, whose return value is itself checked against a response model on the way back out.

Read the diagram from left to right: the client sends JSON, Pydantic checks it, the handler performs the work, and FastAPI turns the result into an HTTP response. Essential information is repeated in the caption and surrounding text, so the flow does not depend on colour alone.

How it works

FastAPI + Pydantic v2 are how the contract in the diagram above gets enforced without you writing the checking code yourself. You declare the shape you expect as a class, and FastAPI wires it into the request path automatically:

PythonCreate a validated order endpoint
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Order(BaseModel):
    item: str
    quantity: int
    price: float

@app.post("/orders")
async def create_order(order: Order):
    return {"received": order.model_dump(), "total": round(order.quantity * order.price, 2)}

Important lines

class Order(BaseModel)
Defines the request schema. Pydantic checks each incoming field against it.
@app.post("/orders")
Connects POST requests at /orders to the function below.
order: Order
Asks FastAPI to validate the JSON before calling the function.
order.model_dump()
Turns the validated Pydantic object into a normal Python dictionary.

order: Order is doing two jobs at once: it tells FastAPI to parse the request body as JSON, validate every field against the Order schema, and only then call create_order with a real, type-correct Order instance — and it tells FastAPI’s auto-generated OpenAPI docs exactly what this endpoint expects, for free. If quantity arrives as "two" instead of 2, none of that code runs; FastAPI has already returned a 422 with a message naming the exact field and the exact problem.

Auth: OAuth2 and JWTs. Most real APIs need to know who is asking, not just what they’re asking for. The common shape: a client logs in once against an auth endpoint and gets back a token — commonly a JWTJWT (JSON Web Token)A compact, signed token — three base64url segments joined by dots (header.payload.signature) — that a server issues after login and a client sends back on later requests to prove who it is, without the server storing session state. The payload (claims, e.g. user id, role, expiry) is only base64-encoded, not encrypted, so it is readable by anyone who has the token; the signature only proves the payload hasn't been tampered with, and only holds if the server's signing secret stays secret. Never put a real password or secret inside the payload. — then attaches it to every subsequent request as Authorization: Bearer <token>, instead of re-sending a password on every call. FastAPI’s OAuth2PasswordBearer dependency handles the mechanical part of this — pulling that header out and making it available to your code — but validating the token is still your job.

A JWT is three base64url-encoded segments joined by dots — header.payload.signature — and the important, easy-to-miss fact is in the middle segment:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImN1c3RvbWVyIn0.6QdXq_OV6av2eCHGSrF4ETy0YwmRkG4fKcLVHMNid9E
└──────────── header ────────────┘ └──────────────── payload ────────────────┘ └──────────── signature ────────────┘

The payload is encoded, not encrypted — anyone holding the token can decode the middle segment and read the claims inside it with nothing more than a base64 decoder, no secret required. What the signature (the third segment) actually guarantees is narrower than people assume: it proves the payload hasn’t been altered since the server signed it, and only if the server’s signing secret has stayed secret. It says nothing about whether the payload should be trusted for a particular action — that’s a separate check your code still has to make (e.g. does this token’s role claim actually permit this operation?). Never put a real secret or password inside a JWT payload — it is exactly as readable as if you’d sent it in plain text.

Rate limiting protects a service from being asked to do more work than it can handle, whether that’s abuse or just a legitimate traffic spike. A common, simple approach is the token bucketToken bucketA rate-limiting algorithm where a bucket holds up to N tokens, refills at a fixed rate (e.g. 5 tokens/second), and each request must take one token to proceed. If the bucket is empty, the request is rejected (typically with `429 Too Many Requests`) until refill catches up. Unlike a simple fixed-window counter, it allows short bursts up to the bucket's capacity while still capping the long-run average rate.: a bucket holds up to capacity tokens, refills at a fixed rate, and every request has to take one token to proceed. Run dry, and the server replies 429 Too Many Requests — usually with a Retry-After header — until the bucket refills. The useful property, compared to a plain “N requests per second” counter, is that it still allows a short burst up to the bucket’s capacity while capping the average rate over time — a real client doesn’t call at a perfectly even cadence, and a token bucket doesn’t punish it for that.

Retries with exponential backoff. A request can fail for reasons that have nothing to do with whether it was well-formed: the server is momentarily overloaded, a network hop drops a packet, a connection times out. The correct client response to a transient failure is usually to retry — but retrying immediately, in a tight loop, is close to the worst thing a client can do to a struggling server, because it adds load at exactly the moment the server has the least of it to spare. Exponential backoffExponential backoffA client retry strategy where the wait between attempts grows geometrically (e.g. 0.5s, 1s, 2s, 4s, 8s) instead of retrying immediately, so a struggling server gets progressively more room to recover instead of being hit by an instant flood of retries. Real implementations add jitter (a small random amount added to each delay) so that many clients who failed at the same moment don't all retry in lockstep and re-create the exact spike that caused the failure. waits progressively longer between attempts — 0.5s, 1s, 2s, 4s, ... — instead of retrying instantly, and real implementations add jitter, a random amount added to (or substituted for) each delay, so that many clients who failed at the same moment don’t all retry in lockstep and recreate the exact spike that caused the failure.

Idempotency is what makes a retry safe rather than merely polite. An operation is idempotent if doing it twice has the same effect as doing it once — PUT /users/42 {"name": "Alex"} is idempotent by definition: setting the name to “Alex” a second time changes nothing further. POST /charge {"amount": 50} is not: doing it twice charges the card twice. For non-idempotent operations, the fix is an idempotency keyIdempotency keyA unique value the client generates and attaches to a request (e.g. an `Idempotency-Key` header), so the server can recognize a retried request as the same one instead of a new one. The server stores the first response against the key; if the same key arrives again — because a retry followed a timeout, not an actual failure — it returns the cached response instead of processing the request twice. Without one, retrying a non-idempotent request (like "charge this card") can repeat its side effect. — a value the client generates once and sends on the original request and every retry of it. The server, on first seeing that key, does the work and stores the result against it; on seeing it again, it returns the stored result instead of doing the work a second time.

FIG 00-04.2 · WHY THE DOUBLE CHARGE HAPPENEDRETRY · no idempotency keyt=0POST /charge — client waits, then times outserver finishes the charge at t=1.8s —client never sees the replyt=2.0 client gives upretry — new charget=2.32 chargesRETRY · same Idempotency-Key: abc123t=0POST /charge (key: abc123) — times outserver finishes at t=1.8s, stores theresult keyed by abc123t=2.0 client gives upretry, same keyt=2.31 chargecached reply, instant
FIG 00-04.2— top: a client retries a timed-out request with no idempotency key. The first attempt actually succeeded server-side; the client just never saw the reply. The retry looks like a brand-new charge to the server, because nothing marks it as a repeat. Bottom: the same timeline, but both attempts carry the same idempotency key — the server recognizes the retry and returns the cached result instead of charging again.

Streaming: polling, SSE, and WebSockets. Not every response is ready instantly, and not every client interaction fits a single request/response pair. Three shapes cover almost every case:

FIG 00-04.3 · THREE WAYS TO GET DATA OVER TIME
FIG 00-04.3— polling repeats a full request/response round trip, mostly receiving empty replies, until the answer is ready. Server-Sent Events open one connection and let the server push chunks down it as they become available — one direction only, which is exactly the shape an LLM uses to stream tokens. A WebSocket opens one connection where either side can send at any time — needed when the client has to talk back mid-conversation, not just receive.

Server-Sent EventsServer-Sent Events (SSE)A one-way streaming protocol over a plain HTTP response (`Content-Type: text/event-stream`) where the server keeps the connection open and pushes `data: ...` lines as they become available, instead of the client waiting for one complete response. It only flows server-to-client — no messages back on the same connection — which is exactly the shape of an LLM streaming its answer token by token. Simpler than a WebSocket when the client never needs to send more than the original request. (SSE) run over a plain HTTP response with Content-Type: text/event-stream — the server keeps the connection open and writes data: ...\n\n lines as they become available, and the client reads them as a stream instead of waiting for one complete body. It only flows one way, server to client, which is precisely the shape of an LLM producing an answer token by token: the client never needs to send anything back mid-stream. A WebSocket, by contrast, is a genuinely bidirectional connection — needed when the client has to send more messages on the same connection during the exchange, like a chat interface where either side can speak at any time.

A worked example

Trace backoff_delay(attempt) = min(cap, base * 2^(attempt - 1)) with base=0.5s, cap=4.0s, across five attempts, and see where jitter changes the picture:

Attempt Delay before this attempt (no jitter) With full jitter — random(0, delay)
1 0s (first try, no wait)
2 0.5s somewhere in [0s, 0.5s]
3 1.0s somewhere in [0s, 1.0s]
4 2.0s somewhere in [0s, 2.0s]
5 4.0s (capped — 0.5 * 2^4 = 8.0 would exceed cap) somewhere in [0s, 4.0s]

Without jitter, a thousand clients that all failed at t=0 because a server briefly choked would all retry again at exactly t=0.5s, then all again at exactly t=1.5s, and so on — a thousand-request spike, on a schedule, hitting a server that just proved it couldn’t handle the load a moment ago. With jitter, those thousand retries land at any point across each window instead of one instant, which is the entire reason jitter is worth the extra line of code.

Now trace the double-charge bug itself as a small ledger. The client sends POST /charge with Idempotency-Key: order-abc; the response is lost in transit; the client retries with the same key:

Step Server-side ledger for key order-abc Client sees
1st attempt arrives not present → server charges the card, stores {charge_id: 1} against the key reply lost — client thinks it failed
Client retries (same key) key is present → server returns the stored {charge_id: 1}, does not charge again charge_id: 1
Total real charges 1

Drop the idempotency key from that trace and the second row reads “not present → server charges the card again, stores {charge_id: 2}” — two real charges for one purchase, exactly the support ticket this chapter opened with.

Try it

First, a real FastAPI service — Pydantic validation, a rejected request, and a real SSE stream — running end to end with no server anywhere except an in-memory ASGI transport (httpx.ASGITransport), which calls the app directly without opening a real socket:

LAB 00-04.A · A REAL FASTAPI SERVICE, VALIDATED AND STREAMED
IDLE⌘↵ to run

Next, a JWT issued, read without a secret, verified with one, and rejected after tampering:

LAB 00-04.B · JWTS: SIGNED, NOT SECRET
IDLE⌘↵ to run

Now the bug from the top of this chapter, reproduced directly — the same retry logic, run once with an idempotency key and once without:

LAB 00-04.C · THE DOUBLE-CHARGE BUG, WITH AND WITHOUT AN IDEMPOTENCY KEY
IDLE⌘↵ to run

Last, a token bucket rate limiter — tune the capacity and refill rate and watch when 429 starts:

LAB 00-04.D · A TOKEN BUCKET RATE LIMITER
IDLE⌘↵ to run

Production reality

Every FastAPI service the fixtures in this course describe — and every real LLM API you’ll call in Layer 4 — is built from exactly these pieces, under real constraints:

  • Idempotency keys cost storage and a design decision, not just a header. Stripe’s real idempotency implementation (see Resources) keeps a stored key for 24 hours and explicitly documents what happens if you reuse a key with a different request body — it’s treated as a client bug, not honoured, because the whole guarantee depends on the key meaning “the same request,” not “some request.”
  • A JWT’s short expiry is doing real work. Because a JWT can’t be revoked the way a server-side session can — the server has no record of it beyond the fact that its signature checks out — a leaked token stays valid until it expires. Real systems keep expiries short (minutes to a few hours) and pair a long-lived JWT-issuing flow with a separate, revocable refresh token, precisely so a leak has a small blast radius.
  • 429 without Retry-After just moves the guessing to the client. A well-behaved rate limiter tells the client how long to wait, not just that it should; a well-behaved client reads that header instead of guessing its own backoff schedule against a server it knows nothing about.
  • Retries interact badly with things that aren’t idempotent, in ways that are easy to ship by accident. A generic “retry on any 5xx” middleware, bolted onto a client library after the fact, will retry a POST exactly as happily as a GET unless someone explicitly taught it the difference — the bug in this chapter’s hook is usually not a missing feature, it’s a default nobody thought to turn off for the one endpoint where it matters.
  • Streaming changes your error handling, not just your response type. A normal request either succeeds or fails as a whole; a stream can fail after it’s already sent the client half an answer. Production SSE endpoints need a defined way to signal “the rest of this didn’t arrive” mid-stream, not just a clean success/failure split.

Common mistakes

Adding a client-side retry loop for resilience, without checking whether the operation being retried is idempotent.

Fix — Retrying is only free resilience for idempotent operations (GET, PUT) or ones protected by an idempotency key. For anything else — most POSTs — a naive retry loop turns a transient network blip into a duplicated side effect, exactly like this chapter's opening bug.

Treating a JWT's signature as proof that the request is authorized to do what it's asking, rather than proof the payload wasn't altered.

Fix — A valid signature only means the claims are genuinely what the server originally signed. Whether those claims actually permit this specific action is a separate authorization check your own code still has to make on every request — a syntactically valid token from a legitimately logged-in low-privilege user is not the same as a token allowed to do this.

Retrying immediately in a tight loop ("just call it again") when a request fails, instead of backing off.

Fix — Immediate, synchronized retries add load at exactly the moment a struggling server has the least capacity to spare, which can turn a brief blip into an extended outage. Exponential backoff with jitter spaces retries out, both over time and across the many clients failing at once.

Assuming a 429 or a timeout means the request definitely didn't happen server-side.

Fix — A timeout only means the *client* didn't get a reply in time — the server may have completed the work anyway, as in this chapter's worked example. Never treat "I didn't get a response" as equivalent to "nothing happened"; that gap is exactly what idempotency keys exist to close.

Choosing a WebSocket for a problem that's actually one-directional, like streaming an LLM's answer to the client.

Fix — A WebSocket buys bidirectional messaging at the cost of a stickier, stateful connection your infrastructure has to manage (load balancers, reconnect logic, backpressure on both sides). If the server only ever pushes and the client never talks back mid-stream, SSE gets the same "watch it arrive" experience over plain HTTP, with far less to build and operate.

Exercises

warmup · 1

A FastAPI endpoint declares `class Signup(BaseModel): email: str; age: int`. A client sends `POST /signup` with the JSON body `{"email": "a@b.com", "age": "thirty"}`. What status code comes back, and why does your handler function's body never execute at all?

Reveal solution

422 Unprocessable Entity. FastAPI runs the request body through the Signup Pydantic model before calling your handler — "thirty" cannot be parsed as an int, so validation fails and FastAPI returns a field-level error ({"detail": [{"loc": ["body", "age"], "msg": "...", "type": "int_parsing"}]}) itself. Your handler is never invoked, because the request never satisfied the contract it promised to satisfy — there is nothing valid to hand it.

core · 2

Write `backoff_delay(attempt, base=0.5, cap=8.0)` returning the exponential backoff delay (no jitter) for a 1-indexed retry attempt, capped at `cap`. Then write a jittered version that returns a random value between 0 and that delay, and explain in one sentence why the jittered version is safer to actually deploy.

Reveal solution
import random

def backoff_delay(attempt, base=0.5, cap=8.0):
    return min(cap, base * 2 ** (attempt - 1))

def backoff_delay_jittered(attempt, base=0.5, cap=8.0):
    return random.uniform(0, backoff_delay(attempt, base, cap))

Without jitter, every client that failed at the same moment (say, because the server briefly went down) computes the exact same delay and retries at the exact same instant — recreating the spike that caused the failure in the first place. Jitter spreads those retries out over the delay window instead of letting them land in lockstep.

stretch · 3

Design an idempotency scheme for `POST /transfers`, which moves money between two accounts. Where does the key come from, where is it stored, and what should the server do if a second request with the same key arrives while the first is still being processed — not yet finished?

Reveal solution

The client generates the key (typically a UUID) once per logical transfer attempt and sends it as a header (Idempotency-Key: ...) on every retry of that same attempt — the server must never generate it, or a retry would get a new key and defeat the whole point. The server stores it in a table keyed by that string, recording a status (in_progress / completed) and, once done, the actual result. The subtle case is a second request arriving while the first is still running: if the server only checks for a completed result, both requests race past the check and the transfer happens twice. The fix is to claim the key — write an in_progress row — before starting any work, as a single atomic operation (e.g. an INSERT with a unique constraint on the key). A second request that hits that constraint knows a transfer is already underway and should wait, poll, or return 409, but must never start a second transfer.

Knowledge check

Knowledge check

1A client sends `quantity: "two"` where the schema requires an integer. What should FastAPI return?
2What does a valid JWT signature prove?
3Why add jitter to exponential backoff?
4A payment request timed out, but the server may have completed it. What makes an automatic retry safe?

Practice activity

Practice · 10–20 minutes

Make a retry delay calculator

Goal: Write and test a capped exponential-backoff function.

  1. Copy the starter function.
  2. Return base * 2 raised to attempt - 1.
  3. Use min so the delay never exceeds cap.
  4. Print the delays for attempts 1 through 6.
  5. Check that the sequence stops growing after it reaches the cap.

Expected result: With base 0.5 and cap 4.0, print 0.5, 1.0, 2.0, 4.0, 4.0, 4.0.

Optional challenge: Add full jitter by returning a random value between zero and the capped delay.

Reveal solution

Starter code:

def backoff_delay(attempt: int, base: float = 0.5, cap: float = 4.0) -> float:
    # Replace this line.
    return 0.0


for attempt in range(1, 7):
    print(backoff_delay(attempt))
def backoff_delay(attempt: int, base: float = 0.5, cap: float = 4.0) -> float:
    delay = base * 2 ** (attempt - 1)
    return min(delay, cap)


for attempt in range(1, 7):
    print(backoff_delay(attempt))

The exponent doubles the delay after each failed attempt. min applies the safety cap so a later retry does not wait forever.

Key takeaways

  • FastAPI + Pydantic enforce a request/response contract mechanically — a malformed request never reaches your handler code; it fails fast with a 422 before your function is ever called.
  • A JWT's signature proves its payload wasn't altered since signing; it does not hide the payload's contents (base64 is readable by anyone) and it does not prove the request is authorized — that check is still yours to make.
  • Exponential backoff with jitter exists because retrying immediately, in lockstep with every other failing client, can turn a brief blip into a sustained outage.
  • A retry is only automatically safe for idempotent operations; for anything with a real side effect, an idempotency key is what prevents a lost reply from becoming a duplicated action.
  • Streaming is a spectrum — polling (simplest, wasteful), SSE (one-directional push, what LLM token streams use), WebSocket (full duplex) — and the right choice depends on whether the client ever needs to talk back mid-stream.

Flashcards

Flashcards

1 of 6

Revision and interview questions

  1. Trace a valid FastAPI request from incoming JSON to the final response.
  2. Explain what a JWT signature proves and what it does not prove.
  3. Why can an immediate retry make an overloaded service less reliable?
  4. Compare polling, Server-Sent Events, and WebSockets for an AI response.

One-page sketchnote summary

Print me · one-page revision sheet

APIs & services

Main idea

Reliable APIs make the happy path clear and the failure path safe. Their contracts cover data, identity, traffic, retries, and streaming.

Core terms

  • API — a conversation contract
  • Schema — allowed data shape
  • JWT — signed identity claims
  • Backoff — increasing retry delay
  • Idempotency key — duplicate protection
  • SSE — one-way server stream

One picture

CLIENTsends JSONPydanticrequest modelfields + typesmatchesschema?yeshandleryour functionPydanticresponse model200 + JSONback to the client, as validated JSONno422 Unprocessable Entityfield-level error, handler never runs

Code pattern

@app.post("/orders")
async def create_order(order: Order):
  return {"total": order.quantity * order.price}

Watch out

  • Do not retry side effects without duplicate protection
  • Do not put secrets inside JWT payloads
  • Do not retry immediately in a tight loop
  • Do not use WebSockets when one-way streaming is enough

Remember

  • Validate at the boundary
  • Authorise every protected action
  • Back off and add jitter
  • Design retries and idempotency together
  • Choose the smallest streaming tool that fits

Three quick questions

  1. Where does validation happen?
  2. Could this request safely run twice?
  3. Does the client need to send during the stream?

Lesson checklist

0 of 7 complete

Resources

Next up
Data & storage
A service that validates requests and streams responses still has to put something somewhere — next: SQL, transactions, indexing, pgvector, caching, and the message queues that keep a slow job off the request path.