Response models, status codes, and exception handling
response_model — controlling what actually goes out
Just as a BaseModel parameter validates what comes in, a route’s
response_model controls and validates what goes out — and,
notably, strips anything not declared on that model, even if the
function actually returns more:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class AgentConfig(BaseModel):
name: str
model: str
class StoredAgent(BaseModel):
id: int
name: str
model: str
internal_notes: str
@app.get("/agents/{agent_id}", response_model=AgentConfig)
def get_agent(agent_id: int):
return StoredAgent(id=agent_id, name="research_agent", model="claude-sonnet", internal_notes="flagged for review")click Run to see this pane's outputEven though get_agent actually returns a StoredAgent — including
id and internal_notes — the response sent to the client only
contains AgentConfig’s fields. id and internal_notes are silently
dropped, not because the function didn’t have them, but because
response_model defines the actual public contract. This matters for
exactly the reason it sounds like it would: keeping something like
internal_notes out of the response entirely, by declaring the
response shape explicitly, rather than trusting every route to
remember not to leak it.
status_code — the response isn't always 200
By default, a successful response is 200 OK. status_code on the
route decorator sets a different one — 201 Created is the
conventional choice for a successful POST:
@app.post("/agents", response_model=AgentConfig, status_code=201)
def create_agent(config: AgentConfig):
return configclick Run to see this pane's output-i on curl prints the response’s status line along with its body —
201 Created, not the default 200, exactly as status_code=201
declared.
HTTPException — the HTTP-flavored version of a custom exception
Custom exceptions from the I/O lesson
communicated a specific failure by type. HTTPException is FastAPI’s
version of the same idea, specifically shaped for HTTP — it carries a
status code and a detail message, and raising it anywhere inside a
route short-circuits straight to an error response:
from fastapi import HTTPException
agents_db = {42: {"name": "research_agent", "model": "claude-sonnet"}}
@app.get("/agents/{agent_id}", response_model=AgentConfig)
def get_agent(agent_id: int):
if agent_id not in agents_db:
raise HTTPException(status_code=404, detail=f"agent {agent_id} not found")
return agents_db[agent_id]click Run to see this pane's outputraise HTTPException(...) works exactly like
any other raise you’ve written,
it just happens to be an exception type FastAPI specifically knows how
to turn into a proper HTTP error response.
Global exception handlers — closing the loop on custom exceptions
HTTPException is fine per-route, but
a custom exception hierarchy, like AgentError/ConfigError from the I/O lesson,
can be handled globally, once, rather than wrapped in a
try/except/HTTPException in every single route that might raise
it:
from fastapi import Request
from fastapi.responses import JSONResponse
class AgentError(Exception):
pass
class DuplicateAgentError(AgentError):
def __init__(self, name: str):
self.name = name
super().__init__(f"an agent named '{name}' already exists")
@app.exception_handler(AgentError)
def handle_agent_error(request: Request, exc: AgentError):
return JSONResponse(status_code=409, content={"detail": str(exc)})
@app.post("/agents", response_model=AgentConfig, status_code=201)
def create_agent(config: AgentConfig):
if config.name in existing_names:
raise DuplicateAgentError(config.name)
return configclick Run to see this pane's outputThe second call never touches try/except inside create_agent at
all — handle_agent_error intercepts the raised DuplicateAgentError
before it becomes an unhandled error, and turns it into that 409
automatically.
@app.exception_handler(AgentError) registers handle_agent_error to
run whenever any AgentError — including any subclass, like
DuplicateAgentError,
the same isinstance-based catching from the OOP and I/O lessons
— is raised anywhere in the app, converting it into a proper 409 Conflict response, without create_agent (or any other route) needing
its own try/except for this case at all.
What does response_model=AgentConfig actually do to a route function's return value?
Build a small in-memory agent registry with two endpoints. POST /agents takes an AgentConfig body (name: str, model: str) and returns a 201 with response_model=AgentConfig — if an agent with that name already exists, raise a custom DuplicateAgentError (registered with a global exception handler returning 409 and {"detail": "an agent named '<name>' already exists"}), rather than handling it with HTTPException directly in the route. GET /agents/{agent_id}, where agent_id: int = Path(gt=0), returns the matching agent with response_model=AgentConfig — if no agent with that id exists, raise HTTPException(status_code=404, detail=f"agent {agent_id} not found"). Both routes must be async def.