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 eight 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 (extractor failures: malformed JSON body, unparseable path parameter) |
https://ferra.rs/errors/validation_failed | 422 Unprocessable Content | Validation Failed | FerraError::Unprocessable (well-formed body whose values violate #[field(...)] rules — see § Validation failures (422)) |
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 eight 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.
The validation (400) and validation_failed (422) URIs are
intentionally distinct. A malformed request body (JSON parse
failure, type mismatch on a required field, an Id parameter that
will not parse) returns 400 — the request was syntactically
unprocessable. A well-formed request body whose values violate
the model’s declarative #[field(...)] rules returns 422 — the
request parsed cleanly but its semantics were rejected. The split
follows RFC 9110 §15.5.1 (400) vs §15.5.21 (422).
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, or when an Id parameter fails
to parse. Declarative-rule violations on a well-formed body are
the separate 422 path — see § Validation failures (422).
{
"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 on the 400 path carries a flat list of error
messages per field name. The 422 path uses a sibling shape — see
the worked example linked above.
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"
}
validation_failed — 422
Returned when an inbound POST or PUT body parses cleanly but
violates one or more #[field(...)] rules declared on the target
model. The full worked example with multi-field aggregation lives
in § Validation failures (422); the
short form:
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"title": ["must be at least 1 character"]
}
}
The errors object on this variant is shaped
Map<String, Vec<String>> keyed by the wire-side field name —
every violation observed during the validation pass is included
(no short-circuiting), so a consumer never needs to retry to
discover further violations.
Validation failures (422)
A 422 response fires when the request body is well-formed — it
parses as JSON and deserialises into the model’s request shape —
but one or more values violate the declarative #[field(...)]
rules on the target model. Validation runs before persistence
on every POST (create) and PUT (update) handler, so an
invalid payload never reaches the database. The wire shape is
identical across create and update.
This is the 422 path. The 400 validation path above continues
to denote extractor failures — a body that does not parse as
JSON at all, a path parameter that will not parse as the
declared type. Both paths share the application/problem+json
envelope; only the 422 path carries the per-field errors map.
The model-side declaration grammar — every supported rule, its
default English message, and the compile-time FRG-2NN codes
that protect typos — is documented in
ferra-forge.md § Validation rules.
This section pins the wire shape your consumer sees.
Worked example: multi-field violation
For a Film resource declared as:
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub title: String,
#[field(min = 0, max = 10)]
pub rating: i32,
}
a request that violates two fields simultaneously:
POST /films HTTP/1.1
Content-Type: application/json
{ "title": "", "rating": 99 }
receives:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"rating": ["must be at most 10"],
"title": ["must be at least 1 character"]
}
}
Both fields appear in the same response. Ferra never short-circuits
on the first failed rule, so a consumer that submits a payload with
N invalid fields receives N entries in errors in a single
round-trip. Within a single field, every rule that fired produces
its own message — #[field(min_length = 5, email)] on a value of
"ab" produces errors.<field> = ["must be at least 5 characters", "is not a valid email address"] (two entries).
Assertion contract for SDK consumers
A consumer driving validation off the wire response asserts the following invariants on a 422:
| Wire field | Value | Notes |
|---|---|---|
| HTTP status | 422 | always equal to the body’s status integer |
Content-Type header | application/problem+json | identical envelope to every other Ferra error |
type | "https://ferra.rs/errors/validation_failed" | pinned URI; closed-namespace member (table above) |
title | "Validation Failed" | distinct from "Validation Error" (the 400 path’s title) |
status | 422 (integer) | |
detail | "validation failed" | constant literal — does NOT echo input back into the response (constitution §I — no reflected-XSS on the error path) |
errors | Map<String, Vec<String>> | keys are wire-side field names (the same names that appear in the request body’s JSON); values are non-empty arrays of human-readable English messages, one entry per rule that fired |
The errors map’s keys preserve the request’s wire-side names —
when a model uses serde’s #[serde(rename = "...")] to map a
wire-side name to a different Rust-side field name, the errors
map keys by the wire-side name (what the consumer submitted and
what they will inspect).
_links is never present on a 422 body. Validation failure is
terminal — there is no resource yet, no self-link to thread, no
hypermedia to follow. A consumer that branches on the response’s
_links.self.href should treat 422 as the absent-link case.
Update-vs-create symmetry
The validation step runs identically on POST /<resource> and
PUT /<resource>/{id}. The 422 body is identical across both.
A field omitted from a PUT body is “no change” (Ferra’s
existing partial-update semantics) and bypasses that field’s
per-field rules. A field present with explicit null on a PUT
body for a #[field(required)] Option<T> field fails with
["is required"] — null on the wire is a positive declaration
of “set to none”, which the rule rejects.
Consumer-side branching pattern
Add one branch to the existing match on the wire type field:
match body["type"].as_str() {
Some("https://ferra.rs/errors/validation_failed") => {
// 422 path — read body["errors"] as Map<String, Vec<String>>
// and surface each (field, msg) pair into your form's
// per-field error UI.
}
Some("https://ferra.rs/errors/validation") => {
// 400 path — extractor failure; the body shape differs
// (flat `errors` list, not the per-field map).
}
// ... other arms ...
_ => /* unreachable on a non-fabricated error */,
}
Server-side, when handling a FerraError value, the 422 path
reads through the new variant:
use ferra::FerraError;
fn observe(err: &FerraError) {
match err {
FerraError::Unprocessable(_) => {
// 422 — declarative-rule violation
}
FerraError::Validation(_) => {
// 400 — extractor failure
}
// other arms unchanged
_ => {}
}
}
FerraError::Unprocessable is the typed home for the 422 path;
FerraError::error_type returns
"https://ferra.rs/errors/validation_failed" on this variant.
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.
match body["type"].as_str() {
Some("https://ferra.rs/errors/not_found") => /* 404 path */,
Some("https://ferra.rs/errors/validation") => /* 400 path; extractor failure */,
Some("https://ferra.rs/errors/validation_failed") => /* 422 path; #[field(...)] rule violation */,
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:
use ferra::FerraError;
fn log_one(err: &FerraError) {
tracing::warn!(error_type = err.error_type(), "request failed");
}
FerraError::error_type returns one of the five URIs that map
through the typed enum (not_found, validation, validation_failed,
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 eight 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
eight URIs above fails OpenAPI schema validation against the spec.
Observability events
Two structured tracing events fire alongside the wire surface of the
error namespace and the deprecation surface. Both follow the
<snake-cased crate name>::<event segment> target convention so an
operator can filter them independently via tracing-subscriber’s
EnvFilter (RUST_LOG=ferra_http::sunset=warn, etc.).
| Target | Level | Trigger | Documented at |
|---|---|---|---|
ferra_http::rate_limit | WARN | every 429 emission from the governor middleware | this file § “rate_limited — 429” |
ferra_http::sunset | WARN | once per (version, sunset) per process when a request lands past the declared sunset date | foundry.md § “Post-sunset warn-event” |
The post-sunset event carries five fields (version, sunset,
days_overdue, http.method, http.target); the rate-limit event
carries three (http.method, http.target, http.retry_after_seconds).
Both event-schemas are contracts under ADR-0015 §“event schema is a
contract” — additive widenings only, never renames.
When the observability Cargo feature is on, the framework ALSO
advances a metrics::counter! named ferra.sunset.post_sunset_hits
on every post-sunset hit (regardless of the once-per-process dedup
that gates the warn-event). See foundry.md § “Post-sunset
warn-event” for the full surface.
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.