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 modulesAPIRouter 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)
The router's prefix, applied automatically
click Run to see this pane's output

prefix="/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": "..."}
5 requests OK, the 6th rejected
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)
Startup, then Ctrl+C to stop
click Run to see this pane's output

Everything 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}
Client: gets the response immediately
click Run to see this pane's output
Server logs: the background task, after
click Run to see this pane's output

The 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.

Check your understanding
1/5

What does prefix="/agents" on an APIRouter do?