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)¶
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