Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error handling

Every Ferra-served HTTP response that is not a success carries an RFC 7807 problem-details body (Content-Type: application/problem+json). Every body’s type field is a URI from a closed set the framework maintains as a single source of truth.

This page is the reference for that closed set: the URI table, the HTTP status / title / example body for each variant, and the consumer-side branching pattern.


The closed ERROR_TYPES URI set

The framework can emit exactly seven type URIs on the wire. Every literal in framework source (and in any consumer crate) MUST appear in this set; a CI gate (error_types_closed.sh) fails the build on any drift.

URIHTTP statusTitleSource
https://ferra.rs/errors/not_found404 Not FoundResource Not FoundFerraError::NotFound
https://ferra.rs/errors/validation400 Bad RequestValidation ErrorFerraError::Validation
https://ferra.rs/errors/conflict409 ConflictConflictFerraError::Conflict
https://ferra.rs/errors/internal500 Internal Server ErrorInternal Server ErrorFerraError::Internal
https://ferra.rs/errors/payload_too_large413 Payload Too LargePayload Too Largerequest-body-limit middleware
https://ferra.rs/errors/rate_limited429 Too Many RequestsToo Many Requestsrate-limit middleware
https://ferra.rs/errors/unauthorized401 UnauthorizedUnauthorizedFoundry::with_docs_protected(...)

Three of the seven URIs (payload_too_large, rate_limited, unauthorized) are emitted by middleware layers, not through FerraError. The framework’s full URI surface is the union of the two paths; both are governed by the same ERROR_TYPES slice.


Worked example bodies

not_found404

Returned by every read / update / delete that resolves to zero matching rows.

{
  "type": "https://ferra.rs/errors/not_found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "films/c2bb1f10-72b8-486a-9f2b-c92e4a2cdf41 not found"
}

validation400

Returned when an inbound JSON body or a path parameter fails the framework’s typed deserialisation, when an Id parameter fails to parse, or when a future validator-derived field check rejects the input.

{
  "type": "https://ferra.rs/errors/validation",
  "title": "Validation Error",
  "status": 400,
  "detail": "validation failed",
  "errors": {
    "title": ["must not be empty"],
    "year":  ["must be between 1888 and the current year"]
  }
}

The errors object is present only on the validation variant. Its shape is Map<String, Vec<String>> — a list of error messages per field name.

conflict409

Returned when a write would violate a unique constraint (duplicate slug, primary-key collision, etc.).

{
  "type": "https://ferra.rs/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "title 'Casablanca' is already in use"
}

internal500

Returned for any database / infrastructure failure not classifiable as one of the typed variants above. detail is always the constant literal "internal server error" — no internal path fragment, no crate name, no underlying-library substring leaks (constitution §I).

{
  "type": "https://ferra.rs/errors/internal",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "internal server error"
}

payload_too_large413

Emitted when an inbound request body exceeds the framework’s default 1 MiB cap (constitution §I — DoS protection).

{
  "type": "https://ferra.rs/errors/payload_too_large",
  "title": "Payload Too Large",
  "status": 413,
  "detail": "request body too large"
}

rate_limited429

Emitted by the framework’s default rate-limiter on mutation routes (POST / PUT / DELETE). The response carries a Retry-After header when the limiter can compute a sensible delay.

{
  "type": "https://ferra.rs/errors/rate_limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "rate limit exceeded"
}

unauthorized401

Emitted by Foundry::with_docs_protected(verifier) when an unauthenticated request reaches the docs surface. New in 0.5.0.

{
  "type": "https://ferra.rs/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "authentication required"
}

Consumer-side branching

The recommended consumer pattern is to compare the wire type field against the URI constants — never against the Display form of the error. The URI is the wire contract; the message text is not.

#![allow(unused)]
fn main() {
match body["type"].as_str() {
    Some("https://ferra.rs/errors/not_found")        => /* 404 path */,
    Some("https://ferra.rs/errors/validation")       => /* 400 path */,
    Some("https://ferra.rs/errors/conflict")         => /* 409 path */,
    Some("https://ferra.rs/errors/payload_too_large") => /* 413 path */,
    Some("https://ferra.rs/errors/rate_limited")     => /* 429 path; check Retry-After */,
    Some("https://ferra.rs/errors/unauthorized")     => /* 401 path */,
    Some("https://ferra.rs/errors/internal")         => /* 500 path; retry with backoff */,
    _ => /* unreachable on a non-fabricated error */,
}
}

Server-side, when handling a FerraError value (for example, when mapping to a custom log line or a metrics tag), use the typed FerraError::error_type method:

#![allow(unused)]
fn main() {
use ferra::FerraError;

fn log_one(err: &FerraError) {
    tracing::warn!(error_type = err.error_type(), "request failed");
}
}

FerraError::error_type returns one of the four URIs that map through the typed enum (not_found, validation, conflict, internal). The other three URIs reach the wire via middleware layers and are never observed as FerraError values.


OpenAPI schema constraint

The OpenAPI spec the framework emits at /docs/openapi.json constrains ProblemDetails.type to the closed ERROR_TYPES set as an enum. SDK generators (orval, openapi-generator, kiota, …) produce a typed union over the seven URIs rather than a free-form string — consumers in TypeScript, Go, Python, etc. branch exhaustively on the wire type value.

For an AI coding assistant generating client code: the type field is an enum, not a free-form string. Synthesising a value outside the seven URIs above fails OpenAPI schema validation against the spec.


Adding a new URI

The framework’s URI namespace is closed by design: a new variant requires a coordinated change across three surfaces.

  1. Add the URI literal to ERROR_TYPES in crates/ferra-core/src/error.rs.
  2. Extend the OpenAPI emitter so ProblemDetails.type carries the new value in its enum constraint (the emitter reads ERROR_TYPES directly; this step is automatic once step 1 lands).
  3. Publish at least one HTML anchor on https://ferra.rs/errors/<variant> returning HTTP 200, so that AI agents and human readers reach a stable documentation page for the new variant.

The release-blocking CI gates verify (1) source-side closure (error_types_closed.sh) and (3) network-side resolution (the URI probe). New variants without all three gates green fail the build.

The closed-namespace contract is recorded in ADR-0026. The contract is not load-bearing for consumer correctness — the URI table above is self-contained.