Skip to content

Error Contracts

Consistent error responses across your API.

Standard Error Format

Use a consistent structure for all error responses:

from pydantic import BaseModel
from typing import Optional, List, Dict, Any

class ErrorDetail(BaseModel):
    field: Optional[str] = None
    message: str
    code: Optional[str] = None

class ErrorResponse(BaseModel):
    error: str  # Machine-readable error code
    message: str  # Human-readable message
    details: Optional[List[ErrorDetail]] = None
    request_id: Optional[str] = None
    documentation_url: Optional[str] = None

Error Response Examples

Validation Error (422)

{
  "error": "validation_error",
  "message": "Request validation failed",
  "details": [
    {
      "field": "email",
      "message": "Invalid email format",
      "code": "invalid_format"
    },
    {
      "field": "age",
      "message": "Must be at least 18",
      "code": "min_value"
    }
  ],
  "request_id": "req_abc123",
  "documentation_url": "https://docs.example.com/errors#validation_error"
}

Authentication Error (401)

{
  "error": "unauthorized",
  "message": "Invalid or expired access token",
  "request_id": "req_abc123",
  "documentation_url": "https://docs.example.com/errors#unauthorized"
}

Authorization Error (403)

{
  "error": "forbidden",
  "message": "You don't have permission to access this resource",
  "request_id": "req_abc123"
}

Not Found (404)

{
  "error": "not_found",
  "message": "User with ID 123 not found",
  "request_id": "req_abc123"
}

Conflict (409)

{
  "error": "conflict",
  "message": "Email already registered",
  "details": [
    {
      "field": "email",
      "message": "This email is already associated with an account",
      "code": "duplicate"
    }
  ],
  "request_id": "req_abc123"
}

Rate Limited (429)

{
  "error": "rate_limited",
  "message": "Too many requests. Try again in 60 seconds.",
  "details": [
    {
      "message": "Rate limit: 100 requests per minute",
      "code": "limit_exceeded"
    }
  ],
  "request_id": "req_abc123"
}

Server Error (500)

{
  "error": "internal_error",
  "message": "An unexpected error occurred. Please try again later.",
  "request_id": "req_abc123"
}

FastAPI Implementation

Custom Exception Classes

from fastapi import HTTPException
from typing import Optional, List, Dict, Any

class APIError(HTTPException):
    """Base API error with consistent format."""

    def __init__(
        self,
        status_code: int,
        error: str,
        message: str,
        details: Optional[List[Dict[str, Any]]] = None,
        headers: Optional[Dict[str, str]] = None,
    ):
        self.error = error
        self.message = message
        self.details = details

        super().__init__(
            status_code=status_code,
            detail={
                "error": error,
                "message": message,
                "details": details,
            },
            headers=headers,
        )

class NotFoundError(APIError):
    def __init__(self, resource: str, identifier: Any):
        super().__init__(
            status_code=404,
            error="not_found",
            message=f"{resource} with ID {identifier} not found",
        )

class ValidationError(APIError):
    def __init__(self, details: List[Dict[str, Any]]):
        super().__init__(
            status_code=422,
            error="validation_error",
            message="Request validation failed",
            details=details,
        )

class ConflictError(APIError):
    def __init__(self, message: str, details: Optional[List[Dict[str, Any]]] = None):
        super().__init__(
            status_code=409,
            error="conflict",
            message=message,
            details=details,
        )

class UnauthorizedError(APIError):
    def __init__(self, message: str = "Invalid or expired access token"):
        super().__init__(
            status_code=401,
            error="unauthorized",
            message=message,
            headers={"WWW-Authenticate": "Bearer"},
        )

class ForbiddenError(APIError):
    def __init__(self, message: str = "You don't have permission to access this resource"):
        super().__init__(
            status_code=403,
            error="forbidden",
            message=message,
        )

class RateLimitError(APIError):
    def __init__(self, retry_after: int):
        super().__init__(
            status_code=429,
            error="rate_limited",
            message=f"Too many requests. Try again in {retry_after} seconds.",
            headers={"Retry-After": str(retry_after)},
        )

Global Exception Handler

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
import structlog
import uuid

app = FastAPI()
log = structlog.get_logger()

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
    request.state.request_id = request_id

    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id

    return response

@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": exc.error,
            "message": exc.message,
            "details": exc.details,
            "request_id": getattr(request.state, "request_id", None),
        },
        headers=exc.headers,
    )

@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
    details = []
    for error in exc.errors():
        details.append({
            "field": ".".join(str(x) for x in error["loc"][1:]),  # Skip 'body'
            "message": error["msg"],
            "code": error["type"],
        })

    return JSONResponse(
        status_code=422,
        content={
            "error": "validation_error",
            "message": "Request validation failed",
            "details": details,
            "request_id": getattr(request.state, "request_id", None),
        },
    )

