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")
internal_notes never leaves the server
click Run to see this pane's output

Even 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 config
201, not 200
click 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]
A missing agent
click Run to see this pane's output

raise 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 config
First agent, then a duplicate
click Run to see this pane's output

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

Check your understanding
1/6

What does response_model=AgentConfig actually do to a route function's return value?

Exercise · Graded (real FastAPI, in-browser)

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.