Building AI APIs and Backends with FastAPI
Your LLM logic needs a front door — an HTTP endpoint your app, teammates, or other services can call. FastAPI is the Python standard for that: async-native (perfect for I/O-bound LLM calls), Pydantic-typed (the schemas you already use), and it streams. This chapter turns your Claude code into a real backend.
pip install fastapi uvicorn:- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Build a FastAPI app with typed request/response models (Pydantic).
- Use
async defendpoints correctly for I/O-bound LLM work. - Stream tokens to the client with a streaming response.
- Handle errors, validation, and dependencies (auth, clients) cleanly.
- Wrap a Claude call — or an agent — as a production-shaped endpoint.
Why FastAPI for AI backends intermediate
Here's the moment this chapter exists for. You've written Claude code that works beautifully in a notebook — a prompt, a call, a clean answer. Then a teammate asks, "Can my app use it?" and you realise a notebook cell isn't something a phone, a website, or another service can talk to. Your logic needs a front door: a URL that accepts a request over the network and hands back a reply. That front door is an API, and the framework you build it with decides whether your service is fast, safe, and pleasant to run — or a fragile thing that falls over the first busy afternoon.
So why this framework and not Flask or Django, which have been around longer? Because an LLM backend has an unusual personality, and FastAPI was built for exactly it. Picture the difference between a chef and a waiter. A CPU-heavy service is like a chef: it's doing work the whole time — chopping, cooking — so it needs a whole kitchen station per order. An LLM service is like a waiter: it takes your order, walks it to the kitchen (the model), and then just waits. A single waiter can juggle a dozen tables precisely because most of the job is standing around waiting for food to be ready. Your endpoint spends almost all of its time waiting on a slow, token-metered upstream — so the framework you want is one that lets a single worker wait on many requests at once. That's FastAPI's native mode, and it's the whole reason it fits.
The common mistake here is treating "which web framework" as a matter of taste, like tabs versus spaces. It isn't. If you pick a synchronous-first framework and then make blocking model calls, each request hogs a worker for the entire multi-second wait, and your throughput collapses under a load that FastAPI would shrug off. The three things an LLM endpoint needs most — wait efficiently (async), validate and shape data (the Pydantic models you already write for structured output), and stream partial results — are precisely the three things FastAPI makes effortless. You're not learning a new discipline; you're pointing the typing and async habits you already built earlier in the course at an HTTP surface. The table below lines each LLM-backend need up with the FastAPI feature that answers it, so you can see the fit directly.
An LLM backend has a specific shape — it mostly waits on a slow, token-metered upstream (O3), returns structured data, and often streams. FastAPI fits that shape better than the older Python frameworks.
Compare the two halves left to right: the same single worker under a sync-first framework (left) and an async one (right), each handling three requests that are all just waiting on the model.
- The left worker fires request A and then blocks — it can't touch B or C until A's whole wait finishes, so they sit in the green "queued" boxes. One slow wait stalls everyone.
- The right worker fans three arrows out at once: A, B, and C are all "waiting" (amber) simultaneously, because the worker hands control back the instant each one starts waiting.
- The payoff is the two footer labels: sync-first → throughput collapses; async → high concurrency. The work each request does is identical — only the waiting is overlapped.
In short: LLM endpoints mostly wait, so the framework that lets one worker wait on many requests at once (FastAPI's async) wins — this is an architecture decision, not a style preference.
| LLM backend need | FastAPI answer |
|---|---|
| I/O-bound: mostly waiting on the model | Async-native — one worker serves many concurrent requests (A3, O3) |
| Structured, validated I/O | Pydantic models validate requests & shape responses (Ch 2) |
| Stream tokens to the UI | First-class streaming responses (C2) |
| Clients need to know the contract | Auto-generated OpenAPI docs at /docs |
| Fast to write, easy to test | Type-hint-driven; a route is just a function |
Lab B3.1 · A typed endpoint intermediate
The first API you write always feels like it must be doing something magical, and it helps to see that it isn't. An endpoint is just an ordinary Python function with a sticky note on it. The sticky note — a decorator — says "hey framework, when a request arrives at this URL, call me." Everything else you'd normally hand-wire for a web service (reading the request body, parsing JSON, checking the fields are the right types, turning your return value back into JSON, writing the docs) FastAPI does for you, and it figures out how by reading the type hints you already know how to write. That's the single idea to hold onto in this lab: you describe the shapes, FastAPI does the plumbing.
Here's the mental model. Think of a Pydantic model as a form at a government office. The form lists exactly which boxes must be filled in and what goes in each — "Question: text. Max tokens: a whole number, and if you leave it blank we'll write 512." When a request shows up, FastAPI checks it against the form before your code sees it. A request that fails the form — a number where text belongs, a missing required box — is bounced at the counter with a 422 error that spells out exactly what was wrong. Your handler only ever runs on requests that already passed inspection, which is why so much defensive code you'd write in other frameworks simply disappears here.
The common beginner mistake is to skip the response model and just return a raw dict, reaching for the typed model only on the way in. It works, but you give up half the benefit: the return type is what lets FastAPI guarantee the JSON you send back has a stable, documented shape, and it's what fills in the response half of the interactive docs. Declare both — request model in, response model out — and the endpoint's signature becomes the contract: anyone (a teammate, a frontend, future-you) can read the two models and know precisely what to send and what they'll get, without reading the body of the function at all. The lab below is the smallest complete example of that contract, echoing the question straight back so the wiring is all you're looking at.
AskRequest before your handler runs (bad input is bounced with a 422), then serializes the return value through AskResponse — you describe the shapes, FastAPI does the plumbing.
Follow the top arrow left to right — it is the life of one request through the endpoint.
- The JSON request arrives and hits AskRequest (the "form"): FastAPI checks every field's type before your code sees it.
- The red branch dropping down is the failure path — a request that doesn't match the form is bounced at the counter with a
422, and your handler never runs. - Only validated requests reach the green ask() handler — your actual logic — which returns an object shaped by AskResponse, so the JSON you send back has a stable, documented form.
In short: Declare a request model and a response model, and the endpoint's signature becomes the contract — validation in, clean JSON out, docs for free.
shellpip install "fastapi[standard]" # includes uvicorn
fastapi dev main.py # dev server + auto-reload; docs at /docs
main.pyfrom fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class AskRequest(BaseModel): # request schema — validated automatically
question: str
max_tokens: int = 512
class AskResponse(BaseModel): # response schema — shapes the JSON out
answer: str
@app.post("/ask")
def ask(req: AskRequest) -> AskResponse:
return AskResponse(answer=f"You asked: {req.question}")
This is the smallest possible FastAPI app: it defines what a request looks like, what a response looks like, and one endpoint (a URL your code answers). FastAPI turns a plain Python function into a working web API for you.
app = FastAPI()creates the application object. Everything else attaches to it.class AskRequest(BaseModel)is a Pydantic model describing the JSON a caller must send: aquestion(text) and an optionalmax_tokensthat defaults to512. FastAPI uses this to validate the incoming request automatically — bad input is rejected before your code runs.class AskResponse(BaseModel)describes the JSON you send back: just ananswerstring. This shapes the output into clean, predictable JSON.@app.post("/ask")is a decorator that says "when someone sends a POST request to the URL/ask, run the function below".def ask(req: AskRequest) -> AskResponse:declares that the body arrives as anAskRequestand the reply is anAskResponse— the type hints are the API contract.- The body just echoes the question back inside an
AskResponse. FastAPI converts that object into JSON for the caller.
What the output means: Start the server with fastapi dev main.py, POST {"question": "hi"} to /ask, and you get back {"answer": "You asked: hi"}. Visit /docs and there's a full interactive API page you never had to write.
Try this: Open /docs in a browser and send a test request from there. Then POST a request with max_tokens set to a word instead of a number — FastAPI replies with an automatic 422 error explaining exactly what's wrong.
req: AskRequest and FastAPI parses+validates the JSON body into that model (a malformed request gets an automatic 422 with a clear error). Declare -> AskResponse and it serializes the return value to JSON matching that schema. Open /docs and there's an interactive, correct API reference you didn't write. That's the whole appeal: the contract is the code.Lab B3.2 · Async endpoints for LLM calls intermediate
Picture a launderette to feel what async buys you. You have one attendant and ten washing machines. If the attendant loads machine one and then stands there watching it spin for forty minutes before touching machine two, the place is a disaster — nine machines idle, a queue out the door. Obviously that's not how it works: the attendant loads a machine, walks off to load the next while the first runs, and circles back only when a load is done. The machines do the waiting; the attendant stays busy dealing with whatever's ready. An async web server is that attendant, and each slow LLM call is a machine spinning on its own.
This matters for LLM backends specifically because of where the time goes. A model call might take three seconds, but your server does almost no work during those three seconds — it fires off a network request and then waits for tokens to come back. That's called I/O-bound work: bound by input/output (the network), not by the CPU. The magic word is await. When your code hits await client.messages.create(...), it's telling the event loop "I'm going to be waiting a while — go do something useful and wake me when the answer arrives." That handoff is what lets a single worker keep dozens of requests in flight at once. Without it, each request would monopolise the worker for its whole three-second wait, and a handful of simultaneous users would be enough to make everyone else queue.
The trap — and it is the single most common FastAPI-for-LLM bug, worth burning into memory now — is putting blocking code inside an async def route. If you call the synchronous SDK, or time.sleep(), or crunch a big loop, you never hand control back to the event loop. Our launderette attendant is now standing frozen in front of one machine again, and every other request stalls behind them. The rule is simple: in an async def route, everything slow must be await-ed (use the async client), and genuinely blocking work must be pushed to a threadpool so the loop stays free. Async isn't faster for one request — that call still takes its three seconds — it's dramatically better under concurrency, which is exactly the condition a real backend lives in. The diagram below shows one worker holding three awaiting requests at once; keep the launderette in mind as you read it.
This is where FastAPI earns its place for AI. An LLM call spends seconds waiting (O3). With async def and the async Anthropic client, one worker handles many in-flight requests instead of blocking on each — the concurrency lesson from A3, applied to your API.
This picture explains why async matters for an LLM backend. A single worker (one process) is serving three requests at the same time — because each one is just waiting on the model, the worker doesn't need to finish one before starting the next.
- The box on the left ("1 worker") is a single server process. Normally you might expect it to handle one request at a time.
- The three arrows fan out to req A, req B, req C — three requests that arrived at nearly the same moment. Each label says "awaiting model": the request has been sent to Claude and is now doing nothing but waiting for the answer.
- The box on the right ("Claude") is the slow upstream — the language model the requests are all waiting on. That wait (often seconds) is the whole reason this works.
- The key idea: while req A is parked waiting, the one worker is free to start req B, then req C. It overlaps the waiting instead of standing idle. This is I/O-bound work — mostly waiting, not computing — which is exactly what
asyncis good at.
In short: One async worker can hold many in-flight LLM calls at once because the calls spend their time waiting, not using the CPU. That's why you don't need one CPU core per request.
The async LLM endpoint — in Claude or OpenAI
The FastAPI route is identical either way — async def + await is what lets one worker serve many in-flight calls. Only the model call changes: in Claude you await client.messages.create(...) and read the first text block; in OpenAI you await client.responses.create(...) and read resp.output_text. Toggle the tab to switch SDK.
Requires: pip install anthropic
llm_endpoint.pyfrom anthropic import AsyncAnthropic
client = AsyncAnthropic() # async client — awaitable calls
@app.post("/ask")
async def ask(req: AskRequest) -> AskResponse: # async def!
resp = await client.messages.create(
model="claude-opus-4-8", max_tokens=req.max_tokens,
messages=[{"role":"user","content": req.question}],
)
text = next(b.text for b in resp.content if b.type == "text")
return AskResponse(answer=text)
Requires: pip install openai
llm_endpoint.pyfrom openai import AsyncOpenAI
client = AsyncOpenAI() # async client — awaitable calls
@app.post("/ask")
async def ask(req: AskRequest) -> AskResponse: # async def!
resp = await client.responses.create(
model="gpt-5.5", max_output_tokens=req.max_tokens,
input=req.question,
)
text = resp.output_text
return AskResponse(answer=text)
Now the endpoint actually calls Claude. The important change from Lab B3.1 is the two words async and await — they are what let one worker serve many slow LLM requests at once (the diagram above).
- You create the async client once (
AsyncAnthropic()for Claude,AsyncOpenAI()for OpenAI — toggle the tab above). Its calls are awaitable: you can pause on them without freezing the whole server. async def ask(...)— the wordasyncmarks this as a coroutine. While it's paused waiting, FastAPI can run other requests. A plaindefhere would block everyone (see the warning box below).- The
await client.messages.create(...)(Claude) /await client.responses.create(...)(OpenAI) line sends the question to the model, andawaitmeans "pause here until the reply comes back, but let other requests run meanwhile". The request shape is the same one you learned in the earlier model chapters. - You then read the answer out of the reply — the first text block via
next(b.text for b in resp.content ...)on Claude, orresp.output_texton OpenAI — and return it wrapped inAskResponse.
What the output means: A POST to /ask now returns Claude's real answer as {"answer": "..."}. Under load, one worker handles many of these at the same time because each is mostly waiting.
Try this: Fire several requests at once (open /docs in a few tabs and submit together). Because of async/await, they overlap instead of lining up one behind the other.
async def route, a synchronous blocking call (a sync SDK call, time.sleep, heavy CPU work) freezes the whole worker — every concurrent request stalls (A3). Use the async client and await it. If you must call blocking code, run it in a threadpool (FastAPI does this automatically for plain def routes, or use run_in_threadpool). Mixing a sync SDK call into an async route is the #1 FastAPI-for-LLM mistake.Lab B3.3 · Streaming tokens advanced
Think about why a good chatbot feels alive and a bad one feels broken. Ask ChatGPT or Claude a question and the answer starts appearing almost instantly, word by word, like someone typing to you. Now imagine the same answer arriving as a blank spinner for eight seconds, then a wall of text dumped all at once. The total time is identical — the model took just as long either way — but the first feels responsive and the second feels dead. That gap is entirely about when the user first sees something, and closing it is what this lab is for.
The idea is called streaming, and the honest framing is important: streaming does not make generation faster. The model produces tokens at the same rate regardless. What streaming changes is latency you can perceive — the "time to first token" — by shipping each piece of the answer to the browser the instant the model produces it, instead of buffering the whole reply and sending it in one lump at the end. It's the difference between a waiter bringing each dish as it's cooked versus making you wait until the entire table's order is plated. Same kitchen speed; wildly different experience.
Mechanically, this rests on a Python idea you've met before: the generator. A normal function computes everything and returns once. A generator yields values one at a time, pausing between each — perfect for "hand me the next chunk as it's ready." FastAPI's StreamingResponse takes such a generator and flushes each yielded piece down the open HTTP connection immediately, rather than waiting for the function to finish. So the pattern is: an async generator loops over the model's token stream and yields each chunk; StreamingResponse pipes those chunks to the client live. The subtle mistake to avoid is accidentally re-buffering — for instance, collecting the chunks into a list "to return at the end" — which quietly throws away the entire benefit and turns your stream back into a lump. If you're yielding, yield straight through. There's a real second win here too, covered in the tip below: a long non-streaming response can trip HTTP idle timeouts, whereas a stream keeps bytes flowing and sidesteps that failure mode. The lab shows the whole shape in a handful of lines.
For a chat UI, you want tokens to appear as they're generated, not after a multi-second wait (C2, O3). FastAPI streams via a streaming response that yields chunks.
Read it as two timelines running left→right, both ending at the same point on the right (same total generation time).
- The top red bar is the buffered response: the whole answer is held back, so the user stares at a blank spinner and the first byte only lands at the very end.
- The bottom row of green chunks is the stream: each token (
yielded by the generator) is flushed the instant it's ready. - The payoff is the two "first byte" arrows — far right for buffered, almost at the start for streamed. Same finish line, wildly different perceived latency.
In short: An async generator + StreamingResponse ships tokens as they arrive, cutting time-to-first-token without changing how long generation takes.
Streaming tokens from the endpoint — in Claude or OpenAI
The StreamingResponse + async-generator shape is the same either way; only the streaming call differs. In Claude you open client.messages.stream(...) and iterate stream.text_stream; in OpenAI you open client.responses.stream(...) and filter the event stream for response.output_text.delta events, reading each chunk from event.delta. Both yield plain text chunks straight through. Toggle the tab to switch SDK.
Uses the client / objects set up earlier in this lesson, and needs your vendor's API key set. Run the earlier blocks first.
stream_endpoint.pyfrom fastapi.responses import StreamingResponse
@app.post("/ask/stream")
async def ask_stream(req: AskRequest):
async def token_gen():
async with client.messages.stream(
model="claude-opus-4-8", max_tokens=req.max_tokens,
messages=[{"role":"user","content": req.question}],
) as stream:
async for text in stream.text_stream:
yield text # each chunk flushed to the client
return StreamingResponse(token_gen(), media_type="text/plain")
stream_endpoint.pyfrom fastapi.responses import StreamingResponse
@app.post("/ask/stream")
async def ask_stream(req: AskRequest):
async def token_gen():
async with client.responses.stream(
model="gpt-5.5", max_output_tokens=req.max_tokens,
input=req.question,
) as stream:
async for event in stream:
if event.type == "response.output_text.delta":
yield event.delta # each chunk flushed to the client
return StreamingResponse(token_gen(), media_type="text/plain")
This endpoint sends the answer back word-by-word as the model writes it, instead of waiting for the whole reply. That's what makes a chat UI feel like live typing rather than a long freeze then a wall of text.
@app.post("/ask/stream")defines a second endpoint at/ask/streamso you keep the plain/askand add a streaming one alongside it.async def token_gen():is an inner generator — a function that hands back pieces one at a time usingyield, rather than returning everything at once.- The
async with client.messages.stream(...)(Claude) /client.responses.stream(...)(OpenAI) line opens a streaming connection to the model. Theasync withmakes sure that connection is closed cleanly when the reply is finished. - The
async forloop receives small chunks of text as the model produces them — iteratingstream.text_streamon Claude, or on OpenAI filtering the event stream forresponse.output_text.deltaevents and readingevent.delta— andyieldimmediately passes each chunk onward to the caller — nothing is held back. return StreamingResponse(token_gen(), media_type="text/plain")hands FastAPI the generator. FastAPI then streams each yielded chunk to the client as it appears.
What the output means: The caller sees text arrive progressively, a few words at a time, instead of one big response after several seconds. The user gets feedback almost instantly.
Try this: Call /ask/stream with curl -N http://localhost:8000/ask/stream (the -N disables buffering) and watch the answer type itself out live.
async def + yield) is the async version of A3's streaming.Lab B3.4 · Errors, validation & dependencies advanced
The gap between a demo and a service is almost entirely about what happens when things go wrong. Your happy-path endpoint works great when the request is well-formed, the key is valid, and the model answers cleanly. But the moment you expose it to the real world, you get empty questions, forty-thousand-character prompts pasted from a PDF, requests with no auth header, and a model that occasionally rate-limits you or refuses to answer. A demo throws a raw stack trace and a 500 at every one of these. A production endpoint gives each its own honest, correct HTTP status — and this lab wires up the three mechanisms that get you there: validation, dependencies, and error mapping.
Take them one at a time. Validation you've already half-built: a Pydantic Field lets you attach rules right to the model — min_length, max_length, ranges — and FastAPI enforces them before your handler runs, bouncing bad input with a 422 for free. Capping input size isn't just tidiness; an unbounded prompt is a cost-and-latency bomb, since you pay per token. Dependencies are the piece worth slowing down on. Think of Depends as a checklist stapled to the door: "before you let this request in, run these checks and gather these things." An auth check, a database handle, the model client — each becomes a small function FastAPI runs first and injects into your handler. That indirection feels like overhead until you write a test: because the client arrives through Depends, you can swap in a fake one with a single line and test your endpoint without ever touching the real API. Injected seams are testable seams.
Error mapping is the third habit, and the mental model is a translator standing between two worlds. The SDK (Anthropic's or OpenAI's) speaks in Python exceptions — RateLimitError, and so on — while HTTP clients speak in status codes. Your job is to translate: a rate-limit becomes a 503 "busy, retry" (a temporary condition the caller can back off from), a model refusal becomes a 422 "can't answer that" (the request was understood but won't be fulfilled), and only a genuinely broken server should ever surface a 500. The classic mistake is letting SDK exceptions bubble up uncaught, so every upstream hiccup looks to your callers like your service crashed — they can't tell "try again in a second" apart from "this is fundamentally broken," and they retry the wrong things. Catch the exceptions you expect, map each to the status code that tells the caller what to do, and your endpoint becomes something other systems can build on reliably. The lab combines all three on one route.
A production endpoint validates input, handles the LLM's failure modes, and manages shared resources (the client, auth) cleanly.
Read each row left→right: on the left is something that can go wrong; the arrow is the endpoint doing the translating; on the right is the HTTP status the caller receives.
- The top box names the endpoint's job here — it stands between the SDK's Python exceptions and the HTTP status codes clients understand.
- The red right-hand boxes (422, 401) are client-fault outcomes: bad input or a missing key — the caller must fix the request.
- The amber right-hand boxes (503, 422) are model/upstream outcomes: a rate-limit says "retry soon", a refusal says "understood but won't answer" — different actions, different codes.
In short: Catch the exceptions you expect and map each to the status code that tells the caller how to react — otherwise every hiccup looks like your server crashed.
A production endpoint — validation, auth & error mapping — in Claude or OpenAI
The validation (Field), the Depends auth check, and the exception-to-HTTP mapping are all vendor-neutral — only the model call and its exception class change. In Claude you catch anthropic.RateLimitError and inspect resp.stop_reason; in OpenAI you catch openai.RateLimitError and inspect resp.status. Toggle the tab to switch SDK.
Uses the client / objects set up earlier in this lesson, and needs your vendor's API key set. Run the earlier blocks first.
robust.pyfrom fastapi import FastAPI, HTTPException, Depends, Header
from pydantic import BaseModel, Field
import anthropic
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=4000) # validation for free
def require_key(x_api_key: str = Header(...)): # a dependency: auth
if not _valid(x_api_key):
raise HTTPException(status_code=401, detail="invalid key")
@app.post("/ask", dependencies=[Depends(require_key)])
async def ask(req: AskRequest) -> AskResponse:
try:
resp = await client.messages.create(model="claude-opus-4-8",
max_tokens=512, messages=[{"role":"user","content": req.question}])
except anthropic.RateLimitError:
raise HTTPException(status_code=503, detail="busy, retry") # map to HTTP (O3)
if resp.stop_reason == "refusal": # handle stop reasons (C2)
raise HTTPException(status_code=422, detail="cannot answer")
return AskResponse(answer=next(b.text for b in resp.content if b.type=="text"))
robust.pyfrom fastapi import FastAPI, HTTPException, Depends, Header
from pydantic import BaseModel, Field
import openai
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=4000) # validation for free
def require_key(x_api_key: str = Header(...)): # a dependency: auth
if not _valid(x_api_key):
raise HTTPException(status_code=401, detail="invalid key")
@app.post("/ask", dependencies=[Depends(require_key)])
async def ask(req: AskRequest) -> AskResponse:
try:
resp = await client.responses.create(model="gpt-5.5",
max_output_tokens=512, input=req.question)
except openai.RateLimitError:
raise HTTPException(status_code=503, detail="busy, retry") # map to HTTP (O3)
if resp.status != "completed": # incomplete / refusal (C2)
raise HTTPException(status_code=422, detail="cannot answer")
return AskResponse(answer=resp.output_text)
A demo endpoint becomes a production endpoint when it checks its input, requires authentication, and turns the model's failures into proper HTTP errors instead of crashing. This block adds all three.
question: str = Field(min_length=1, max_length=4000)— the PydanticFieldadds rules to the input. An empty or too-long question is rejected automatically with a422before your code even runs — validation you get for free just by declaring it.def require_key(x_api_key: str = Header(...))is a dependency: a small check FastAPI runs first. It reads anx-api-keyHTTP header and, if the key is invalid, raisesHTTPException(status_code=401, ...)— the standard code for "not authenticated".@app.post("/ask", dependencies=[Depends(require_key)])attaches that auth check to the endpoint.Depends()is how FastAPI wires in shared pieces (auth, a database, the client) so they're reusable and easy to test.- The
try/exceptwraps the model call: aRateLimitError(the API is busy —anthropic.RateLimitErroron Claude,openai.RateLimitErroron OpenAI) is turned into a503"busy, retry" instead of a stack trace. This maps an SDK error to a sensible HTTP code. - The refusal check (
resp.stop_reason == "refusal"on Claude,resp.status != "completed"on OpenAI) handles the case where the model declined or couldn't finish, returning a422"cannot answer" rather than pretending it succeeded.
What the output means: Bad input → automatic 422. Missing/invalid key → 401. Model busy → 503. Model refuses → 422. Only a genuinely successful call returns the answer — every failure mode has a clear, correct HTTP status.
Try this: Send a request with no x-api-key header and confirm you get a 401. Then send a question longer than 4000 characters and watch the 422 from the Field limit.
| Concern | FastAPI mechanism |
|---|---|
| Input validation | Pydantic Field constraints → automatic 422 on bad input |
| LLM failures | Catch SDK exceptions, map to sensible HTTP codes (429→503, refusal→422) — O3 degradation |
| Shared resources | Depends() injects the client, DB, or auth — testable & reusable |
| Auth | A dependency that checks a header/token; never hardcode keys (T1) |
| Secrets | API key from env, injected at runtime (O3, T1) — not in code |
From app to deployed service advanced
A running app on your laptop and a deployed service look identical in the browser — and could not be more different in reality. The gap is everything that separates "it works when I press play" from "it keeps working at 3am under load, when a machine dies, when the API key rotates, and when a hundred users arrive at once." The reassuring news is that you already learned this material in O3; the point of this section is to show that a FastAPI app is the thing O3 was preparing you to ship. There's no new framework to learn — just the same app, wearing its deployment clothes.
Two ideas are worth calling out because they surprise people coming from CPU-heavy services. First, scale on concurrency, not cores. You met the reason back in the async section: LLM work is I/O-bound, so a single async worker holds many in-flight requests while they wait on the model. That flips the usual autoscaling instinct — you don't add a CPU core per request, you watch the number of concurrent requests and add workers when that climbs. Second, the health check deserves a moment of thought, because the obvious implementation is a trap. Your orchestrator (Kubernetes, a load balancer) pings a /health route constantly to decide if the instance is alive. If that route calls the model, you've turned a liveness probe into a firehose of paid API calls, and a brief model outage will make your orchestrator kill perfectly healthy instances. The right move is a cheap route that returns {"status": "ok"} without touching the model — it answers "is this process up?", which is all a liveness check should ask.
The rest is the boring, load-bearing plumbing: the API key comes from an environment variable injected at runtime, never baked into the image or committed to git (a leaked key is a bill and a breach); the container runs uvicorn to serve the app; graceful shutdown lets in-flight requests finish when a pod is told to stop rather than severing them mid-answer; and middleware logs each request so you can actually see what your service is doing in production. None of these is glamorous, and all of them are the difference between a service you trust and one you babysit. The table maps each O3 concept onto its concrete FastAPI form so the translation is explicit.
uvicorn, you add workers as concurrency climbs (not cores per request), and the load balancer's liveness probe hits a cheap /health that never calls the model.
Start on the left and move right: what goes into the container, then how the platform around it scales and monitors it.
- The env key box feeds the container the API key at runtime — it is never committed to git or baked into the image (a leaked key is a bill and a breach).
- Inside the container,
uvicornserves the app and a cheap/healthroute answers "is the process up?" without spending a paid model call. - On the right: the top arrow shows you add workers as concurrency rises (not a CPU core per request, because the work is I/O-bound); the dashed arrow is the load balancer pinging the model-free liveness probe.
In short: A FastAPI app deploys as an O3 container — runtime-injected key, uvicorn, scale on concurrency, and a health check that never touches the model.
A FastAPI app is exactly what O3 taught you to containerize and scale. The connection is direct:
| O3 concept | Here it is in FastAPI |
|---|---|
| Containerize with runtime secrets | The Dockerfile from O3 runs uvicorn main:app; key via env |
| Scale on concurrency, not CPU | Async workers + autoscale on request concurrency |
| Health check | A cheap @app.get("/health") that doesn't call the model |
| Graceful shutdown | Uvicorn drains in-flight (slow) requests on SIGTERM |
| Observability | Middleware logs the four signals per request (O4, I4) |
run() in an endpoint, add the health check and auth, containerize per O3, and monitor per O4. FastAPI is where your LLM logic becomes a service other systems can actually call.Serving an agent (not just a call) expert
Everything so far assumed a quick, one-shot call — and an agent breaks that assumption in every direction. A single Claude call is like ordering a coffee: you ask, you wait a few seconds, you get one answer, done. An agent is like hiring a contractor to renovate a room: it works in a loop, calls tools, makes decisions, occasionally does something expensive or irreversible, and might take minutes rather than seconds. You can serve it through a FastAPI endpoint — that part doesn't change — but the naive request/response contract that fit a coffee order fits a renovation badly, and this section is about the three ways the design has to bend.
The first bend is time. An HTTP client holding a connection open for five minutes is asking for trouble — timeouts, dropped connections, a spinner the user assumes is frozen. So a long agent run wants one of two shapes instead of a plain wait: stream the run as progress events (the same streaming muscle from the earlier lab, now carrying "step 3: searching…" instead of answer tokens), or accept the task, immediately return a job id, and let the client poll or receive a webhook when it's done. This is the identical long-running-task pattern you'll see in A2A (I2) and LangGraph's durable state (L5); it's not a FastAPI quirk, it's how any system fronts work that outlives a single request.
The second bend is state. A single call is stateless — each request stands alone. A conversation isn't: turn five needs to remember turns one through four. Since your endpoint might be one of many workers behind a load balancer, you can't stash that history in a local variable and hope the same worker handles the next turn. The caller passes a thread or session id, and the conversation state lives in a shared store or a LangGraph checkpointer (L5) that any worker can read. The third bend is safety and cost, and it's the one that bites hardest: an agent can loop, and a looping agent burns tokens and can take real actions. So the endpoint's job isn't just to run the agent but to bound it — a max-steps or token budget so a runaway can't rack up a fortune — and to make it observable by returning structure, not just a blob of text. The mistake to avoid is fronting an agent exactly like you fronted a single call: block for minutes, return a bare string, and hope. Instead, hand back a typed result carrying what the agent did — the answer, how many steps it used, which tools it called, why it stopped — so the caller can see, debug, and trust it. The tables below lay out each concern and its approach.
Compare the two halves. The left is the request shape you've used all chapter; the right is what an agent forces you to change.
- The left side is a single Claude call: the client sends, waits a few seconds (the round-trip arrows), and gets one answer back — plain request/response is fine.
- The right side is an agent run that can take minutes. Because holding the connection open that long invites timeouts ("don't hold open"), you pick one of two shapes.
- The two payoff paths: stream progress (reuse the streaming muscle, now carrying "step 3…" events) or return a job id immediately and let the client poll / webhook for the result — the same long-running-task pattern as A2A and LangGraph.
In short: Front a single call with block-and-return; front a long, stateful agent by streaming progress or handing back a job id to poll.
The endpoints above wrap a single Claude call, but the same shape serves a whole agent (Ch 4, L4/L5). One caveat: agent runs are long and stateful, which changes the endpoint design.
| Agent-serving concern | Approach |
|---|---|
| Runs take a long time | Stream progress, or return a job id and poll/webhook — don't block for minutes |
| Stateful conversations | Pass a thread/session id; back state with a store or LangGraph checkpointer (L5) |
| Risky actions | Human-approval steps become endpoints too (approve/reject) — the L5 interrupt over HTTP |
| Cost/abuse | Auth, rate limits, per-user budgets at the API edge (O2 gateway) |
Common pitfalls expert
| Pitfall | Fix |
|---|---|
Sync SDK call inside an async def route | Use the async client + await; or a threadpool |
| Blocking the event loop (sleep, heavy CPU) | Offload to a threadpool/worker; keep routes non-blocking |
| No input validation | Pydantic Field constraints; cap sizes |
| Unhandled LLM errors/stop reasons | Catch SDK exceptions; branch on stop_reason (C2) |
| Blocking for a long agent run | Stream, or job-id + poll/webhook |
| Unauthenticated public endpoint | Auth dependency, rate limits, guardrails (T1, I5) |
| Health check that calls the model | Cheap liveness route; probe the model sparingly (O3) |
Exercises expert
Exercise B3.1 — Typed classify endpoint
Context: The classifier from Chapter 2 becomes a real service the moment it has a typed contract and input limits. FastAPI's auto-docs and 422s do the tedious guarding for you.
Your task: Build a POST /classify endpoint that takes text and returns a typed {"label", "confidence"} using the Chapter 2 classifier, with a Pydantic Field capping input length.
Requirements:
- Return a typed response with a
labeland aconfidence - Use a Pydantic
Fieldto cap the input text length - Confirm a too-long request gets an automatic 422 — no manual check
- Verify the endpoint appears in the auto-generated docs at
/docs
💡 Hint: Put the length cap in the request model's Field so the 422 is generated for you, then wire the classifier into the response model.
Exercise B3.2 — Sync vs async under load
Context: The async payoff is invisible until you put concurrency on it. Firing many requests at a sync endpoint and an async one side by side makes the event-loop story from the async lesson measurable.
Your task: Write the same LLM endpoint two ways — a plain def with the sync client and an async def with the async client — fire ~20 concurrent requests at each, compare total time, and explain the gap.
Requirements:
- Implement the endpoint both as sync (
def+ sync client) and async (async def+ async client) - Fire roughly 20 concurrent requests at each version
- Measure and compare total wall-clock time for both
- Explain the difference in terms of the event loop overlapping I/O waits
- Show the async version finishing far faster under concurrency
💡 Hint: The async version overlaps the waiting while the blocking version serializes — the concurrent load is what exposes the difference.
Show what to look for
The async version finishes far faster under concurrency because one worker overlaps the waiting; the sync-in-async version (or a blocking call) serializes and stalls. This is the I/O-bound story from O3 made measurable — and why the async client matters.
Exercise B3.3 — Stream + harden
Context: A deployable service is the streaming endpoint plus the boring hardening: auth, error mapping, and a health route that never touches the model. That's what makes it O3-ready rather than a demo.
Your task: Add a streaming /ask/stream endpoint and an auth dependency, map a rate-limit error to a 503 and a refusal to a 422, and add a /health route that doesn't call the model.
Requirements:
- Provide a streaming
/ask/streamendpoint - Gate requests with an auth dependency
- Map a rate-limit error to a 503 and a model refusal to a 422
- Add a
/healthroute that returns without calling the model - The result is a deployable, O3-ready service
💡 Hint: Reuse the streaming generator from the ladder, add the auth via Depends, and translate each upstream failure into its own status code.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: In FastAPI the request model is the contract, and typing it buys you validation and auto-generated docs for free. A malformed request is rejected before your handler ever runs.
Your task: Write a FastAPI POST /echo endpoint that accepts {"text": str} via a Pydantic model and returns the text's length, then show why the model gives you validation for free.
Requirements:
- Define a Pydantic
BaseModelwith a requiredtext: strfield - The endpoint takes the model as its body and returns the length of the text
- A request with the wrong type (e.g. a number) is auto-rejected with a 422 — no hand-written check
- Note the run command (
uvicorn main:app) and that validation is a side effect of typing
💡 Hint: Just declaring the field type on the model is the whole validation story; FastAPI does the 422 for you before your function body executes.
Show solution
Needs fastapi + uvicorn to serve. The request model is the contract; FastAPI validates and documents it automatically:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class EchoIn(BaseModel):
text: str # required, must be a string
@app.post("/echo")
def echo(body: EchoIn):
return {"length": len(body.text)}
# run: uvicorn main:app --reload
# POST {"text": "hi"} -> {"length": 2}
# POST {"text": 123} -> 422 auto-validation error, no code needed
Declaring text: str means malformed requests are rejected with a 422 before your code runs —
validation is a side effect of typing the model.
Context: An LLM call is I/O-bound — it spends most of its time waiting on the network. Handled with async def and await, that waiting frees the event loop to serve other requests instead of blocking a worker.
Your task: Explain why async def plus await matters for a web server calling a slow LLM, and write the async endpoint shape that awaits the model call.
Requirements:
- Explain that the LLM call is I/O-bound and
awaityields the event loop while waiting - The endpoint is declared
async defand takes a typed Pydantic body - It
awaits the async model call rather than blocking - State the rule: never do blocking work inside an
async defor you stall the whole loop - The shape is complete enough to serve many concurrent users from one process
💡 Hint: Mark the handler async def and await the network call — the win is that one process overlaps many requests' waiting.
Show solution
Why async: an LLM call is I/O-bound — it spends most of its time waiting on the network. With
async def + await, that waiting frees the event loop to handle other requests
instead of blocking a worker, so one process serves many concurrent users.
Needs fastapi + an async client. Endpoint shape:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class AskIn(BaseModel):
prompt: str
async def call_model(prompt: str) -> str:
# await the async LLM client here; returns the model's text
...
@app.post("/ask")
async def ask(body: AskIn):
answer = await call_model(body.prompt) # yields the loop while waiting
return {"answer": answer}
Use await on the network call; never do blocking work inside an async def or you
stall the whole event loop.
Context: Streaming doesn't make generation faster, but it slashes perceived latency: the user sees the first tokens almost immediately instead of staring at a spinner for the whole answer.
Your task: Return a streaming response so the client receives tokens as they're generated, using an (async) generator wrapped in a StreamingResponse.
Requirements:
- Write an (async) generator that
yields each token/delta as it arrives - Wrap that generator in a
StreamingResponse - Set an appropriate
media_type(e.g.text/plain) - The endpoint returns the streaming response rather than a fully-built string
- Note that this improves time-to-first-token, not total generation time
💡 Hint: The generator is the engine and StreamingResponse is the wrapper — in real code you iterate the model's streaming API and yield each delta.
Show solution
Needs fastapi. Yield chunks from an (async) generator and wrap it in
StreamingResponse:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def token_stream(prompt: str):
# in real code, iterate the model's streaming API and yield each delta
for tok in ["Hel", "lo, ", "world"]:
yield tok
@app.post("/stream")
async def stream(prompt: str):
return StreamingResponse(token_stream(prompt), media_type="text/plain")
Streaming improves perceived latency — time-to-first-token drops sharply even if total generation
time is unchanged. The generator yields each delta as it arrives.
Context: Production endpoints need to reject bad input with a clean status code and get their shared resources injected rather than built inline. HTTPException and Depends are how FastAPI does both without a mess of globals.
Your task: Add real-world robustness to an ask endpoint: reject an empty prompt with a clean 400 and inject a shared resource (like a model client) via Depends.
Requirements:
- Validate the prompt and raise
HTTPException(status_code=400, ...)when it's empty - Provide the shared client through a dependency function used with
Depends - The endpoint receives the injected client as a parameter
- The dependency is a swappable seam — a fake can be injected in tests
- An expected error surfaces as a proper status code, not an uncaught 500
💡 Hint: Raise HTTPException for the empty-prompt case and let Depends(get_client) hand the client in, which is what makes the endpoint testable.
Show solution
Needs fastapi. Raise HTTPException for expected errors; use Depends
so the endpoint gets its dependencies injected (testable, reusable):
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
app = FastAPI()
class AskIn(BaseModel):
prompt: str
def get_client():
# build/return a shared model client; swap for a fake in tests
return {"model": "demo"}
@app.post("/ask")
async def ask(body: AskIn, client=Depends(get_client)):
if not body.prompt.strip():
raise HTTPException(status_code=400, detail="prompt must not be empty")
return {"model": client["model"], "chars": len(body.prompt)}
Depends makes the client a swappable seam (inject a fake in tests), and
HTTPException turns an expected error into a proper status code instead of a 500.
Context: Endpoints need a running server to exercise, but the rules they enforce shouldn't. Pushing validation into a Pydantic model lets you test the contract in milliseconds with no HTTP at all, and add the mandatory /health route.
Your task: Add a GET /health endpoint, and factor the request validation into a Pydantic model with a validator you can unit-test in pure Python without starting a server.
Requirements:
- Define a
GET /healthroute (needs FastAPI to serve) - Put the prompt rule in a Pydantic
field_validatorthat rejects blank input - A valid model instance is accepted; a blank one raises
ValidationError - Assert both cases in plain Python — no web server required to test the contract
- The same validator protects the live endpoint
💡 Hint: The validator lives on the model, so you can construct the model in a test and assert it raises on blank input while the server-bound routes stay separate.
Show solution
The /health endpoint needs fastapi to serve. But the validation logic lives
in a Pydantic model you can test with no server at all — this block runs clean:
from pydantic import BaseModel, field_validator, ValidationError
class AskIn(BaseModel):
prompt: str
@field_validator("prompt")
@classmethod
def not_blank(cls, v):
if not v.strip():
raise ValueError("prompt must not be empty")
return v
# unit test the contract without starting a web server:
assert AskIn(prompt="hi").prompt == "hi"
try:
AskIn(prompt=" ")
raise AssertionError("should have rejected blank prompt")
except ValidationError:
print("blank prompt correctly rejected")
# the health endpoint (needs fastapi to serve):
# @app.get("/health")
# def health(): return {"status": "ok"}
Pushing validation into the model means you test the rules in milliseconds without HTTP — and the same rule protects the live endpoint.
Context: Fronting a multi-step agent is different from fronting a single call: an agent can loop, call tools, and run up cost. The endpoint's real job is to bound it and make its behavior observable.
Your task: Design the production endpoint that fronts a multi-step agent — what it returns, how it bounds cost, and how failures surface — and provide the response shape.
Requirements:
- Bound the loop with a max-steps or token budget and return a clear 'gave up' result when exceeded
- Return structure, not just text — e.g. answer, steps used, tools called, and a stopped reason
- Fail gracefully: wrap tool/model errors as a 502/503 with a request id, never a raw stack trace
- Stream progress for long runs so the client isn't left on a spinner
- Model the structured result with a Pydantic model (the running system needs the full stack)
💡 Hint: Make the result a Pydantic model carrying stopped_reason ("done" | "max_steps" | "error") so callers can see what the agent did and the budget cap keeps it from running away.
Show solution
Needs the full FastAPI + agent stack to run; the design is the deliverable:
- Bound the loop: pass a max-steps / token budget into the agent; return a clear “gave up” result if it’s exceeded rather than looping forever (and running up cost).
- Return structure, not just text:
{answer, steps_used, tools_called, stopped_reason}so callers can see what happened and debug. - Fail gracefully: wrap tool/model errors and surface a 502/503 with a request id; never leak a raw stack trace.
- Stream progress for long runs so the client isn’t staring at a spinner.
from pydantic import BaseModel
class AgentResult(BaseModel):
answer: str
steps_used: int
tools_called: list[str]
stopped_reason: str # "done" | "max_steps" | "error"
# endpoint (needs the stack):
# @app.post("/agent")
# async def run_agent(body: AskIn) -> AgentResult:
# return await agent.run(body.prompt, max_steps=8)
Fronting an agent is different from fronting a call: the endpoint’s job is to bound it (steps/budget) and make its behavior observable (structured result), because an agent can otherwise run away.
✓ Checkpoint — you can move on when you can…
- Build a FastAPI app with typed request/response models.
- Write an
async defLLM endpoint and explain why async fits. - Stream tokens to the client with a streaming response.
- Validate input, handle LLM errors/stop reasons, and inject dependencies.
- Describe how this app maps to O3 deployment and serving an agent.
Knowledge check check yourself
Why is async def the right shape for an LLM endpoint, and what happens if you make a blocking call inside one?
Show answer
How do FastAPI's Pydantic type hints do "double duty" on an endpoint?