Lesson 2 - Handling Errors

Welcome to Handling Errors

In the last lesson you learned to return the right status code when things go well. But real APIs spend a lot of their time on the unhappy paths: a client asks for a task that doesn’t exist, tries to act on someone else’s data, or sends a request that clashes with what’s already there. When that happens, returning 200 OK with a confusing body is the worst thing you can do — the client thinks everything worked. A good API fails loudly and clearly, with a 4xx status code and a message that says exactly what went wrong.

FastAPI gives you two tools for this. The first, HTTPException, is a ready-made way to stop a request and return an error response with one line. The second, custom exception handlers, lets you catch your own error classes and turn them into clean HTTP responses in one central place — so your business logic stays readable and every error of a given kind looks the same. We’ll build both on the Task Manager API from earlier modules.

By the end of this lesson, you will be able to:

  • Raise an HTTPException to return a proper error response with a clear message
  • Choose the right status code for each failure (404, 403, 409, 400)
  • Attach response headers to an HTTPException with the headers= argument
  • Register a custom exception handler that maps your own exception classes to tailored responses

You’ll start with the most common error of all: something the client asked for isn’t there. Let’s begin.


Raising an Error with HTTPException

Suppose a client asks for a task by its ID. If the task exists, you return it. If it doesn’t, you must not return 200 with null or an empty object — you must return 404 Not Found. The clean way to do that in FastAPI is to raise an HTTPException.

HTTPException is an exception class FastAPI gives you. When you raise it inside a path operation, FastAPI stops processing the request and sends back an error response using the status_code and detail you provide:

from fastapi import FastAPI, HTTPException

app = FastAPI()
TASKS = {1: "Write the report"}

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    if task_id not in TASKS:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    return {"id": task_id, "title": TASKS[task_id]}

Read the function top to bottom. If the requested task_id isn’t in our TASKS dictionary, we raise HTTPException(...) and the function stops right there — the return line below is never reached. If the task does exist, we skip the if and return it normally. Let’s see both outcomes with the TestClient:

from fastapi.testclient import TestClient

client = TestClient(app)
print(client.get("/tasks/1").status_code, client.get("/tasks/1").json())
print(client.get("/tasks/99").status_code, client.get("/tasks/99").json())
200 {'id': 1, 'title': 'Write the report'}
404 {'detail': 'Task 99 not found'}

Two things to notice. First, the existing task comes back with 200 and its data, exactly as before. Second, the missing task returns 404 and a JSON body — {'detail': 'Task 99 not found'}. FastAPI took the string you passed as detail and placed it under the key "detail" in the response body. That’s the standard shape for FastAPI errors, so clients always know where to look for the message.

The key idea: raise is how you interrupt a request. You don’t have to thread an “error” flag back through your code — you raise, and FastAPI handles building the response.


Choosing the Right Status Code (and Adding Headers)

HTTPException always works the same way; what changes is the status_code you pick. Choosing the right one is what makes your errors useful. Here are the four you’ll reach for most in a Task Manager:

  • 404 Not Found — the thing the client asked for doesn’t exist (a task ID that isn’t in the database).
  • 403 Forbidden — the client is known, but isn’t allowed to do this (editing a task owned by someone else).
  • 409 Conflict — the request clashes with the current state (creating a task that already exists, or completing one that’s already done).
  • 400 Bad Request — the request is malformed in a way validation didn’t catch (a date in the past, an empty title after trimming spaces).

Here’s each one as a separate guard in a single endpoint, so you can see how the code reads:

from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.post("/tasks/{task_id}/assign")
def assign_task(task_id: int, owner: str):
    if task_id not in {1, 2}:
        raise HTTPException(status_code=404, detail="Task not found")
    if owner == "guest":
        raise HTTPException(status_code=403, detail="Guests cannot own tasks")
    if task_id == 2:
        raise HTTPException(status_code=409, detail="Task is already assigned")
    if owner.strip() == "":
        raise HTTPException(status_code=400, detail="Owner name cannot be blank")
    return {"task_id": task_id, "owner": owner}

Each if checks one failure condition and raises the code that fits it. A reader (or a frontend developer) can tell at a glance what each error means just from the number: 404 is “gone”, 403 is “not allowed”, 409 is “clash”, 400 is “you sent something bad.”

HTTPException also accepts an optional headers= argument, a dictionary of response headers to send alongside the error. This is occasionally important — for example, a 401 Unauthorized is supposed to include a WWW-Authenticate header so the client knows how to authenticate:

raise HTTPException(
    status_code=401,
    detail="Not authenticated",
    headers={"WWW-Authenticate": "Bearer"},
)

You won’t need headers= for most errors, but it’s there when a status code’s specification calls for an extra header. Just know it exists.


Custom Exception Handlers for Your Own Errors

So far we’ve raised HTTPException inside each path operation. That’s perfect for one-off checks. But imagine your app has real business rules — “you can’t buy an item that’s out of stock” — and those rules live deep inside helper functions, far from any endpoint. You don’t want every helper importing HTTPException and knowing about HTTP status codes; that mixes your domain logic (the rules of your app) with web concerns (status codes and JSON shapes).

The clean solution is a custom exception handler. You define your own plain Python exception class for the domain error, raise that wherever the rule is broken, and register one handler that says “whenever this exception escapes, turn it into this HTTP response.” The mapping from error to response lives in exactly one place.

