Custom Exceptions and FastAPI Error Handling

FastAPI’s error handling system turns Python exceptions into HTTP responses. When you raise HTTPException(status_code=404), FastAPI catches it and returns a {"detail": "..."} JSON response with a 404 status. For more complex applications, you define a custom exception hierarchy โ€” Python classes that inherit from Exception โ€” and register exception handlers that convert those exceptions into structured JSON responses. This separation of concerns keeps your business logic clean: functions raise descriptive Python exceptions, and exception handlers decide how to present them to API clients.

HTTPException โ€” The Quick Approach

from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

# HTTPException โ€” raise anywhere in a route handler
@app.get("/posts/{post_id}")
async def get_post(post_id: int):
    post = db.get(post_id)
    if post is None:
        raise HTTPException(
            status_code=404,
            detail=f"Post with id={post_id} not found"
        )
    if not post.published:
        raise HTTPException(
            status_code=403,
            detail="This post is not published"
        )
    return post

# Custom headers in HTTPException (e.g. for auth)
@app.get("/protected")
async def protected():
    raise HTTPException(
        status_code=401,
        detail="Not authenticated",
        headers={"WWW-Authenticate": "Bearer"},
    )
Note: FastAPI’s HTTPException is different from Python’s built-in exceptions โ€” it carries an HTTP status_code and a detail field that becomes the JSON response body. When FastAPI catches an HTTPException, it returns {"detail": "your message"} with the given status code. For validation errors (wrong field types, missing required fields), FastAPI automatically returns a 422 Unprocessable Entity with a detailed breakdown of which fields failed โ€” you never need to raise this yourself.
Tip: Keep your detail messages informative but not overly technical. A message like "Post not found" is appropriate for a 404. A message like "ValueError: attribute 'author' of SQLAlchemy model 'Post' is not loaded" reveals internal implementation details and should never reach the client. In your exception handlers, log the full technical details server-side and return a clean, user-facing message in the response.
Warning: Never expose internal exception messages directly to API clients. A stack trace or SQLAlchemy error message in a 500 response leaks information about your database schema and application structure that can help attackers. FastAPI in production mode hides traceback details, but your custom exception handlers must also sanitise messages. Return generic messages for 5xx errors: "An unexpected error occurred. Please try again later."

Custom Exception Hierarchy

# app/exceptions.py โ€” custom exception classes

class AppError(Exception):
    """Base class for all application-specific errors."""
    def __init__(self, message: str, status_code: int = 500, code: str = "ERROR"):
        super().__init__(message)
        self.message     = message
        self.status_code = status_code
        self.code        = code   # machine-readable error code

class NotFoundError(AppError):
    def __init__(self, resource: str, identifier = None):
        detail = f"{resource} not found"
        if identifier is not None:
            detail = f"{resource} with id={identifier} not found"
        super().__init__(detail, status_code=404, code="NOT_FOUND")
        self.resource   = resource
        self.identifier = identifier

class UnauthorizedError(AppError):
    def __init__(self, message: str = "Authentication required"):
        super().__init__(message, status_code=401, code="UNAUTHORIZED")

class ForbiddenError(AppError):
    def __init__(self, message: str = "You do not have permission"):
        super().__init__(message, status_code=403, code="FORBIDDEN")

class ConflictError(AppError):
    def __init__(self, resource: str, field: str, value: str):
        super().__init__(
            f"{resource} with {field}='{value}' already exists",
            status_code=409,
            code="CONFLICT"
        )

class ValidationError(AppError):
    def __init__(self, field: str, issue: str):
        super().__init__(
            f"Validation failed on '{field}': {issue}",
            status_code=422,
            code="VALIDATION_ERROR"
        )
        self.field = field

Registering Exception Handlers

# app/main.py โ€” register exception handlers

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from app.exceptions import AppError
import logging

logger = logging.getLogger(__name__)
app = FastAPI()

