from typing import Any, Optional
from fastapi.responses import JSONResponse
from pydantic import BaseModel
class ErrorDetail(BaseModel):
code: str
message: str
details: Optional[Any] = None
class ErrorBody(BaseModel):
error: ErrorDetail
class AppError(Exception):
status_code: int = 500
code: str = "internal_error"
def __init__(self, message: str, details: Optional[Any] = None):
super().__init__(message)
self.message = message
self.details = details
def to_response(self) -> JSONResponse:
body = ErrorBody(
error=ErrorDetail(code=self.code, message=self.message, details=self.details)
)
return JSONResponse(status_code=self.status_code, content=body.model_dump())
class NotFoundError(AppError):
status_code = 404
code = "not_found"
class ConflictError(AppError):
status_code = 409
code = "conflict"
class UnauthorizedError(AppError):
status_code = 401
code = "unauthorized"
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from errors import AppError, ErrorBody, ErrorDetail
logger = logging.getLogger("app.errors")
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError):
logger.warning("app_error code=%s path=%s", exc.code, request.url.path)
return exc.to_response()
@app.exception_handler(StarletteHTTPException)
async def handle_http_error(request: Request, exc: StarletteHTTPException):
body = ErrorBody(
error=ErrorDetail(code="http_error", message=str(exc.detail))
)
return _json(exc.status_code, body)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError):
body = ErrorBody(
error=ErrorDetail(
code="validation_error",
message="Request validation failed",
details=exc.errors(),
)
)
return _json(422, body)
@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception):
logger.error("unhandled path=%s", request.url.path, exc_info=exc)
body = ErrorBody(
error=ErrorDetail(code="internal_error", message="Something went wrong")
)
return _json(500, body)
def _json(status_code: int, body: ErrorBody):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=status_code, content=body.model_dump())
from fastapi import FastAPI
from pydantic import BaseModel
from errors import ConflictError, ErrorBody, NotFoundError
from handlers import register_error_handlers
app = FastAPI(title="Users API")
register_error_handlers(app)
_USERS = {1: {"id": 1, "name": "Ada"}}
class User(BaseModel):
id: int
name: str
@app.get(
"/users/{user_id}",
response_model=User,
responses={404: {"model": ErrorBody}},
)
async def get_user(user_id: int):
user = _USERS.get(user_id)
if user is None:
raise NotFoundError(
f"User {user_id} does not exist", details={"user_id": user_id}
)
return user
@app.post("/users", response_model=User, status_code=201)
async def create_user(user: User):
if user.id in _USERS:
raise ConflictError("User already exists", details={"user_id": user.id})
_USERS[user.id] = user.model_dump()
return user
This snippet shows how a FastAPI service can guarantee that every error leaves the application in the same JSON shape, so clients never have to parse ad-hoc payloads. The core idea is to funnel all failures through a single custom exception type and one registered handler, rather than scattering HTTPException and JSONResponse calls across route functions.
In errors.py, AppError is a base exception carrying a machine-readable code, a human message, an HTTP status_code, and optional details. Concrete subclasses like NotFoundError and ConflictError simply pin the status and code, which keeps the vocabulary of failures small and centralized. ErrorBody is a Pydantic model describing the wire format, so the contract is documented and appears in the OpenAPI schema instead of living only in the developer's head. The to_response helper renders any AppError into that envelope under a top-level error key.
In handlers.py, register_error_handlers wires three handlers onto the app. The AppError handler is the happy path: it logs at warning level and returns the structured body. A second handler catches Starlette's built-in HTTPException and Pydantic's RequestValidationError, normalizing them into the same envelope so framework-raised errors don't leak a different shape. The final catch-all handler for Exception logs the full traceback with exc_info and returns a generic 500 that never exposes internal details to callers — a deliberate trade-off favoring safety over debuggability on the client side.
In main.py, the app is assembled and handlers are registered before routes. The get_user route demonstrates the intended style: business code just raise NotFoundError(...) or ConflictError(...) and trusts the handler to format everything. This inverts the usual pattern where each endpoint builds its own responses.
The main pitfall is ordering and coverage: the Exception handler must be registered so it truly catches everything unhandled, and validation errors must be intercepted or FastAPI's default 422 shape will slip through. The payoff is a stable, versionable error contract, cleaner route code, and one place to add fields like request IDs later.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
package deps
import (
"crypto/tls"
"crypto/x509"
"net/http"
mTLS client configuration with custom root CA pool
Share this code
Here's the card — post it anywhere.