@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
    request_id = getattr(request.state, "request_id", None)

    # Log the actual error
    log.error(
        "unhandled_exception",
        request_id=request_id,
        error=str(exc),
        exc_info=True,
    )

    # Return generic error to client
    return JSONResponse(
        status_code=500,
        content={
            "error": "internal_error",
            "message": "An unexpected error occurred. Please try again later.",
            "request_id": request_id,
        },
    )

Using Errors in Endpoints

@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    user = await db.get(User, user_id)
    if not user:
        raise NotFoundError("User", user_id)
    return user

@app.post("/users")
async def create_user(
    user_data: UserCreate,
    db: AsyncSession = Depends(get_db),
):
    # Check for duplicate email
    existing = await db.execute(
        select(User).where(User.email == user_data.email)
    )
    if existing.scalar_one_or_none():
        raise ConflictError(
            message="Email already registered",
            details=[{
                "field": "email",
                "message": "This email is already associated with an account",
                "code": "duplicate",
            }],
        )

    user = User(**user_data.dict())
    db.add(user)
    await db.commit()
    return user

RFC 7807: Problem Details

For maximum interoperability, consider RFC 7807 format:

from pydantic import BaseModel
from typing import Optional

class ProblemDetails(BaseModel):
    """RFC 7807 Problem Details."""
    type: str  # URI reference identifying the problem type
    title: str  # Short summary
    status: int  # HTTP status code
    detail: Optional[str] = None  # Human-readable explanation
    instance: Optional[str] = None  # URI reference to specific occurrence

@app.exception_handler(APIError)
async def rfc7807_error_handler(request: Request, exc: APIError):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "type": f"https://docs.example.com/errors/{exc.error}",
            "title": exc.error.replace("_", " ").title(),
            "status": exc.status_code,
            "detail": exc.message,
            "instance": str(request.url),
        },
        headers={"Content-Type": "application/problem+json"},
    )

Error Codes Catalog

Document all possible error codes:

ERROR_CATALOG = {
    # Authentication errors
    "unauthorized": {
        "status": 401,
        "description": "Invalid or missing authentication",
        "resolution": "Provide a valid access token",
    },
    "token_expired": {
        "status": 401,
        "description": "Access token has expired",
        "resolution": "Refresh your access token",
    },

    # Authorization errors
    "forbidden": {
        "status": 403,
        "description": "Insufficient permissions",
        "resolution": "Request access from an administrator",
    },

    # Resource errors
    "not_found": {
        "status": 404,
        "description": "Resource does not exist",
        "resolution": "Verify the resource ID",
    },

    # Validation errors
    "validation_error": {
        "status": 422,
        "description": "Request body failed validation",
        "resolution": "Check the 'details' field for specific issues",
    },

    # Conflict errors
    "conflict": {
        "status": 409,
        "description": "Resource conflict",
        "resolution": "Resolve the conflict or use a different value",
    },
    "duplicate": {
        "status": 409,
        "description": "Duplicate resource",
        "resolution": "Use a unique value",
    },

    # Rate limiting
    "rate_limited": {
        "status": 429,
        "description": "Too many requests",
        "resolution": "Wait and retry after the Retry-After period",
    },

    # Server errors
    "internal_error": {
        "status": 500,
        "description": "Unexpected server error",
        "resolution": "Contact support with the request_id",
    },
}

# Expose as documentation endpoint
@app.get("/errors", include_in_schema=False)
async def list_error_codes():
    """List all possible error codes."""
    return ERROR_CATALOG

TypeScript Client Handling

interface APIError {
  error: string;
  message: string;
  details?: Array<{
    field?: string;
    message: string;
    code?: string;
  }>;
  request_id?: string;
}

async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);

  if (!response.ok) {
    const error: APIError = await response.json();

    switch (error.error) {
      case "validation_error":
        throw new ValidationError(error.details ?? []);
      case "unauthorized":
        throw new UnauthorizedError(error.message);
      case "not_found":
        throw new NotFoundError(error.message);
      case "rate_limited":
        const retryAfter = response.headers.get("Retry-After");
        throw new RateLimitError(parseInt(retryAfter ?? "60"));
      default:
        throw new APIClientError(error);
    }
  }

  return response.json();
}

// Usage
try {
  const user = await apiRequest<User>("/users/123");
} catch (error) {
  if (error instanceof ValidationError) {
    // Show field-specific errors
    error.details.forEach(({ field, message }) => {
      setFieldError(field, message);
    });
  } else if (error instanceof NotFoundError) {
    router.push("/404");
  } else if (error instanceof RateLimitError) {
    showToast(`Rate limited. Try again in ${error.retryAfter}s`);
  } else {
    showToast("An error occurred. Please try again.");
  }
}

Best Practices Summary

Practice Implementation
Consistent structure Same format for all errors
Machine-readable codes error field for programmatic handling
Human-readable messages message field for display
Field-level details details array for validation errors
Request tracking request_id for debugging
Documentation links documentation_url for help
Standard status codes Match HTTP semantics
Don't leak internals Generic message for 500 errors

See Also

  • Error Handling -- Backend exception hierarchy and structured logging for errors
  • Input Validation -- Validation patterns that produce the error details in this contract