Architecture¶
This document describes how Errium is put together internally. It's aimed at contributors and at anyone building a new framework adapter.
Design goal: core vs. adapters¶
Errium is split into a framework-agnostic core and thin, framework-specific adapters:
src/
├── errium_core/ # framework-agnostic: classification, contracts, formatting, settings
├── errium/ # FastAPI / Starlette adapter
├── errium_flask/ # Flask adapter
├── errium_ninja/ # Django Ninja adapter
└── errium_drf/ # Django REST Framework adapter
errium_core never imports FastAPI, Starlette, or Flask. Where it needs to recognize a
framework's exception type without depending on that framework (e.g. FastAPI's
RequestValidationError), it does so by duck-typing — checking exc.__class__.__name__ or
walking type(exc).__mro__ for a known module path — rather than importing the framework. See
errium_core/classifiers/validation.py and errium_core/classifiers/database.py for examples.
Each adapter package (errium, errium_flask, errium_ninja, errium_drf) contributes:
- One or more
ExceptionClassifierimplementations for framework-specific exception types (FastAPIHTTPExceptionClassifier,WerkzeugHTTPExceptionClassifier, ...). - An integration point that hooks into the framework's error-handling mechanism (a middleware for
FastAPI/Starlette, an extension class for Flask) and wires everything together: build a
ClassificationEngine, classify the exception, wrap it in aStandardizedError, format it withDefaultFormatter, and return the framework's native JSON response type.
Adding support for a new framework means adding a new src/errium_<framework> package that
follows this same pattern and reuses errium_core untouched.
Core building blocks (errium_core)¶
Contracts (errium_core/contracts/)¶
ErrorCategory(categories.py) — the closed set of category strings every response'scodefield can take:VALIDATION_ERROR,AUTHENTICATION_ERROR,AUTHORIZATION_ERROR,RESOURCE_NOT_FOUND,DUPLICATE_RESOURCE,DATABASE_ERROR,INTERNAL_SERVER_ERROR.ClassifiedError(classified_error.py) — the minimal output of classification:category,status_code,message.StandardizedError(error.py) — the full internal representation of an error before formatting: addstrace_id,success,timestamp, optionaldetails, and the originalexception(kept around so the formatter can build debug info from it).
Classification (errium_core/classifiers/)¶
ExceptionClassifier(base.py) — aProtocolevery classifier implements: apriority: intproperty andclassify(exc) -> ClassifiedError | None. ReturningNonemeans "not my exception type, try the next classifier."ClassificationEngine(engine.py) — holds classifiers sorted by priority (highest first, ties default to 100).classify(exc)runs each classifier in order and returns the first non-Noneresult, falling back toGenericExceptionClassifier(alwaysINTERNAL_SERVER_ERROR, 500) if nothing matches. On construction it always registersValidationExceptionClassifier(priority 150) andDatabaseExceptionClassifier(priority 140) — the two classifiers that are genuinely framework-agnostic. Adapters register their own higher-priority, framework-specific classifiers (priority 200+) on top of that.ValidationExceptionClassifier(validation.py) — matches PydanticValidationErroror anything namedRequestValidationError, always mapping toVALIDATION_ERROR/ 422.DatabaseExceptionClassifier(database.py) — matches SQLAlchemy exceptions by walking the exception class's MRO for asqlalchemy.module path (no SQLAlchemy dependency). AnIntegrityErrorwhose message looks like a uniqueness violation (unique constraint,duplicate key,duplicate entry,already exists) maps toDUPLICATE_RESOURCE/ 409; any other SQLAlchemy error maps toDATABASE_ERROR/ 500.GenericExceptionClassifier(generic.py) — the engine's fallback, not part of the priority-sorted list.status_mapping.py—category_for_status_code(status_code), the shared HTTP-status-code →ErrorCategorytable (401→AUTHENTICATION, 403→AUTHORIZATION, 404→NOT_FOUND, 409→DUPLICATE, 422→VALIDATION, else→INTERNAL).FastAPIHTTPExceptionClassifier,WerkzeugHTTPExceptionClassifier,NinjaHttpErrorClassifier, andDRFAPIExceptionClassifierall use this so the mapping is defined once.
Each adapter entry point (the FastAPI middleware, the FastAPI validation handler, the Flask
extension, the Ninja registration function, the DRF exception handler) constructs its own
ClassificationEngine instance rather than sharing one. A custom classifier meant to apply
everywhere needs to be registered on each entry point.
Formatting (errium_core/formatters/)¶
ErrorFormatter(base.py) — aProtocol:format(error: StandardizedError) -> dict[str, Any].DefaultFormatter(default.py) — the only implementation. Produces the response body:success,status_code,code,message,trace_id,timestamp,details. Readsget_settings().debug:- In production (
debug=False), anystatus_code >= 500has itscode/messagesanitized to a genericINTERNAL_SERVER_ERROR/ "An unexpected error occurred." regardless of what the classifier produced, so internals never leak to clients. - In debug mode (
debug=True), adebugblock is added with the exception class name, its message, a full formatted stack trace, and a short list of contextual hints keyed off the status code (e.g. "Check the 'details' field..." for 422).
Normalization (errium_core/normalizers/validation.py)¶
ValidationNormalizer.normalize(errors) turns a raw Pydantic/FastAPI errors() list (dicts with
loc, type, msg) into a flat {field_path: message} dict:
1. Strips a leading body/query/header/path/formData location segment.
2. Looks up a human-readable message template by error type, then by msg (exact match, then
substring match against known markers), falling back to the raw message if nothing matches.
3. Formats {field_name} placeholders (e.g. password → "Password") and normalizes punctuation
(capitalized start, trailing period).
Custom field-message mappings can be injected via ValidationNormalizer(custom_mappings={...}).
The FastAPI validation handler, the Flask extension, and the Django Ninja adapter all use this to
turn a raw pydantic ValidationError/RequestValidationError/Ninja ValidationError into the
beautified details dict. The DRF adapter does not use this — DRF's ValidationError.detail
is a different shape (a recursive tree of already human-readable ErrorDetail strings, not
loc/type/msg dicts needing a message-template lookup) — see errium_drf/normalizers.py
below.
Settings (errium_core/config/settings.py)¶
ErriumSettings(debug: bool) is a process-global singleton, sourced from the ERRIUM_DEBUG
env var at import time ("true"/"1"/"yes", case-insensitive). get_settings() /
set_settings() read and replace it. Tests mutate this directly via set_settings(...); because
it's a module-level singleton, tests that rely on a specific debug/production mode should set it
explicitly rather than assuming a default.
Tracing (errium_core/tracing/trace.py)¶
generate_trace_id() returns a fresh uuid4() string per error, used to correlate the
client-facing response with server-side logs.
Adapter: FastAPI (errium)¶
Two independent entry points exist because Starlette resolves RequestValidationError
differently from other exceptions:
ErriumMiddleware(middleware/error_middleware.py) — a StarletteBaseHTTPMiddlewarethat wrapscall_nextin try/except, catching anything that escapes normal request handling (uncaught exceptions andHTTPExceptions). RegistersFastAPIHTTPExceptionClassifier(priority 200) andFastAPIValidationErrorClassifier(priority 210).validation_exception_handler(handlers/validation_handler.py) — registered separately viaapp.add_exception_handler(RequestValidationError, ...), because FastAPI resolvesRequestValidationErrorthrough its own exception-handler mechanism rather than letting it propagate through middleware. It builds its ownClassificationEngine, classifies the error, and additionally runsValidationNormalizeronexc.errors()to produce beautifieddetails.
errium/classifiers.py holds FastAPIHTTPExceptionClassifier (matches
fastapi.HTTPException / starlette.exceptions.HTTPException, maps status code via
category_for_status_code) and FastAPIValidationErrorClassifier (matches
RequestValidationError specifically, at higher priority than the core's generic validation
classifier).
Both entry points converge on the same contract: build a ClassifiedError, wrap it in a
StandardizedError, format with DefaultFormatter, return as a fastapi.responses.JSONResponse.
Adapter: Flask (errium_flask)¶
WerkzeugHTTPExceptionClassifier(classifiers.py) — matcheswerkzeug.exceptions.HTTPException(whatflask.abort()raises), maps its.codeviacategory_for_status_code, uses.descriptionas the message.ErriumFlask(extension.py) — a Flask extension following the standardinit_app(app)pattern (works withErriumFlask(app)directly or the app-factory pattern viaErriumFlask().init_app(app)). It registers a single catch-all handler viaapp.register_error_handler(Exception, ...). Flask dispatches bothHTTPExceptions (e.g. fromabort()) and unhandled generic exceptions to a registeredExceptionhandler when no more specific handler exists, so one registration is sufficient — unlike the FastAPI adapter, Flask doesn't need a second, separately-registered handler for validation errors.- The handler classifies the exception, and if it's a pydantic
ValidationError(there's no Flask-native request-validation exception type — this covers the common case of validating a request body with a Pydantic model by hand inside a view), runs it throughValidationNormalizerfor the same beautifieddetailsthe FastAPI adapter produces. - Response is returned as
(jsonify(...), status_code), Flask's standard(body, status_code)response tuple form.
Adapter: Django Ninja (errium_ninja)¶
Django Ninja is the closest architectural match to FastAPI: it's Pydantic-native, and its request
validation errors carry the same loc/type/msg shape FastAPI's do. The adapter follows the
same registration style Ninja itself uses — decorators on a NinjaAPI instance — rather than a
Flask-style extension class:
register_errium(api: NinjaAPI)(extension.py) — the sole entry point. It builds aClassificationEngine, registersNinjaValidationErrorClassifier,NinjaHttpErrorClassifier, andDjangoHttp404Classifier, then registers one shared handler function againstninja.errors.ValidationError,ninja.errors.HttpError,django.http.Http404, and the baseExceptionviaapi.exception_handler(...). This overrides Ninja's own built-in default handlers for those same exception types (Ninja registers defaults for all four when aNinjaAPIis constructed) — notably, Ninja's defaultExceptionhandler re-raises whendjango.conf.settings.DEBUGisFalse("let django deal with it"), which would bypass Errium's JSON contract entirely in production.register_erriumalways returns a standardized response instead, deferring the debug/production decision to Errium's ownERRIUM_DEBUGsetting rather than Django'sDEBUG.classifiers.py:NinjaValidationErrorClassifier(priority 210) — matchesninja.errors.ValidationError.NinjaHttpErrorClassifier(priority 200) — matchesninja.errors.HttpError, which also covers its subclassesAuthenticationError,AuthorizationError, andThrottledviaisinstance. Mapsexc.status_codethroughcategory_for_status_code.DjangoHttp404Classifier(priority 200) — matches Django's ownHttp404(e.g. fromget_object_or_404), which isn't anHttpErrorsubclass and needs its own classifier.- Ninja's
locquirk: when a single PydanticSchemais used as a body/form parameter (the idiomatic pattern —def view(request, payload: SomeSchema)), Ninja wraps validation errors behind a synthetic segment equal to the endpoint's parameter name (e.g.("body", "payload", "password")), instead of FastAPI's flat("body", "password"). Query, path, and header params don't get this wrapper — Ninja already flattens those. Left alone, this would leak the arbitrary parameter name into thedetailsdict's keys and break contract parity with the other adapters.extension.py's_flatten_ninja_errors()strips that one synthetic segment (forbody/formlocations only) before handing errors toValidationNormalizer, sodetailskeys always match what FastAPI/Flask would produce for the same field.
Because NinjaAPI/django.http can't even be imported without Django settings configured first,
anything that touches errium_ninja — including its own test suite — needs
django.conf.settings.configure(...) + django.setup() called before import. See tests/conftest.py.
Adapter: Django REST Framework (errium_drf)¶
DRF hooks in through a single settings-driven function rather than a decorator or extension object:
errium_exception_handler(exc, context)(handler.py) — pointREST_FRAMEWORK["EXCEPTION_HANDLER"]at this function's dotted path (or import and assign it directly). It builds a module-levelClassificationEngineonce (registeringDRFValidationErrorClassifier,DRFAPIExceptionClassifier,DjangoHttp404Classifier, andDjangoPermissionDeniedClassifier), classifies, and returns arest_framework.response.Responsebuilt fromDefaultFormatter.format(...).- Full catch-everything coverage, unlike the DRF default:
APIView.dispatch()wraps every view call in a broadexcept Exception, routing it throughhandle_exception()->exception_handler(exc, context). DRF's own defaultexception_handler(rest_framework.views.exception_handler) deliberately returnsNonefor anything that isn'tAPIException/Http404/PermissionDenied, which makeshandle_exceptionre-raise — falling through to Django's plain, non-JSON 500 handling.errium_exception_handlernever returnsNone: theClassificationEngine'sGenericExceptionClassifierfallback catches everything else, so every exception raised inside a DRF view (function-based via@api_view, or class-based viaAPIView/viewsets — both go through the samedispatch()) gets Errium's standardized JSON response. (Earlier planning for this adapter assumed a separate Django middleware would be needed for full parity — that turned out to be unnecessary oncedispatch()'s exception-handling was traced through; the settings hook alone is sufficient.) classifiers.py:DRFValidationErrorClassifier(priority 210) — matchesrest_framework.exceptions.ValidationError. Deliberately keeps DRF's own status code (exc.status_code, 400 by default) instead of forcing 422 like the other adapters — 400 is DRF's established convention, and remapping it would surprise existing DRF API consumers.DRFAPIExceptionClassifier(priority 200) — matches the generalrest_framework.exceptions.APIExceptionfamily (NotFound,PermissionDenied,AuthenticationFailed,Throttled, etc.), mapsexc.status_codeviacategory_for_status_code, usesstr(exc.detail)as the message.DjangoHttp404Classifier/DjangoPermissionDeniedClassifier(priority 200) — cover Django's ownHttp404anddjango.core.exceptions.PermissionDenied(distinct from DRF's ownPermissionDeniedAPIException), matching what DRF's default handler special-cases too.normalizers.py:flatten_drf_errors(detail)recursively flattens DRF'sValidationError.detailtree (dicts for nested serializer fields, lists ofErrorDetailfor one field's messages or per-item errors on amany=Trueserializer) into the same flat{field_path: message}shape the other adapters produce — no template-mapping needed since DRF'sErrorDetailstrings are already human-readable. Multiple messages for one field are joined with a space; non-field errors (a bare list with no dict wrapper) land under"non_field_errors".- Auth/throttle headers:
APIView.handle_exceptionsetsexc.auth_header(forNotAuthenticated/AuthenticationFailed, when an authenticator is configured) andexc.wait(forThrottled) on the exception before calling the exception handler.errium_exception_handlerreads those and setsWWW-Authenticate/Retry-Afterresponse headers, mirroring what DRF's own default handler does — otherwise a 401 challenge response would be missing the header clients rely on to know how to authenticate.
Response contract¶
Every adapter produces the same JSON shape:
{
"success": false,
"status_code": 422,
"code": "VALIDATION_ERROR",
"message": "Validation failed.",
"trace_id": "87b003a8-7c15-4a6c-9c76-a05b22b109e2",
"timestamp": "2026-05-27T12:00:00Z",
"details": {
"password": "Password is required."
}
}
With ERRIUM_DEBUG=true, a debug object is added (exception class name, message, full stack
trace, contextual hints). See DefaultFormatter above for the sanitization rule that keeps this
out of production responses.
One deliberate inconsistency: the status_code for a validation error is 422 on FastAPI, Flask,
and Django Ninja, but 400 on DRF — the DRF adapter preserves DRF's own established convention
rather than forcing uniformity (see DRFValidationErrorClassifier above). The response shape is
identical across all four adapters either way.
Testing layout¶
tests/unit/— pure unit tests against classifiers, normalizers, and the formatter, with no running app.tests/integration/— drives a real app (fastapi.testclient.TestClient,flask.Flask.test_client(),ninja.testing.TestClient, or DRF'srest_framework.test.APIRequestFactoryagainst@api_view-decorated views) end-to-end through the adapter's actual wiring, asserting on the full JSON response shape.tests/conftest.py— configures a minimal Django settings module (includingREST_FRAMEWORK["EXCEPTION_HANDLER"]pointed aterrium_drf.errium_exception_handler) and callsdjango.setup()before test collection, soerrium_ninjaanderrium_drf(and anything else that needs Django) are importable. No-op for the FastAPI/Flask suites.