Skip to content

Error Handling

Introduction

When you start a new Arvel project, error handling is already configured for you. Arvel turns exceptions raised during request handling into consistent JSON error responses. You raise a semantic exception — "not found", "forbidden", "validation failed" — and the framework's handler serializes it with the right status code and a uniform body. You never assemble error responses by hand.

The Error Envelope

Every HTTP exception serializes to the same envelope:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Post not found.",
    "details": [
      {"field": "email", "issue": "The email has already been taken."}
    ]
  }
}
  • code is a stable, machine-readable identifier (UPPER_SNAKE_CASE).
  • message is a human-readable, single-sentence description.
  • details is optional — present only when the exception carries field-level issues, as validation errors do.

This shape is identical whether the error came from your code, a form request, or FastAPI's own body parsing, so clients only ever parse one format.

HTTP Exceptions

Available Exceptions

Arvel provides a typed exception for each common HTTP status. Each carries a fixed status code and machine code:

Exception Status code
BadRequestException 400 BAD_REQUEST
UnauthenticatedException 401 UNAUTHENTICATED
AuthorizationException 403 FORBIDDEN
NotFoundException 404 NOT_FOUND
MethodNotAllowedException 405 METHOD_NOT_ALLOWED
ConflictException 409 CONFLICT
ValidationException 422 VALIDATION_FAILED
UnprocessableException 422 UNPROCESSABLE
ThrottleException 429 TOO_MANY_REQUESTS
ServerErrorException 500 INTERNAL_ERROR
HttpException 500 (overridable) INTERNAL_ERROR

Note

All of these are importable from the top-level arvel package except UnprocessableException, which lives in arvel.http.exceptions. The CsrfMismatchException raised by VerifyCsrf (419, CSRF_MISMATCH) lives in arvel.http.middleware.

Throwing Exceptions

Raise an exception anywhere during request handling, and the handler converts it to the matching response:

from arvel import NotFoundException, ValidationException
from arvel.http.exceptions import UnprocessableException

raise NotFoundException("Post not found.")

raise ValidationException(
    "Validation failed.",
    details=[{"field": "email", "issue": "must be unique"}],
)

ThrottleException requires a retry_after_seconds, which the handler emits as a Retry-After header:

raise ThrottleException("Slow down.", retry_after_seconds=30)

Where Exceptions Come From

You'll often encounter these exceptions without raising them yourself:

  • find_or_fail(...) and missing route model bindings surface as 404s.
  • Form request rule failures raise ValidationException (422).
  • A failed authorize() or a denied gate check raises AuthorizationException (403).
  • The Authenticate middleware with no user raises UnauthenticatedException (401).

Custom HTTP Exceptions

For your own domain errors, subclass HttpException and set the status and code:

from arvel import HttpException


class PaymentRequiredException(HttpException):
    status_code = 402
    code = "PAYMENT_REQUIRED"

Then raise it like any other:

raise PaymentRequiredException("Your subscription has lapsed.")

Warning

Never put internal detail — SQL, stack traces, file paths, secrets — into the message of a client-facing exception. Keep messages user-appropriate and log the internals.

The Exception Handler

The exception handler is registered automatically when the application boots (into_asgi()). It:

  • Serializes HttpException subclasses to the envelope with their status code.
  • Normalizes FastAPI's RequestValidationError (body-parsing failures) into the same 422 shape.
  • Catches any other unhandled Exception and returns a generic 500 with the body "Something went wrong"no stack traces, SQL, or internal paths leak to the client, in any environment.

Reporting and Logging

The handler logs as it works: handled HttpExceptions are logged at warning level (with auth and cookie headers redacted), and unhandled exceptions are logged at error level with the exception attached, before the generic 500 is returned. See Logging.

Custom Translators

To map a third-party or library exception to an HTTP response, register a translator — a callable that converts the exception into an HttpException. Resolve HttpExceptionHandler from the container in a provider's boot() and add the translator:

async def boot(self) -> None:
    handler = self.app.make(HttpExceptionHandler)
    handler.add_translator(SomeLibraryError, lambda exc: NotFoundException(str(exc)))

Arvel registers a few translators by default — for instance, the ORM's ModelNotFoundError is translated to NotFoundException, and the auth package's exceptions are mapped to their HTTP equivalents.

Problem Details (RFC 7807)

Arvel also ships a ProblemDetailsHandler (in arvel.http.problem_details) that renders errors as application/problem+json per RFC 7807. It's opt-in: bind it in place of the default HttpExceptionHandler in the container.

Warning

ProblemDetailsHandler registers handlers for HttpException and validation errors but does not install a catch-all for unhandled Exceptions. If you opt in, make sure unexpected errors are handled elsewhere so they don't escape as raw 500s.