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 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.

URIHTTP statusTitleSource
https://ferra.rs/errors/not_found404 Not FoundResource Not FoundFerraError::NotFound
https://ferra.rs/errors/validation400 Bad RequestValidation ErrorFerraError::Validation (extractor failures: malformed JSON body, unparseable path parameter)
https://ferra.rs/errors/validation_failed422 Unprocessable ContentValidation FailedFerraError::Unprocessable (well-formed body whose values violate #[field(...)] rules — see § Validation failures (422))
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 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_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, 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.

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"
}

validation_failed422

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 fieldValueNotes
HTTP status422always equal to the body’s status integer
Content-Type headerapplication/problem+jsonidentical 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)
status422 (integer)
detail"validation failed"constant literal — does NOT echo input back into the response (constitution §I — no reflected-XSS on the error path)
errorsMap<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.).

TargetLevelTriggerDocumented at
ferra_http::rate_limitWARNevery 429 emission from the governor middlewarethis file § “rate_limited429
ferra_http::sunsetWARNonce per (version, sunset) per process when a request lands past the declared sunset datefoundry.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.

  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.