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.
| URI | HTTP status | Title | Source |
|---|---|---|---|
https://ferra.rs/errors/not_found | 404 Not Found | Resource Not Found | FerraError::NotFound |
https://ferra.rs/errors/validation | 400 Bad Request | Validation Error | FerraError::Validation |
https://ferra.rs/errors/conflict | 409 Conflict | Conflict | FerraError::Conflict |
https://ferra.rs/errors/internal | 500 Internal Server Error | Internal Server Error | FerraError::Internal |
https://ferra.rs/errors/payload_too_large | 413 Payload Too Large | Payload Too Large | request-body-limit middleware |
https://ferra.rs/errors/rate_limited | 429 Too Many Requests | Too Many Requests | rate-limit middleware |
https://ferra.rs/errors/unauthorized | 401 Unauthorized | Unauthorized | Foundry::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_found — 404
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"
}
validation — 400
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.
conflict — 409
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"
}
internal — 500
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_large — 413
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_limited — 429
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"
}
unauthorized — 401
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.
- Add the URI literal to
ERROR_TYPESincrates/ferra-core/src/error.rs. - Extend the OpenAPI emitter so
ProblemDetails.typecarries the new value in itsenumconstraint (the emitter readsERROR_TYPESdirectly; this step is automatic once step 1 lands). - 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.