Here’s the pattern. We define an OutOfStock exception, register a handler for it with the @app.exception_handler decorator, and raise it from an endpoint:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

class OutOfStock(Exception):
    def __init__(self, name):
        self.name = name

@app.exception_handler(OutOfStock)
def handle_oos(request: Request, exc: OutOfStock):
    return JSONResponse(status_code=409, content={"error": f"{exc.name} is out of stock"})

@app.get("/buy/{name}")
def buy(name: str):
    raise OutOfStock(name)

Let’s walk through the three pieces:

  1. class OutOfStock(Exception) is an ordinary Python exception. It carries one piece of data, name, so the handler knows what was out of stock. It knows nothing about HTTP — that’s the point.
  2. @app.exception_handler(OutOfStock) registers handle_oos as the handler for any OutOfStock raised anywhere in the app. FastAPI calls it with the incoming request and the exception instance (exc). Here we build a JSONResponse with status 409 and our own body shape.
  3. raise OutOfStock(name) in the endpoint simply expresses the rule. There’s no status code or JSON here — the endpoint just signals “this is out of stock” and lets the handler decide how that becomes a response.

Now let’s call it:

from fastapi.testclient import TestClient

client = TestClient(app)
r = client.get("/buy/widget")
print(r.status_code, r.json())
409 {'error': 'widget is out of stock'}

The endpoint raised a bare OutOfStock, yet the client received a clean 409 with the body {'error': 'widget is out of stock'}. Notice the body uses the key "error", not "detail" — because we shaped it inside JSONResponse. With a custom handler you control the exact response, and you write that mapping once no matter how many places raise the exception.

HTTPException uses “detail”; custom handlers shape their own body

A raised HTTPException always returns its message under the "detail" key — that’s FastAPI’s standard error shape, and it’s great for quick, local checks. A custom exception handler, by contrast, builds its own JSONResponse, so you decide the status code and every key in the body (we used "error" above). Reach for HTTPException for one-off guards inside an endpoint; reach for a custom handler when a domain error can happen in many places and you want one tidy place to map it to a response.


Practice Exercises

Exercise 1: Raise a 404

You have a PROJECTS dictionary and a GET /projects/{project_id} endpoint. Add a check that raises HTTPException when the project ID isn’t found, with a clear message. What status code and message would you use?

Hint

Use if project_id not in PROJECTS: raise HTTPException(status_code=404, detail=f"Project {project_id} not found"). The 404 says “not found” and the detail string lands under the "detail" key in the response body, so the client gets a readable message.

Exercise 2: Pick the code

For each failure, choose the right status code: (a) a user tries to delete a task that belongs to a different user; (b) a user tries to create a task with a title that already exists; (c) a user submits a due date that is in the past.

Hint

(a) 403 Forbidden — the user is known but not allowed to touch that task. (b) 409 Conflict — the request clashes with existing state. (c) 400 Bad Request — the request is malformed in a way validation didn’t catch. Each number tells the client the kind of problem at a glance.

Exercise 3: Add a custom handler

You have a domain exception TaskLocked(Exception) raised by deep helper code when someone edits a locked task. Write the handler so that any TaskLocked becomes a 423-style conflict response with the body {"error": "<title> is locked"}. Which decorator and response class do you need?

Hint

Register it with @app.exception_handler(TaskLocked) on a function (request: Request, exc: TaskLocked), and return JSONResponse(status_code=409, content={"error": f"{exc.title} is locked"}). Because you build the JSONResponse yourself, you control both the status code and the exact keys in the body.


Summary

A professional API fails clearly. The quickest way is to raise HTTPException(status_code=..., detail="...") inside a path operation: FastAPI stops the request and returns an error response with your message under the "detail" key. Choosing the right code matters — 404 for missing, 403 for not allowed, 409 for a clash, 400 for a malformed request — and you can attach extra response headers with the headers= argument when a code calls for them. For your own domain errors that can happen in many places, define a plain exception class, register a handler with @app.exception_handler(...), and return a tailored JSONResponse. That keeps your business logic free of HTTP details and maps each error to a clean response in exactly one place.

Key Concepts

  • HTTPException — raise it to stop a request and return an error response.
  • detail= — the message; FastAPI places it under the "detail" key.
  • Choosing codes404 not found, 403 forbidden, 409 conflict, 400 bad request.
  • headers= — optional response headers to send with an HTTPException.
  • @app.exception_handler(MyError) — map a custom exception class to a tailored JSONResponse.

Why This Matters

Errors are part of your API’s contract, not an afterthought. A frontend that gets a 404 knows to show “not found”; one that gets a 403 knows to show “you don’t have access” — but only if your API returns the right code with a clear body. HTTPException makes local checks a one-liner, and custom handlers let your domain code raise meaningful errors (OutOfStock, TaskLocked) without ever importing a status code, so the messy translation to HTTP lives in one tidy place. Get this right and every consumer of your API — and every teammate reading your code — has an easier life.


Next Steps

Continue to Lesson 3 - Headers, Cookies, and Forms

Read request headers and cookies, and accept form-encoded data alongside JSON.

Back to Module Overview

Return to the HTTP Done Right module overview


Continue Building Your Skills

You can now turn failures into clean, correct responses — raising HTTPException for everyday checks and registering custom handlers for your own kinds of errors. Next you’ll round out your command of HTTP by reading what clients send beyond the URL and JSON body: request headers, cookies, and form-encoded data.