Building a real app
APIRouter — organizing routes across files
Every example so far has lived in one file. A real app splits routes
across files the same way
any Python project splits code across modules
— APIRouter is FastAPI’s version of that split:
# agents.py
from fastapi import APIRouter
router = APIRouter(prefix="/agents", tags=["agents"])
@router.get("/{agent_id}")
def get_agent(agent_id: int):
return {"agent_id": agent_id}# main.py
from fastapi import FastAPI
from agents import router as agents_router
app = FastAPI()
app.include_router(agents_router)click Run to see this pane's outputprefix="/agents" means every route on this router is automatically
prefixed — the route defined as "/{agent_id}" in agents.py actually
serves GET /agents/{agent_id} once included in main.py. main.py
just imports and includes routers, the same
from module import name pattern from the functions lesson,
keeping the app’s entry point small as more routers get added for
other resources.
CORS — allowing cross-origin requests
A browser blocks a web page from one origin (https://my-frontend.com)
from calling an API on a different origin (https://api.my-app.com) by
default — a security restriction browsers enforce, called CORS
(Cross-Origin Resource Sharing). An agent frontend calling this API from
a different domain is exactly this situation, and needs the API to
explicitly opt in:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://my-frontend.com"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)add_middleware wraps every request/response passing through the app
— here, adding the specific headers that tell a browser “requests from
https://my-frontend.com are allowed.” Without this, a browser-based
frontend calling this API from a different origin would have its
requests blocked by the browser itself, before your API even sees
them. This one is genuinely a browser-only behavior — curl never
enforces CORS at all, so there’s no terminal demo that could show it
accurately; it only ever shows up as a blocked request in a real
browser’s network tab.
Rate limiting
Beyond who can call an endpoint
(Concept 9’s authentication),
a real API also needs to limit how often — especially relevant for an
endpoint that calls an LLM, where each request carries a real cost.
slowapi is a common library for this, not built into FastAPI itself:
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.get("/generate")
@limiter.limit("5/minute")
def generate(request: Request):
return {"result": "..."}click Run to see this pane's output@limiter.limit("5/minute") restricts each client (identified here by
IP address, via get_remote_address) to 5 requests per minute to this
route — a 6th request within that window gets rejected automatically
with a 429 Too Many Requests response, before generate’s own logic
runs.
Startup and shutdown: the lifespan context manager
Some setup should happen exactly once — when the app starts — rather
than on every single request: opening a shared database connection
pool, initializing a client for an external API. FastAPI’s lifespan
is
a context manager,
the same mechanism as with open(...), just scoped to the entire
application’s lifetime instead of one file:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
print("starting up: connecting to shared resources...")
app.state.llm_client = create_llm_client()
yield
print("shutting down: closing connections...")
app.state.llm_client.close()
app = FastAPI(lifespan=lifespan)click Run to see this pane's outputEverything before yield runs once, at startup; everything after
yield runs once, at shutdown —
the exact same before-yield/after-yield structure a context manager always has,
guaranteeing the shutdown code runs even if the app is stopped
abnormally, the same guarantee with gives a single file.
Background tasks — work after the response is sent
Some work shouldn’t delay a response at all — logging an analytics
event, sending a notification — but also doesn’t need to block the
client waiting for it to finish. BackgroundTasks schedules a function
to run after the response has already been sent:
from fastapi import BackgroundTasks
def log_generation(prompt: str):
print(f"logging: generated a response for '{prompt}'")
@app.post("/generate")
def generate(prompt: str, background_tasks: BackgroundTasks):
result = f"response to: {prompt}"
background_tasks.add_task(log_generation, prompt)
return {"result": result}click Run to see this pane's outputclick Run to see this pane's outputThe client receives {"result": "..."} immediately — log_generation
runs afterward, in the background, without the client waiting on it at
all. This is a different tool than
Lesson 7’s create_task():
create_task() starts something concurrent within a running
coroutine; BackgroundTasks specifically defers work until after a
response has already gone out, which is the shape that fits logging or
notification work tied to one specific request.
What does prefix="/agents" on an APIRouter do?