Skip to content

Axum Extractors, Routers, State, and Error Responses

An AI HTTP endpoint should admit, validate, identify, and dispatch work. It should not hold a request open while a large model, video decoder, tool loop, or retry policy runs invisibly inside the handler.

The companion mosaic-api crate implements a complete first boundary: POST /v1/runs validates a typed request, creates an internal run identity, performs a non-blocking enqueue into a bounded channel, and returns 202 Accepted. Invalid JSON, invalid domain state, full capacity, and a dead worker are different observable outcomes.

  • construct an Axum router from routes, extractors, state, and layers;
  • keep HTTP DTOs separate from validated domain values;
  • understand extractor ordering and body consumption;
  • return stable success and error envelopes;
  • distinguish 400, 413, 422, 429, and 503;
  • make handlers thin by handing work to an application boundary;
  • test a router without binding a TCP port;
  • investigate requests that were acknowledged but never processed;
  • account for request-size, queue, identity, and error-disclosure risks.

This chapter depends on Serde contracts, domain validation, Tokio channels, backpressure, and configuration. Tower policies are expanded in the next chapter.

untrusted HTTP bytes
├── method/path/content type/body limit
transport DTO decoded by extractor
├── syntax/shape validation
domain construction + invariant validation
├── admission/capacity decision
durable transaction or bounded handoff
stable HTTP response + request identity

An extractor is not the whole validation boundary. Json<T> proves only that a supported media type was decoded into T. Domain constructors must still reject empty identifiers, invalid budget relationships, unauthorized capabilities, and policy violations.

The router is composition, not a controller

Section titled “The router is composition, not a controller”

The companion builds two routes:

let router = Router::new()
.route("/health/live", get(liveness))
.route("/v1/runs", post(create_run))
.with_state(state);

Axum routes requests through Tower services. This makes middleware, testing, and nested application composition share one model. Prefer feature routers:

