Free Ebook cover Flask Essentials: Practical Backend Patterns for Small Services

Flask Essentials: Practical Backend Patterns for Small Services

New course

14 pages

Flask Essentials: Error Handling Patterns and Consistent API Errors

CapĂ­tulo 5

Estimated reading time: 8 minutes

+ Exercise

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 code values for client logic and analytics.
  • Human-readable messages: safe, non-sensitive message text.
  • Validation structure: predictable details for 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 App

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 and abort())
  • 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:

  • 422 with VALIDATION_ERROR and details.fields for input issues
  • 409 with CONFLICT for uniqueness conflicts
  • 400 with INVALID_JSON for malformed bodies

Mapping table: Exceptions to HTTP responses

SituationHTTPerror.codeerror.details
Malformed JSON400INVALID_JSONOptional parsing hint (no raw parser output)
Missing/invalid fields422VALIDATION_ERROR{"fields": {"field": ["msg"]}}
Unauthenticated401UNAUTHORIZEDOptional auth scheme info
Forbidden403FORBIDDENOptional required role/scope
Resource not found (entity)404NOT_FOUNDOptional resource type/id
Route not found (no matching endpoint)404NOT_FOUNDUsually none
Wrong method405METHOD_NOT_ALLOWEDOptional allowed methods
Conflict (unique constraint, state conflict)409CONFLICTOptional conflicting field
Unexpected server error500INTERNAL_ERRORNone (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_ERROR message generic; log details with logger.exception.
  • Tip: never include raw database errors in details; map them to CONFLICT or VALIDATION_ERROR with safe hints.

Pitfall: inconsistent 404/405 handling across blueprints

  • Cause: blueprint-level error handlers that override app-level behavior, or missing HTTPException handler.
  • 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 Exception inside a view that converts everything to 400 or hides real bugs.
  • Fix: raise specific APIError types 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(), manual jsonify, and library exceptions without normalization.
  • Fix: normalize all errors through APIError and the centralized handlers; treat HTTPException as 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:

  • APIError and subclasses
  • register_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 ValidationError with details.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 the HTTPException handler 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.

Now answer the exercise about the content:

In a small Flask API, which approach best ensures errors are predictable for clients and consistent across all blueprints?

You are right! Congratulations, now go to the next page

You missed! Try again.

A centralized handler with custom API exceptions enforces one JSON error shape everywhere, keeps stable machine-readable codes, and includes a request_id for correlation, while avoiding leaked internal details.

Next chapter

Flask Essentials: Logging, Observability, and Debuggable Services

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.