# โ”€โ”€ Handle all AppError subclasses โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    # Log based on severity
    if exc.status_code >= 500:
        logger.error(f"Server error: {exc.message}", exc_info=True)
    else:
        logger.info(f"Client error {exc.status_code}: {exc.message}")

    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error":   exc.code,
            "message": exc.message,
        }
    )

# โ”€โ”€ Handle Pydantic validation errors (422) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
    request: Request,
    exc: RequestValidationError
) -> JSONResponse:
    # Extract field-level errors
    errors = []
    for error in exc.errors():
        errors.append({
            "field":   " โ†’ ".join(str(x) for x in error["loc"][1:]),
            "message": error["msg"],
            "type":    error["type"],
        })

    return JSONResponse(
        status_code=422,
        content={
            "success": False,
            "error":   "VALIDATION_ERROR",
            "message": "Request validation failed",
            "errors":  errors,
        }
    )

# โ”€โ”€ Handle unexpected 500 errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
    logger.exception(f"Unhandled exception on {request.method} {request.url}")
    return JSONResponse(
        status_code=500,
        content={
            "success": False,
            "error":   "INTERNAL_ERROR",
            "message": "An unexpected error occurred. Please try again later.",
        }
    )

Using Custom Exceptions in Route Handlers

from app.exceptions import NotFoundError, ForbiddenError, ConflictError

@app.get("/posts/{post_id}")
async def get_post(post_id: int, db: Session = Depends(get_db)):
    post = db.get(Post, post_id)
    if post is None:
        raise NotFoundError("Post", post_id)   # clean, semantic
    return post

@app.post("/posts")
async def create_post(data: PostCreate, current_user = Depends(get_current_user)):
    existing = db.query(Post).filter(Post.slug == data.slug).first()
    if existing:
        raise ConflictError("Post", "slug", data.slug)
    # ...

@app.delete("/posts/{post_id}")
async def delete_post(post_id: int, current_user = Depends(get_current_user)):
    post = db.get(Post, post_id)
    if post is None:
        raise NotFoundError("Post", post_id)
    if post.author_id != current_user.id and current_user.role != "admin":
        raise ForbiddenError("You can only delete your own posts")

Common Mistakes

Mistake 1 โ€” Leaking internal details in error messages

โŒ Wrong โ€” SQLAlchemy internals in the response:

except Exception as e:
    raise HTTPException(500, detail=str(e))
# Returns: "UNIQUE constraint failed: posts.slug" โ€” reveals schema!

โœ… Correct โ€” sanitise the message:

except Exception as e:
    logger.exception("Unexpected DB error")
    raise HTTPException(500, detail="Database operation failed")   # โœ“

Mistake 2 โ€” Raising HTTPException deep inside service layer

โŒ Wrong โ€” service layer has HTTP concerns:

def post_service_delete(post_id: int):
    raise HTTPException(404)   # HTTP exception in business logic!

โœ… Correct โ€” service raises domain exceptions, router handles HTTP:

def post_service_delete(post_id: int):
    raise NotFoundError("Post", post_id)   # โœ“ domain exception

# In the router:
@app.delete("/{post_id}")
async def delete(post_id: int):
    post_service_delete(post_id)   # AppError caught by exception_handler โœ“

Mistake 3 โ€” Not handling RequestValidationError separately

โŒ Wrong โ€” Pydantic 422 errors hidden by generic 500 handler:

@app.exception_handler(Exception)   # catches RequestValidationError too
async def handler(request, exc):
    return JSONResponse(500, content={"error": "server error"})
# Client sends invalid field โ†’ gets 500 instead of clear 422 validation error!

โœ… Correct โ€” register a specific handler for RequestValidationError (shown above).

Quick Reference

Scenario Raise HTTP Status
Resource not found NotFoundError("Post", id) 404
Not logged in UnauthorizedError() 401
No permission ForbiddenError() 403
Duplicate resource ConflictError("Post", "slug", s) 409
Invalid input Pydantic handles automatically 422
Quick HTTP error HTTPException(status_code=400) any
Unexpected server error Log + generic message 500

🧠 Test Yourself

You raise NotFoundError("User", 42) in a service function. What does the API client receive, and why?