public API
├── /health/* process health
├── /v1/runs/* run lifecycle
├── /v1/artifacts/* upload/download metadata
└── /v1/admin/* separately authenticated operations

Merge or nest routers at the composition root. Do not make one handler dispatch on arbitrary path strings, and do not expose an internal debug router merely because it exists.

The input DTO intentionally contains plain deserializable fields:

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateRunRequest {
pub task_id: String,
pub objective: String,
#[serde(default)]
pub input: serde_json::Value,
}

It is not trusted application state. The handler converts it:

let task = Task {
id: TaskId::new(request.task_id)?,
objective: request.objective,
input: request.input,
output_contract: OutputContract {
required_fields: Vec::new(),
reject_unknown_fields: false,
},
budget: RunBudget::default(),
risk: RiskClass::Low,
};
task.validate()?;

Why not deserialize directly into Task?

  • the public API may use different names/versioning from the domain;
  • the server owns defaults such as risk and budget;
  • authenticated tenant/policy data must not be caller-selectable;
  • constructors and validation define the trusted transition;
  • persistence and domain representation can evolve without silently changing HTTP.

deny_unknown_fields catches many client typos. Compatibility policy matters: strict input is useful for commands and safety-sensitive configuration, while event readers may need additive evolution.

Extractors implement FromRequestParts or FromRequest. Parts-only extractors—state, headers, method, URI, path—do not consume the body. A body extractor such as Json<T> consumes it and must be last:

async fn create_run(
State(state): State<ApiState>,
request: Result<Json<CreateRunRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError>

Accepting Result<Json<T>, JsonRejection> gives the application control over the public error contract. A plain Json<T> is convenient, but its rejection response becomes part of the API whether or not you designed it.

Do not buffer a body in authentication, log it, then expect Json to read it again. Metadata middleware should inspect parts. Middleware that must inspect a body needs a strict byte limit, intentional reconstruction, and secret/media redaction.

The API state contains only what the transport needs:

struct ApiState {
queue: mpsc::Sender<QueuedRun>,
next_run: Arc<AtomicU64>,
}

Axum clones state for services; cloneable does not mean copy all application data. Good state holds cheap handles:

  • database pool, not one checked-out connection;
  • object-store client, not uploaded bytes;
  • bounded sender, not the receiver and worker ownership;
  • immutable policy snapshot;
  • service trait object with narrow methods;
  • cancellation/shutdown handle.

Avoid one Arc<Mutex<App>>. It obscures concurrency, expands authority, and can serialize unrelated requests. A handler that needs only RunSubmission should not receive provider secrets or an admin repository.

FromRef can derive substates, but treat it as capability projection rather than a way to make every dependency globally available.

The companion handler performs five steps:

  1. map JSON rejection to a stable transport error;
  2. construct and validate the Task;
  3. generate a server-owned RunId;
  4. try_send one QueuedRun;
  5. return 202 only when handoff succeeds.

try_send is deliberate:

state.queue.try_send(QueuedRun { run_id, task })

If the queue is full, the handler returns 503 queue_full. If the receiver was dropped, it returns 503 queue_closed. It never acknowledges work that disappeared.

For a single-process tutorial this bounded handoff is useful and testable. It is not durable. In a production multi-replica service, the stronger boundary is usually one database transaction that inserts the run, records an idempotency key, and creates an outbox/job row. 202 means the durable system owns the work—not that a model has completed it.

Use status codes to express the caller’s next action:

StatusMeaning hereCaller action
202 AcceptedValid work is owned/queuedPoll or subscribe using returned run ID
400 Bad RequestMalformed JSON or request shapeFix serialization/request
401 UnauthorizedNo valid authenticationAuthenticate; retry only after credential change
403 ForbiddenAuthenticated principal lacks capabilityChange authority/request
409 ConflictIdempotency key or state transition conflictReconcile existing resource
413 Payload Too LargeRequest exceeds transport limitUpload differently or reduce payload
422 Unprocessable ContentSyntax decoded, domain state invalidFix semantic fields
429 Too Many RequestsPrincipal quota/rate exhaustedRespect retry policy
503 Service UnavailableQueue/worker/global capacity unavailableBounded retry with jitter

Do not return 200 with {"success": false}. Do not return 500 for caller-invalid domain input. Do not claim retryability unless repeated requests are safe.

The companion response is:

{
"error": {
"code": "queue_full",
"message": "run queue is at capacity; retry later"
}
}

code is a machine contract; message is bounded human context. Production envelopes often add:

  • request ID;
  • safe field/path violations;
  • documentation URI;
  • retry-after seconds;
  • existing resource ID for idempotency conflict.

Do not expose provider bodies, SQL details, filesystem paths, prompts, tokens, backtraces, or secret configuration. Log an internal error chain under an internal correlation ID. Public errors remain an allowlisted projection.

GET /health/live says the process/runtime can answer. It deliberately does not call the database, model provider, or object store. If liveness depends on downstream availability, an orchestrator can restart every healthy replica during a provider outage.

Separate:

  • liveness: process event loop is responsive;
  • readiness: this replica should receive traffic;
  • startup: migrations/artifacts/warmup completed;
  • deep diagnostic: privileged, deadline-bound component details.

Readiness should reflect the service’s admission promise. A service may remain ready and return typed overload, or become unready when it cannot safely own any new work.

From the repository root:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-api --example http_boundary
cargo test -p mosaic-api

Expected demonstration:

202 Accepted {"run_id":"run-0000000000000001","status":"queued"}
queued run-0000000000000001 for demo

The source is crates/mosaic-api/src/lib.rs. The tests invoke the Router with tower::ServiceExt::oneshot; they do not bind a port or add network timing noise. They prove:

  • valid work returns 202 and reaches the receiver;
  • malformed JSON returns 400 invalid_json;
  • a blank objective returns 422 invalid_task;
  • declared oversized input returns 413 payload_too_large;
  • a full queue returns 503 queue_full;
  • a dropped receiver returns 503 queue_closed;
  • successful and middleware-rejected responses carry request identity.

Useful for short bounded inference where cancellation follows connection lifetime. It is simpler for clients, but proxy deadlines and duplicate retries make long media/model work fragile.

Low latency and excellent for process-local workers/tests. Work is lost on crash and is not shared across replicas.

Survives restart, supports leases/replay, and gives 202 a strong meaning. It adds transaction, polling/notification, cleanup, and idempotency complexity.

Quick to wire. Weakens discoverability and capability boundaries. Prefer typed state and narrow application services.

Minimal code. Couples public behavior to framework versions. Map rejections when clients depend on stable codes.

Failure investigation: API returned 202 but no run exists

Section titled “Failure investigation: API returned 202 but no run exists”
  1. confirm whether 202 followed durable commit or only memory enqueue;
  2. correlate request ID, run ID, idempotency key, and admission event;
  3. check whether sender success preceded worker/process crash;
  4. inspect queue-closed/full metrics and worker supervision;
  5. verify response was not generated before enqueue/commit;
  6. reproduce by terminating the process between handoff and processing;
  7. move the acceptance boundary to transactional durable ownership.

Never “repair” this by returning success earlier.

Failure investigation: valid clients receive 400

Section titled “Failure investigation: valid clients receive 400”
  1. capture content type, content length, route, method, and safe rejection class;
  2. compare DTO version and unknown-field policy;
  3. distinguish malformed JSON from semantic validation;
  4. check proxy decompression/encoding and body truncation;
  5. replay a redacted fixture through oneshot;
  6. add the exact fixture as a compatibility test.

Do not log arbitrary request bodies from production.

Security:

  • authenticate before expensive parse, but bound bytes before all expensive work;
  • derive tenant/risk/capabilities from trusted policy, not body fields;
  • reject unknown command fields where compatibility allows;
  • cap nesting, strings, arrays, decompressed bytes, and multipart parts;
  • use idempotency for retryable state changes;
  • apply per-principal quota before global scarce-resource admission;
  • keep public errors allowlisted and request IDs non-secret;
  • prevent SSRF/path/tool authority from entering through generic JSON.

Performance:

  • handler latency should measure parse, validation, admission, and commit—not model duration;
  • cloning Arc/pool/sender handles is cheap; cloning media bodies is not;
  • atomics provide unique process-local tutorial IDs, not globally durable identities;
  • body limits must cover compressed and expanded representations;
  • queue wait, service time, and downstream time need separate histograms;
  • never label metrics with raw run/request/tenant IDs.
  1. Add GET /v1/runs/{run_id} with a transport DTO that does not expose internal trace events.
  2. Add an Idempotency-Key extractor and distinguish first acceptance, replay, and key/body conflict.
  3. Replace mpsc ownership with a transactional repository trait and fake it in router tests.
  4. Add readiness that fails when the worker is closed without making liveness depend on it.
  5. Add authentication-derived tenant state and prove a body cannot select another tenant.
  6. Create a property test that arbitrary invalid bodies never produce 202.

Keep path/header/body DTOs in the transport crate. Build a canonical request fingerprint from the validated command, store it with the idempotency key and tenant, and return the prior run only when the fingerprint matches. Repository insertion should return a typed outcome (Created, Replay, Conflict, Unavailable). Readiness observes a cheap worker/repository health snapshot; liveness does not call dependencies.

  • What exactly does Json<T> prove?
  • Why is the body extractor last?
  • When is it truthful to return 202?
  • Why are queue_full and queue_closed distinct?
  • Which state values are capabilities rather than data?
  • Why is 422 different from malformed JSON?
  • How can a framework rejection accidentally become a public contract?
  • HTTP DTOs are separate from validated domain state.
  • Every body has byte and shape limits.
  • Handlers perform bounded admission, not the harness loop.
  • 202 follows an owned handoff or durable transaction.
  • Error codes/statuses are stable and tested.
  • Full and closed capacity fail explicitly.
  • Liveness, readiness, and deep health are distinct.
  • Router tests cover success, boundary, failure, and overload.
  • I ran the companion example and tests.