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.
Learning objectives
Section titled “Learning objectives”- 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, and503; - 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.
Dependencies and mental model
Section titled “Dependencies and mental model”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 identityAn 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 operationsMerge 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.
Transport DTO versus domain type
Section titled “Transport DTO versus domain type”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.
Extractor ordering
Section titled “Extractor ordering”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.
Application state should be capabilities
Section titled “Application state should be capabilities”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 handler is an admission transaction
Section titled “The handler is an admission transaction”The companion handler performs five steps:
- map JSON rejection to a stable transport error;
- construct and validate the
Task; - generate a server-owned
RunId; try_sendoneQueuedRun;- return
202only 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.
Status-code contract
Section titled “Status-code contract”Use status codes to express the caller’s next action:
| Status | Meaning here | Caller action |
|---|---|---|
202 Accepted | Valid work is owned/queued | Poll or subscribe using returned run ID |
400 Bad Request | Malformed JSON or request shape | Fix serialization/request |
401 Unauthorized | No valid authentication | Authenticate; retry only after credential change |
403 Forbidden | Authenticated principal lacks capability | Change authority/request |
409 Conflict | Idempotency key or state transition conflict | Reconcile existing resource |
413 Payload Too Large | Request exceeds transport limit | Upload differently or reduce payload |
422 Unprocessable Content | Syntax decoded, domain state invalid | Fix semantic fields |
429 Too Many Requests | Principal quota/rate exhausted | Respect retry policy |
503 Service Unavailable | Queue/worker/global capacity unavailable | Bounded 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.
Stable error envelopes
Section titled “Stable error envelopes”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.
Health is not one boolean
Section titled “Health is not one boolean”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.
Runnable companion implementation
Section titled “Runnable companion implementation”From the repository root:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-api --example http_boundarycargo test -p mosaic-apiExpected demonstration:
202 Accepted {"run_id":"run-0000000000000001","status":"queued"}queued run-0000000000000001 for demoThe 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
202and 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.
Alternatives and trade-offs
Section titled “Alternatives and trade-offs”Synchronous response
Section titled “Synchronous response”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.
In-memory bounded queue
Section titled “In-memory bounded queue”Low latency and excellent for process-local workers/tests. Work is lost on crash and is not shared across replicas.
Durable job row/outbox
Section titled “Durable job row/outbox”Survives restart, supports leases/replay, and gives 202 a strong meaning. It adds transaction,
polling/notification, cleanup, and idempotency complexity.
Generic Extension/large state
Section titled “Generic Extension/large state”Quick to wire. Weakens discoverability and capability boundaries. Prefer typed state and narrow application services.
Framework-default rejections
Section titled “Framework-default rejections”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”- confirm whether
202followed durable commit or only memory enqueue; - correlate request ID, run ID, idempotency key, and admission event;
- check whether sender success preceded worker/process crash;
- inspect queue-closed/full metrics and worker supervision;
- verify response was not generated before enqueue/commit;
- reproduce by terminating the process between handoff and processing;
- 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”- capture content type, content length, route, method, and safe rejection class;
- compare DTO version and unknown-field policy;
- distinguish malformed JSON from semantic validation;
- check proxy decompression/encoding and body truncation;
- replay a redacted fixture through
oneshot; - add the exact fixture as a compatibility test.
Do not log arbitrary request bodies from production.
Security and performance implications
Section titled “Security and performance implications”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.
Exercises
Section titled “Exercises”- Add
GET /v1/runs/{run_id}with a transport DTO that does not expose internal trace events. - Add an
Idempotency-Keyextractor and distinguish first acceptance, replay, and key/body conflict. - Replace
mpscownership with a transactional repository trait and fake it in router tests. - Add readiness that fails when the worker is closed without making liveness depend on it.
- Add authentication-derived tenant state and prove a body cannot select another tenant.
- Create a property test that arbitrary invalid bodies never produce
202.
Solution guidance
Section titled “Solution guidance”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.
Check your understanding
Section titled “Check your understanding”- What exactly does
Json<T>prove? - Why is the body extractor last?
- When is it truthful to return
202? - Why are
queue_fullandqueue_closeddistinct? - Which state values are capabilities rather than data?
- Why is
422different from malformed JSON? - How can a framework rejection accidentally become a public contract?
Completion checklist
Section titled “Completion checklist”- HTTP DTOs are separate from validated domain state.
- Every body has byte and shape limits.
- Handlers perform bounded admission, not the harness loop.
-
202follows 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.