Global Exception Handler
Add a catch-all handler that returns consistent JSON for any unhandled error
Writing code and entering commands is only available on desktop. Open this page on a larger screen to complete this chapter.
The problem with unhandled errors
Your API handles expected errors like "expense not found" with HTTPException. But what about unexpected errors — a bug in your code, a corrupted file, or an import that fails at runtime?
Without a handler, FastAPI returns a raw 500 error. The response format varies, and in some cases the response body contains a Python traceback — exposing internal code paths to the client. This is both unhelpful for the client and a security risk.
A consistent error response
A global exception handler catches any unhandled exception and replaces it with a consistent JSON response:
{"detail": "Internal server error"}The client always gets the same shape, regardless of what went wrong internally. The real error details go to the server logs, where developers can see them without exposing them to users.
How it works
FastAPI's @app.exception_handler(Exception) decorator registers a function that catches any exception not already handled by a more specific handler. Your HTTPException handlers still work normally — this handler only catches what falls through.
Instructions
Add a global exception handler.
- Add two imports at the top of the file:
tracebackandJSONResponsefromfastapi.responses. - After the middleware and before the first endpoint, add the
@app.exception_handler(Exception)decorator. - Define an async function called
global_exception_handlerthat takesrequestandexcas parameters. - Inside the function, call
traceback.print_exc()— this logs the full error traceback to the console. - Return
JSONResponse(status_code=500, content={"detail": "Internal server error"}).
import json
import os
import time
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal, Optional
# Step 1: Add import traceback and from fastapi.responses import JSONResponse
class Expense(BaseModel):
description: str = Field(min_length=1)
amount: float = Field(gt=0)
category: Literal["food", "transport", "entertainment", "utilities", "other"]
date: Optional[str] = None
@field_validator("date")
@classmethod
def validate_date_format(cls, v):
if v is not None:
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("Date must be in YYYY-MM-DD format")
return v
class ExpenseUpdate(BaseModel):
description: Optional[str] = Field(default=None, min_length=1)
amount: Optional[float] = Field(default=None, gt=0)
category: Optional[Literal["food", "transport", "entertainment", "utilities", "other"]] = None
class ExpenseOut(BaseModel):
id: int
description: str
amount: float
category: str
date: Optional[str] = None
class ErrorResponse(BaseModel):
detail: str
class Settings(BaseSettings):
data_file: str = "expenses.json"
app_title: str = "FastAPI"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
app = FastAPI(title=settings.app_title)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
DATA_FILE = Path(settings.data_file)
def save_expenses():
temp_path = str(DATA_FILE) + ".tmp"
with open(temp_path, "w") as f:
json.dump({"counter": counter, "expenses": expenses}, f, indent=2)
os.replace(temp_path, str(DATA_FILE))
def load_expenses():
global expenses, counter
if DATA_FILE.exists():
data = json.loads(DATA_FILE.read_text())
expenses = {int(k): v for k, v in data["expenses"].items()}
counter = data["counter"]
expenses = {}
counter = 0
load_expenses()
@app.middleware("http")
async def log_requests(request, call_next):
start = time.time()
response = await call_next(request)
duration = (time.time() - start) * 1000
print(f"{request.method} {request.url.path} {response.status_code} {duration:.1f}ms")
return response
# Steps 2-5: Add @app.exception_handler(Exception)
# Define async def global_exception_handler(request, exc)
# traceback.print_exc(), return JSONResponse(500)
# Define async def global_exception_handler(request, exc)
# Call traceback.print_exc()
# Return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.post("/expenses", status_code=201, response_model=ExpenseOut)
def create_expense(expense: Expense):
global counter
counter += 1
expenses[counter] = {"id": counter, **expense.model_dump()}
save_expenses()
return expenses[counter]
@app.get("/expenses", response_model=list[ExpenseOut])
def list_expenses(category: str = None):
if category:
return [e for e in expenses.values() if e["category"] == category]
return list(expenses.values())
@app.get("/expenses/{expense_id}", response_model=ExpenseOut, responses={404: {"model": ErrorResponse}})
def get_expense(expense_id: int):
if expense_id not in expenses:
raise HTTPException(status_code=404, detail="Expense not found")
return expenses[expense_id]
@app.delete("/expenses/{expense_id}", response_model=ExpenseOut, responses={404: {"model": ErrorResponse}})
def delete_expense(expense_id: int):
if expense_id not in expenses:
raise HTTPException(status_code=404, detail="Expense not found")
save_expenses()
return expenses.pop(expense_id)
@app.patch("/expenses/{expense_id}", response_model=ExpenseOut, responses={404: {"model": ErrorResponse}})
def update_expense(expense_id: int, updates: ExpenseUpdate):
if expense_id not in expenses:
raise HTTPException(status_code=404, detail="Expense not found")
expenses[expense_id].update(updates.model_dump(exclude_unset=True))
save_expenses()
return expenses[expense_id]
@app.get("/summary")
def spending_summary():
summary = {}
for expense in expenses.values():
cat = expense["category"]
summary[cat] = summary.get(cat, 0) + expense["amount"]
return summary
Interactive Code Editor
Sign in to write and run code, track your progress, and unlock all chapters.
Sign In to Start Coding