DEV Community

Vikas S
Vikas S

Posted on

FastAPI Tutorial Series Part 3: Error Handling and Status Codes

FastAPI Tutorial Series Part 3: Error Handling and Status Codes

Understanding how to handle errors properly is crucial for building robust APIs. In this part, we'll learn how to return appropriate status codes and create meaningful error messages.

HTTP Status Codes Quick Reference

Before we dive in, here are the most common status codes:

  • 200 OK: Request succeeded
  • 201 Created: Resource created successfully
  • 400 Bad Request: Client sent invalid data
  • 404 Not Found: Resource doesn't exist
  • 500 Internal Server Error: Something went wrong on the server

Default Status Codes in FastAPI

FastAPI automatically returns appropriate status codes:

from fastapi import FastAPI

app = FastAPI()

@app.get("/items")  # Returns 200 by default
def get_items():
    return {"items": []}

@app.post("/items")  # Returns 200 by default
def create_item():
    return {"message": "Item created"}
Enter fullscreen mode Exit fullscreen mode

But 200 OK isn't always the best choice for POST requests.

Setting Custom Status Codes

For a POST request that creates a resource, 201 Created is more appropriate:

from fastapi import FastAPI, status

app = FastAPI()

@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(name: str, price: float):
    return {"name": name, "price": price}
Enter fullscreen mode Exit fullscreen mode

The status module provides readable names for all HTTP status codes.

Raising HTTP Exceptions

When something goes wrong, use HTTPException to return an error:

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

items = {
    1: {"name": "Laptop", "price": 999.99},
    2: {"name": "Mouse", "price": 29.99}
}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in items:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item with id {item_id} not found"
        )
    return items[item_id]
Enter fullscreen mode Exit fullscreen mode

Try accessing:

  • http://127.0.0.1:8000/items/1 (exists)
  • http://127.0.0.1:8000/items/999 (doesn't exist)

Custom Error Messages

You can provide detailed error information:

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/users/{user_id}")
def get_user(user_id: int):
    if user_id < 1:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="User ID must be a positive integer",
            headers={"X-Error": "Invalid user ID"}
        )

    if user_id > 100:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail={
                "error": "User not found",
                "user_id": user_id,
                "suggestion": "Check if the user ID is correct"
            }
        )

    return {"user_id": user_id, "name": "John Doe"}
Enter fullscreen mode Exit fullscreen mode

Building a Complete TODO API with Error Handling

Let's put everything together with a practical example:

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional, List

app = FastAPI()

class Todo(BaseModel):
    title: str
    description: Optional[str] = None
    completed: bool = False

class TodoInDB(Todo):
    id: int

# In-memory database
todos_db: List[TodoInDB] = []
next_id = 1

@app.post("/todos", response_model=TodoInDB, status_code=status.HTTP_201_CREATED)
def create_todo(todo: Todo):
    global next_id

    # Check if todo with same title exists
    for existing_todo in todos_db:
        if existing_todo.title.lower() == todo.title.lower():
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=f"Todo with title '{todo.title}' already exists"
            )

    todo_in_db = TodoInDB(**todo.dict(), id=next_id)
    todos_db.append(todo_in_db)
    next_id += 1
    return todo_in_db

@app.get("/todos", response_model=List[TodoInDB])
def get_todos(completed: Optional[bool] = None):
    if completed is None:
        return todos_db
    return [todo for todo in todos_db if todo.completed == completed]

@app.get("/todos/{todo_id}", response_model=TodoInDB)
def get_todo(todo_id: int):
    for todo in todos_db:
        if todo.id == todo_id:
            return todo

    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Todo with id {todo_id} not found"
    )

@app.put("/todos/{todo_id}", response_model=TodoInDB)
def update_todo(todo_id: int, todo_update: Todo):
    for index, todo in enumerate(todos_db):
        if todo.id == todo_id:
            updated_todo = TodoInDB(**todo_update.dict(), id=todo_id)
            todos_db[index] = updated_todo
            return updated_todo

    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Todo with id {todo_id} not found"
    )

@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
    for index, todo in enumerate(todos_db):
        if todo.id == todo_id:
            todos_db.pop(index)
            return

    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Todo with id {todo_id} not found"
    )

@app.patch("/todos/{todo_id}/complete", response_model=TodoInDB)
def mark_complete(todo_id: int):
    for todo in todos_db:
        if todo.id == todo_id:
            todo.completed = True
            return todo

    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Todo with id {todo_id} not found"
    )
Enter fullscreen mode Exit fullscreen mode

Testing the Error Handling

Using the docs at http://127.0.0.1:8000/docs, try:

  1. Create a todo (POST /todos):
{
  "title": "Learn FastAPI",
  "description": "Complete the tutorial series"
}
Enter fullscreen mode Exit fullscreen mode
  1. Try creating a duplicate (should fail with 400):
{
  "title": "Learn FastAPI",
  "description": "Different description"
}
Enter fullscreen mode Exit fullscreen mode
  1. Get a non-existent todo (should fail with 404):

    • GET /todos/999
  2. Update and delete todos to see different status codes

Common Status Codes in RESTful APIs

Here's when to use each:

# 200 OK - Successful GET, PUT, PATCH
@app.get("/items/{item_id}")
def get_item(item_id: int):
    return {"id": item_id}

# 201 Created - Successful POST
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(name: str):
    return {"name": name}

# 204 No Content - Successful DELETE (no body returned)
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
    pass  # Just delete, don't return anything

# 400 Bad Request - Invalid input
if not name:
    raise HTTPException(status_code=400, detail="Name is required")

# 404 Not Found - Resource doesn't exist
if item_id not in database:
    raise HTTPException(status_code=404, detail="Item not found")

# 409 Conflict - Resource already exists
if name in database:
    raise HTTPException(status_code=409, detail="Item already exists")
Enter fullscreen mode Exit fullscreen mode

Validation Errors

FastAPI automatically handles validation errors with 422 Unprocessable Entity:

from pydantic import BaseModel, validator

class Item(BaseModel):
    name: str
    price: float

    @validator('price')
    def price_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError('Price must be positive')
        return v

@app.post("/items")
def create_item(item: Item):
    return item
Enter fullscreen mode Exit fullscreen mode

Try sending a negative price:

{
  "name": "Laptop",
  "price": -100
}
Enter fullscreen mode Exit fullscreen mode

FastAPI will return a detailed error about the validation failure.

Creating Custom Exception Handlers

For more control, create custom exception handlers:

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

app = FastAPI()

class ItemNotFoundError(Exception):
    def __init__(self, item_id: int):
        self.item_id = item_id

@app.exception_handler(ItemNotFoundError)
def item_not_found_handler(request: Request, exc: ItemNotFoundError):
    return JSONResponse(
        status_code=404,
        content={
            "error": "Item not found",
            "item_id": exc.item_id,
            "path": str(request.url)
        }
    )

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id > 100:
        raise ItemNotFoundError(item_id=item_id)
    return {"id": item_id, "name": "Sample Item"}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Always use appropriate status codes

    • Don't return 200 for errors
    • Use 201 for creation
    • Use 204 for successful deletion
  2. Provide helpful error messages

    • Tell users what went wrong
    • Suggest how to fix it
    • Include relevant IDs or data
  3. Be consistent

    • Use the same error format across your API
    • Document your error responses
  4. Don't expose sensitive information

    • Don't reveal database structure
    • Don't show stack traces in production

What's Next?

In Part 4, we'll cover:

  • Advanced Pydantic validation
  • Response models and data filtering
  • File uploads
  • Background tasks

How do you handle errors in your APIs? Share your strategies in the comments!

Top comments (0)