Why a consistent error strategy matters
In small Flask services, errors often start as ad-hoc abort(400) calls, mixed return shapes, and occasional stack traces leaking to clients. A consistent strategy makes errors predictable for API consumers, easier to test, and safer in production. The goal is to ensure every failure returns the same payload shape, with stable machine-readable codes, appropriate HTTP status codes, and optional field-level validation details.
Core goals
- Consistency: same JSON envelope for all errors, across all blueprints.
- Machine-readable codes: stable
codevalues for client logic and analytics. - Human-readable messages: safe, non-sensitive
messagetext. - Validation structure: predictable
detailsfor field errors. - Safety: no stack traces or internal exception messages in responses.
- Observability: log unexpected exceptions with context and a request correlation id.
Error payload template and conventions
Pick one error payload shape and enforce it everywhere. A practical template:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"details": {
"fields": {
"email": ["Invalid email address"],
"age": ["Must be >= 18"]
}
}
},
"request_id": "a1b2c3d4e5"
}Recommended conventions
error.code: UPPER_SNAKE_CASE, stable over time (e.g.,NOT_FOUND,METHOD_NOT_ALLOWED,VALIDATION_ERROR,UNAUTHORIZED,FORBIDDEN,CONFLICT,INTERNAL_ERROR).error.message: short, safe, user-facing text; do not include stack traces, SQL fragments, or secrets.error.details: optional object; when present, keep it structured (avoid dumping raw exception strings).request_id: always present; helps correlate client reports with server logs.- Return JSON for errors consistently; avoid HTML error pages for API routes.
Step-by-step: Custom exceptions mapped to HTTP responses
1) Define a base API exception
Create a small exception hierarchy that carries status_code, code, and optional details. This avoids repeating mapping logic in every route.
from dataclasses import dataclass
from typing import Any, Optional, Dict
@dataclass
class APIError(Exception):
code: str
message: str
status_code: int = 400
details: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
payload = {
"error": {
"code": self.code,
"message": self.message,
}
}
if self.details is not None:
payload["error"]["details"] = self.details
return payload
class ValidationError(APIError):
def __init__(self, field_errors: Dict[str, list[str]], message: str = "Request validation failed."):
super().__init__(
code="VALIDATION_ERROR",
message=message,
status_code=422,
details={"fields": field_errors},
)
class NotFoundError(APIError):
def __init__(self, message: str = "Resource not found."):
super().__init__(code="NOT_FOUND", message=message, status_code=404)
class ConflictError(APIError):
def __init__(self, message: str = "Conflict.", details=None):
super().__init__(code="CONFLICT", message=message, status_code=409, details=details)
Notes: use 422 for semantic validation failures; use 400 for malformed JSON, missing required headers, or invalid query parameter format.
2) Add a request id and include it in every error
Generate a request id early in the request and attach it to logs and responses. If the client sends X-Request-Id, you can accept it; otherwise generate one.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
import uuid
from flask import g, request
def init_request_id():
rid = request.headers.get("X-Request-Id")
g.request_id = rid or uuid.uuid4().hex
Call this from a before_request hook (where you already set up request-scoped data). Ensure your normal (non-error) responses also include it if you want full symmetry, but at minimum include it in errors.
3) Centralize error handlers
Register handlers once so all blueprints share the same behavior. You want handlers for:
- Your custom
APIError - Flask/Werkzeug
HTTPException(covers 404/405 andabort()) - Unexpected
Exception(generic 500)
import logging
from flask import jsonify, g
from werkzeug.exceptions import HTTPException, NotFound, MethodNotAllowed
logger = logging.getLogger(__name__)
def error_response(payload: dict, status_code: int):
# Always attach request_id
payload["request_id"] = getattr(g, "request_id", None)
return jsonify(payload), status_code
def register_error_handlers(app):
@app.errorhandler(APIError)
def handle_api_error(err: APIError):
return error_response(err.to_dict(), err.status_code)
@app.errorhandler(HTTPException)
def handle_http_exception(err: HTTPException):
# Map common HTTP errors to stable codes
if isinstance(err, NotFound):
api_err = APIError(code="NOT_FOUND", message="Route not found.", status_code=404)
elif isinstance(err, MethodNotAllowed):
api_err = APIError(code="METHOD_NOT_ALLOWED", message="Method not allowed.", status_code=405)
else:
# For other HTTPException types, keep it generic and safe
api_err = APIError(
code="HTTP_ERROR",
message="Request failed.",
status_code=err.code or 400,
details={"name": err.name},
)
return error_response(api_err.to_dict(), api_err.status_code)
@app.errorhandler(Exception)
def handle_unexpected_exception(err: Exception):
# Log full details server-side, return safe message client-side
logger.exception("Unhandled exception", extra={"request_id": getattr(g, "request_id", None)})
api_err = APIError(code="INTERNAL_ERROR", message="Internal server error.", status_code=500)
return error_response(api_err.to_dict(), 500)
This pattern ensures that even if a route raises a plain ValueError, the client still receives the same error envelope.
Step-by-step: Returning predictable validation errors
1) Normalize validation output into a field map
Regardless of whether you validate manually or via a library, normalize errors into {field: [messages...]}. This keeps clients simple: they can iterate fields and show messages consistently.
def require_fields(data: dict, required: list[str]) -> None:
errors: dict[str, list[str]] = {}
for field in required:
if field not in data or data[field] in (None, ""):
errors.setdefault(field, []).append("This field is required.")
if errors:
raise ValidationError(errors)
2) Validate types and constraints without leaking internals
Do not return Python exception messages like invalid literal for int(). Convert them into user-safe messages.
def parse_int_field(data: dict, field: str, *, min_value: int | None = None) -> int:
errors: dict[str, list[str]] = {}
try:
value = int(data.get(field))
except (TypeError, ValueError):
errors.setdefault(field, []).append("Must be an integer.")
raise ValidationError(errors)
if min_value is not None and value < min_value:
errors.setdefault(field, []).append(f"Must be >= {min_value}.")
raise ValidationError(errors)
return value
3) Example route using the exceptions
from flask import Blueprint, request
bp = Blueprint("users", __name__)
@bp.post("/users")
def create_user():
data = request.get_json(silent=True)
if data is None:
raise APIError(code="INVALID_JSON", message="Body must be valid JSON.", status_code=400)
require_fields(data, ["email", "age"])
age = parse_int_field(data, "age", min_value=18)
# Example: conflict
if data["email"].endswith("@example.com"):
raise ConflictError("Email already exists.", details={"field": "email"})
# ... create user ...
return {"id": "u_123", "email": data["email"], "age": age}, 201
Clients can now rely on:
422withVALIDATION_ERRORanddetails.fieldsfor input issues409withCONFLICTfor uniqueness conflicts400withINVALID_JSONfor malformed bodies
Mapping table: Exceptions to HTTP responses
| Situation | HTTP | error.code | error.details |
|---|---|---|---|
| Malformed JSON | 400 | INVALID_JSON | Optional parsing hint (no raw parser output) |
| Missing/invalid fields | 422 | VALIDATION_ERROR | {"fields": {"field": ["msg"]}} |
| Unauthenticated | 401 | UNAUTHORIZED | Optional auth scheme info |
| Forbidden | 403 | FORBIDDEN | Optional required role/scope |
| Resource not found (entity) | 404 | NOT_FOUND | Optional resource type/id |
| Route not found (no matching endpoint) | 404 | NOT_FOUND | Usually none |
| Wrong method | 405 | METHOD_NOT_ALLOWED | Optional allowed methods |
| Conflict (unique constraint, state conflict) | 409 | CONFLICT | Optional conflicting field |
| Unexpected server error | 500 | INTERNAL_ERROR | None (log server-side) |
Common pitfalls and how to avoid them
Pitfall: leaking stack traces or internal messages
- Cause: running with debug enabled, returning
str(err), or letting Werkzeug render HTML error pages. - Fix: always return your JSON envelope in handlers; keep
INTERNAL_ERRORmessage generic; log details withlogger.exception. - Tip: never include raw database errors in
details; map them toCONFLICTorVALIDATION_ERRORwith safe hints.
Pitfall: inconsistent 404/405 handling across blueprints
- Cause: blueprint-level error handlers that override app-level behavior, or missing
HTTPExceptionhandler. - Fix: register handlers at the app level so they apply to all blueprints; if you must add blueprint handlers, ensure they call the same shared formatter.
Pitfall: catching exceptions too broadly in routes
- Cause:
try/except Exceptioninside a view that converts everything to400or hides real bugs. - Fix: raise specific
APIErrortypes for expected failures; let unexpected exceptions bubble to the generic handler (500) so they are logged and visible in monitoring.
Pitfall: returning different shapes for different error types
- Cause: mixing
abort(), manualjsonify, and library exceptions without normalization. - Fix: normalize all errors through
APIErrorand the centralized handlers; treatHTTPExceptionas an input to your mapping layer.
Keeping errors consistent across blueprints
Use a shared error module
Put the following in one shared place and import it everywhere:
APIErrorand subclassesregister_error_handlers(app)error_response(...)formatter- Optional: a small catalog of codes/messages to avoid typos
Conventions checklist for every blueprint
- Routes raise
APIError(or subclasses) for expected failures. - No route returns ad-hoc error dicts; only successful payloads are returned directly.
- Validation always raises
ValidationErrorwithdetails.fields. - Do not register competing error handlers per blueprint unless necessary; if you do, reuse the shared formatter and codes.
- When using
abort(), rely on theHTTPExceptionhandler to convert it into your envelope.
Optional: stable error code catalog
To prevent drift, define constants:
class ErrorCodes:
INVALID_JSON = "INVALID_JSON"
VALIDATION_ERROR = "VALIDATION_ERROR"
NOT_FOUND = "NOT_FOUND"
METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"
CONFLICT = "CONFLICT"
INTERNAL_ERROR = "INTERNAL_ERROR"Then reference ErrorCodes.VALIDATION_ERROR rather than repeating strings across modules.