
Chapter Outline
Chapter 3: Middleware, Dependencies, and Error Handling
In this chapter, you will learn:
- What middleware is and how to use it in FastAPI.
- How to apply dependencies for cleaner, reusable logic.
- How to handle errors and exceptions in a structured way.
- How to test error responses with Pytest.
We will extend our Todo API from Chapter 2 by:
- Adding middleware for request logging.
- Creating a dependency to simulate authentication.
- Implementing custom error handling for invalid operations.
3.1 Middleware in FastAPI
In FastAPI, a middleware is a function that runs for every request and response in your application. They provide a central point to perform actions that affect your entire application.
It sits in the "middle" the request-response cycle, allowing you to execute code both before and after the response is generated, but before it's sent back to the client.
3.1.1 Key functions of middleware
Middleware is used to handle cross-cutting concerns that apply globally, preventing you from having to repeat the same code every single endpoint. This is a common an well known design pattern used in Express.js, a popular NodeJS application framework.
3.1.2 Common use cases
- Logging: Recodging details about each request, such as the path, method, and processing time.
- CORS (Cross-Origin Resource Sharing): Adding appropriate CORS headers to allow requests from different origins.
- Caching/Compression: Compressing large responses with GZip or adding custom or cache header headers.
- Security: Rate limit based on IP addresses.
3.1.3 How it works
In FastAPI, you define a middleware by using a function with the @app.middleware("http") decorator. This function receives the incoming request and a call_next function. The call_next function passes the request down the chain to the intended path operation, and your middleware can then capture and modify the response before it is returned.
3.1.4 Example: Request logging middleware
The following code demonstrates a middleware that logs a request, method, path, and duration prior to sending the response back to the client.
fastapi_todo/main.py1import time2from fastapi import FastAPI, Request34app = FastAPI()56@app.middleware("http")7async def log_requests(request: Request, call_next):8 """Middleware to log request method, path, and execution time."""9 start_time = time.time()10 response = await call_next(request)11 duration = time.time() - start_time12 print(f"{request.method} {request.url.path} completed in {duration:.4f}s")13 return response1415@app.get("/")16async def read_root():17 return {"message": "Hello FastAPI!"}
Run your app in a terminal, and issue the following command in another terminal:
bashcurl http://127.0.0.1:8000/
You should see an output similar to the following on the terminal window running your app:
bash2025-10-29 12:52:10,837 - INFO - GET / completed in 0.0012s
The middleware generated the text GET / completed in 0.0012s in the printed log line.
3.1.5 Example: Adding a custom header
The following code demonstrates a middleware that adds a custom header, X-Processed-By-Middleware, to every request.
fastapi_todo/main.py1import time2from fastapi import FastAPI, Request34app = FastAPI()56@app.middleware("http")7async def add_process_time_header(request: Request, call_next):8 # This part runs BEFORE the request is processed by the path operation.9 # The `request` object can be modified here.10 request.state.start_time = time.time()1112 # Pass the modified request to the next function in the chain13 # (e.g., the endpoint).14 response = await call_next(request)1516 # This part runs AFTER the path operation has returned a response.17 process_time = time.time() - request.state.start_time18 response.headers["X-Processed-By-Middleware"] = f"{process_time:.4f}s"1920 # Return the modified response.21 return response2223@app.get("/")24async def read_root():25 return {"message": "Hello FastAPI!"}
If you issued the following command in the terminal:
bashcurl -i http://127.0.0.1:8000/
You should see an output similar to the following on the terminal window:
bash1HTTP/1.1 200 OK2date: Wed, 29 Oct 2025 18:57:37 GMT3server: uvicorn4content-length: 335content-type: application/json6x-processed-by-middleware: 0.0010s78{"message":"Hello FastAPI!"}
3.2 Dependencies in FastAPI
Dependencies are functions that can be injected into routes to provide shared logic, like authentication, DB connections, etc. In FastAPI, dependencies and middleware both address cross-cutting concerns, but they operate at different levels of granularity and serve distinct purposes.
While middleware affects the entire application, dependencies are specific to particular route handlers or groups of handlers, and they inject values diectly into your functions.
3.2.1 Key characteristics
- Scoped: Dependencies are tied to a specific path operation, router, or the entire application, giving you fine-grained control.
- Input-focused: They are primarily used for preparing input data, such as parsing requests headers, querying a database for a user object, or validating a token.
- Integrated: The return value of a dependency is passed as a parameter directly into your endpoint function, making it a clean and clean and intuitive way to access shared resources.
- Can interrupt: If a dependency raises an
HTTPException, it stops the request and returns an error response immediately, preventing the path operation from running.
3.2.2 Common use cases
- Authentication: Getting the current user from a token in the request header.
- Database sessions: Providing a database session object to a route handler.
- Validation: Checking permissions for business logic that must pass before the main logic runs.
3.2.3 Example: Authentication
Here we simulate a simple API key authentication:
fastapi_todo/main.py1from fastapi import Header, HTTPException, Depends23def get_api_key(x_api_key: str = Header(...)) -> str:4 """Simple dependency to check for API key header."""5 if x_api_key != "secret-key":6 raise HTTPException(status_code=403, detail="Forbidden: Invalid API Key")7 return x_api_key89@app.get("/secure-todos", dependencies=[Depends(get_api_key)])10def secure_list_todos() -> list[Todo]:11 """List todos, but only if API key is valid."""12 return todos
Explanation:
- Any request to
/secure-todosmust send a header (case insensitive):
bashX-API-Key: secret-key
- Otherwise, it will return 403 Forbidden.
- Dependencies reduce code duplication and enforce security rules.
3.3 Error Handling
FastAPI offers a powerful and flexible system for handling errors, from simple HTTP errors to complex custom exceptions. By providing a clear and consistent way to communicate failures, it ensures your API is robust and developer-friendly. FastAPI's error handling can be broken down into these primary mechanisms:
3.3.1 HTTPException
This is the most common way to raise HTTP-specific errors in FastAPI. It is a standard Python exception with extra information for APIs, allow you to specify the HTTP status code and a detail message. When you raise an HTTPException, FastAPI automatically catches it and returns an appropriate JSON response.
In an earlier example, we raised an HTTPException for a missing request header x_api_key with the value secret-key.
In the following example, if a requested item is not found, you can raise an HTTPException with a 404 Not Found status code.
fastapi_todo/main.py1from fastapi import HTTPException23@app.get("/todos/{todo_id}", response_model=TodoItem)4def get_todo(todo_id: int) -> TodoItem:5 """Retrieve a Todo item by ID."""6 for todo in todos:7 if todo.id == todo_id:8 return todo9 raise HTTPException(status_code=404, detail="Todo item not found")
3.3.2 Pydantic based
FastAPI's tight integration with Pydantic means it provides automatic data validation for requests. If a client dens invalid data (e.g., a string instead of a number), Pydantic raises a ValidationError, which FastAPI then transforms into a standard 422 Unprocessable Entity HTTP response.
fastapi_todo/main.py1from pydantic import BaseModel, Field23class TodoItem(BaseModel):4 """Represents a Todo item."""5 id: int6 title: str = Field(..., min_length=1, max_length=100)7 completed: bool = False89# If a POST request to this endpoint sends a `id` that is not a number,10# or non-string `title` FastAPI will automatically return a 422 error response.11@app.post("/todos", response_model=TodoItem, status_code=201)12def create_todo(todo: TodoCreate) -> TodoItem:13 """Create a new Todo item."""14 global next_id15 new_todo = TodoItem(id=next_id, title=todo.title, completed=False)16 todos.append(new_todo)17 next_id += 118 return new_todo
Try the following command in a terminal window:
bashcurl -X POST http://127.0.0.1:8000/todos \-H "Content-Type: application/json" \-d '{"title": ""}'
You should see something like the following in the terminal as a reponse from the server:
json{"error":"Validation failed","details":[{"type":"string_too_short","loc":["body","title"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}}]}
3.3.3 Global Exception Handlers
Register a handler for a specific exception type. When raised anywhere in the app, FastAPI will invoke this handler and return its result.
fastapi_todo/main.py1from fastapi.responses import JSONResponse2from fastapi.exception_handlers import RequestValidationError3from fastapi.exceptions import RequestValidationError45@app.exception_handler(RequestValidationError)6async def validation_exception_handler(request, exc):7 """Custom handler for validation errors."""8 return JSONResponse(9 status_code=422,10 content={"error": "Validation failed", "details": exc.errors()},11 )
You can also handle custom Python exceptions:
fastapi_todo/main.py1class DatabaseError(Exception):2 pass34@app.exception_handler(DatabaseError)5async def db_error_handler(request: Request, exc: DatabaseError):6 return JSONResponse(status_code=500, content={"detail": "Database failure"})
3.3.4 Custom Exception Classes
Create your own exception class and corresponding handler for rich error information.
fastapi_todo/main.py1class APIError(Exception):2 def __init__(self, code: int, message: str, details: dict = None):3 self.code = code4 self.message = message5 self.details = details or {}67@app.exception_handler(APIError)8async def api_error_handler(request: Request, exc: APIError):9 return JSONResponse(10 status_code=exc.code,11 content={"message": exc.message, "details": exc.details}12 )1314@app.get("/divide")15def divide(a: float, b: float):16 if b == 0:17 raise APIError(18 code=400,19 message="Division by zero is not allowed",20 details={"a": a, "b": b}21 )22 return {"result": a / b}
This tells FastAPI Whenever an APIError is raised anywhere in a request handler, use this function to produce the response. The handler converts the exception into a structured JSONResponse.
To try to trigger the custom error, you can use the following command:
bashcurl "http://127.0.0.1:8000/divide?a=10&b=0"
You should see an error response similar to the following in your console:
json{"message":"Division by zero is not allowed","details":{"a":10.0,"b":0.0}}
3.4 Tests for Middleware, Dependencies, and Errors
python1from fastapi.testclient import TestClient2from fastapi_todo.main import app34client = TestClient(app)567def test_secure_todos_without_key() -> None:8 response = client.get("/secure-todos")9 assert response.status_code == 40310 assert response.json() == {"error": "Forbidden: Invalid API Key"}111213def test_secure_todos_with_key() -> None:14 response = client.get("/secure-todos", headers={"X-API-Key": "secret-key"})15 assert response.status_code == 20016 assert isinstance(response.json(), list)171819def test_validation_error() -> None:20 response = client.post("/todos", json={}) # missing required "title"21 assert response.status_code == 42222 body = response.json()23 assert body["error"] == "Validation failed"24 assert "details" in body
Explanation:
- Verifies that the dependency (API key) works correctly.
- Confirms that validation errors return custom JSON responses.
3.5 Middleware Flow
3.6 Dependency Injection
3.7 Further Reading
Check your understanding
Test your knowledge of Middleware, Dependencies, and Error Handling