Skip to content
errium

The classification engine

How an exception resolves to a category, a status, and a message.

ClassificationEngine holds a list of classifiers sorted by priority, highest first. classify(exc) walks that list in order and returns the first non-None result. If nothing matches, it falls back to GenericExceptionClassifier.

The protocol

A classifier is anything satisfying ExceptionClassifier:

from typing import Protocol

from errium_core.contracts.classified_error import ClassifiedError


class ExceptionClassifier(Protocol):
    @property
    def priority(self) -> int:
        """The evaluation priority of this classifier.

        Higher priorities are evaluated first.
        """
        ...

    def classify(self, exc: Exception) -> ClassifiedError | None: ...

It’s a Protocol, so there is no base class to inherit. Any object with a priority property and a classify method qualifies.

Priority ordering

register() appends the classifier and re-sorts the list descending by priority. A classifier without a priority attribute defaults to 100.

ClassifierPriorityRegistered by
Classifiers added with errium.register(...)300You
Adapter validation classifiers210–215Each adapter
Adapter HTTP classifiers200Each adapter
DjangoExceptionClassifier190The Ninja and DRF adapters
ValidationExceptionClassifier150Always, on construction
DatabaseExceptionClassifier140Always, on construction
Unspecified100Default for anything else
GenericExceptionClassifier—Fallback, outside the list

The two core classifiers are registered by __init__, so every engine has them regardless of framework. Adapters then register their own on top at 200 and above, so framework-specific handling always wins over the generic core matching.

The fallback

GenericExceptionClassifier has no priority property and is not part of the sorted list. It is stored separately as fallback_classifier and consulted only when every registered classifier has returned None. Its classify returns ClassifiedError rather than ClassifiedError | None — it always matches, always INTERNAL_SERVER_ERROR with a 500.

That is what guarantees the engine always produces a result, which in turn is what lets the DRF adapter never return None.

The always-registered classifiers

ValidationExceptionClassifier (150) matches a Pydantic ValidationError, or anything whose class name is RequestValidationError. It returns VALIDATION_ERROR with a 422 — unless every error is a json_invalid one, meaning the body was not parseable JSON, in which case it returns BAD_REQUEST with a 400 and the message “Malformed request body.” on every adapter.

DatabaseExceptionClassifier (140) matches database exceptions by walking the MRO for a known module path — SQLAlchemy, Django’s ORM (django.db), and the sqlite3, psycopg, psycopg2, asyncpg and pymysql drivers — with no dependency on any of them. See Core building blocks.

DjangoExceptionClassifier (190) is detected the same duck-typed way. It maps Django’s own exceptions to the status Django itself would use: PermissionDenied → 403, SuspiciousOperation → 400, RequestDataTooBig / TooManyFieldsSent → 413, ObjectDoesNotExist → 404, and Django’s ValidationError → 422 (400 on DRF). The Ninja and DRF adapters register it; the FastAPI and Flask engines do not.

One registry, every engine

Classifiers registered with errium.register(...) or errium_core.register_classifier(...) live in a process-wide registry that every engine merges in at classification time, sorted by priority with its own. That is why one registration applies to every adapter and every entry point.

Next: writing your own.