Hedronite · Backend Dev · Rust · Thu 2026-10-29

One app, two error formats Loco JSON controllers, extractors, and request tests

Nine tested requests against one Loco controller show which failures come back as Loco's JSON error body and which come back as Axum's plain text, and how to pin the contract in a test.

Lesson Class: Dev (Rust · Loco web framework)
Tested with: loco-rs 1.2.0 · Axum 0.8 · rustc 1.99.0 · debug build
Lab: app/ · cargo test (3) · cargo loco routes
Source: loco-rs 1.2.0 source and docs · Loco how-to guides
Know the Owner
An extractor's rejection is rendered by whoever owns the extractor.
Pin the Shape
Assert content-type as well as status.
Log the Reason
A generic 422 body hides its cause in a debug log.
The error a client sees is decided before your handler runs.

The problem

You are building a small JSON API in Rust and you want it to behave predictably when clients send bad input. A mobile app asks for /api/notes/abc where a number belongs. A script posts a body with the wrong field name. A browser posts text without a JSON content type. Each of these is a different mistake, and each should produce a response your client code can recognise and handle.

The pain is that a Rust web stack is assembled from several layers, and each layer has its own opinion about error responses. You write a handler, add an extractor, return an error, and assume the client sees one consistent shape. Then someone points a client at the endpoint and finds that some failures come back as JSON and others as a plain-text sentence, with no way to tell which format a given mistake will get until it happens.

This lesson builds a three-route notes API on a fresh Loco application and sends it nine requests, good and bad. The test shows that three bad inputs yield two different error formats in the same app, explains why, and shows how to decide which format you want and pin it with a test. The "fresh application" here is a project generated by loco new with no database, async background workers, and no asset serving: a plain HTTP server with a couple of starter controllers and nothing else.

The idea in plain words

Loco is a Rust web framework built on Axum, and its controllers are ordinary Axum handlers with a few conveniences added. Axum pulls request data into your handler through extractors, which are types in the argument list that parse the request for you. When an extractor fails, Axum has its own default response. Loco adds a second path: if your handler returns Loco's Error, Loco renders it as a JSON body. Whether a failure reaches Loco's JSON path depends on which extractor failed, and that depends on which type you wrote in the signature. Knowing which extractors go through Loco's Error lets you choose, on purpose, how each failure looks to your clients.

The route table and Hooks::routes

A controller module exposes a routes() function that returns a Routes value, and the application lists those values in the Hooks::routes method of src/app.rs. Here is the whole controller for this lesson. It keeps its data in a constant so that no database is needed.

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Clone)]
pub struct Note {
    pub id: u32,
    pub title: String,
}

#[derive(Debug, Deserialize)]
pub struct NewNote {
    pub title: String,
}

#[derive(Debug, Deserialize)]
pub struct ListParams {
    pub limit: Option<usize>,
}

const NOTES: [(u32, &str); 3] = [(1, "boot the app"), (2, "read the route table"), (3, "write a request test")];

fn all() -> Vec<Note> {
    NOTES.iter().map(|(id, t)| Note { id: *id, title: (*t).to_string() }).collect()
}

#[debug_handler]
pub async fn list(Query(p): Query<ListParams>) -> Result<Response> {
    let mut notes = all();
    notes.truncate(p.limit.unwrap_or(notes.len()));
    format::json(notes)
}

#[debug_handler]
pub async fn show(Path(id): Path<u32>) -> Result<Response> {
    match all().into_iter().find(|n| n.id == id) {
        Some(n) => format::json(n),
        None => not_found(),
    }
}

#[debug_handler]
pub async fn create(Json(body): Json<NewNote>) -> Result<Response> {
    let title = body.title.trim();
    if title.is_empty() {
        return bad_request("title must not be empty");
    }
    format::render()
        .status(201)
        .json(Note { id: 4, title: title.to_string() })
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api/notes/")
        .add("/", get(list))
        .add("/", post(create))
        .add("/{id}", get(show))
}

To wire it in, add pub mod notes; to src/controllers/mod.rs and add .add_route(controllers::notes::routes()) to Hooks::routes in src/app.rs, after the health route. Without that call the module still compiles, but nothing mounts it: cargo loco routes will not list /api/notes, and the router has no handler to send those requests to.

The routes() function sets a prefix and then adds three method-and-path pairs. Each .add takes a path and a method router such as get(list), and the prefix is joined onto each path. Axum 0.8 uses {id} for a path parameter, which is why the last route is written /{id}. The #[debug_handler] attribute is Axum's macro that checks a handler's signature at compile time, so that a wrong argument type produces a readable compiler error instead of a long trait-bound message. Running cargo loco routes prints what the server will actually mount:

/_health GET                                       /_health
/_ping GET                                         /_ping
/_readiness GET                                    /_readiness
/api
   ├─ GET                                          /api
   ├─ /health GET                                  /api/health
   └─ /notes
      ├─ GET                                       /api/notes
      ├─ POST                                      /api/notes
      ├─ /strict/{id} GET                          /api/notes/strict/{id}
      └─ /{id} GET                                 /api/notes/{id}

The tree confirms that the prefix api/notes/ combined with the path / becomes /api/notes, and that the parameter route is /api/notes/{id}. The /strict/{id} line is a second lookup route added near the end of this lesson; ignore it until then. The three routes at the top (/_health, /_ping, /_readiness) come from AppRoutes::with_default_routes(), which the generated Hooks::routes calls before your own controllers. A common misreading is to assume the trailing slash in the prefix leaves a trailing slash in the final URL. The table is the authority, so check it after every routing change instead of reasoning about string joins.

Path and Query: axum's extractors, axum's errors

An extractor is a type that implements Axum's request-parsing traits, so that putting it in a handler's argument list makes Axum parse that part of the request before your code runs. Path<u32> parses a URL segment into an integer, and Query<ListParams> parses the query string into a struct with serde.

The important fact is where these two types come from. Loco's prelude re-exports them from Axum with pub use axum::extract::{Form, Multipart, Path, Query, State}. They are plain Axum extractors, with no Loco code between them and the request. When they fail to parse, Axum builds the rejection response itself, and Loco's Error type never sees it. The test output shows what a client receives:

GET abc: 400 [text/plain; charset=utf-8] Invalid URL: Cannot parse `abc` to a `u32`
GET limit=lots: 400 [text/plain; charset=utf-8] Failed to deserialize query string: limit: invalid digit found in string

Both responses are status 400 with a text/plain body, and both bodies carry a detailed, human-readable reason. The handler body never ran for either request, which is the point of extractors: invalid data is rejected before it can reach your logic. A client that expects every error to be JSON will fail to parse these bodies, because they are sentences and not objects.

The common mistake is to test only the status code. Both responses pass an assertion of 400, and a client developer later finds that the body is not JSON. You spot this by asserting the content type alongside the status, which the test section below does.

Json: Loco's wrapper and the hidden reason

The Json<T> in Loco's prelude is a different type from Axum's. It is a Loco wrapper declared with #[from_request(via(axum::Json), rejection(Error))], which means it parses the body with Axum's Json and, if that fails, converts the rejection into Loco's Error::JsonRejection. Because the conversion goes through Error, the response is rendered by Loco as JSON. Loco does this so that body failures follow the same error path as the errors you return from handlers, which keeps one rendering point for them.

The conversion keeps Axum's status code but replaces the message:

POST missing field: 422 [application/json] {"error":"Bad Request"}
POST no json ctype: 415 [application/json] {"error":"Bad Request"}

A body with the wrong field name is 422, and a body sent without a JSON content type is 415. Both bodies are the same generic {"error":"Bad Request"}. The real reason, such as the missing field title, is only logged: Loco calls tracing::debug!(err = err.body_text(), "json rejection"). A client therefore learns the status but not the cause, unless the server runs with debug-level logging enabled and someone reads the log. Otherwise, the client's only recourse is guessing which field it got wrong.

The misreading here is to assume that "JSON error body" means "descriptive error body". The body is valid JSON, but for body rejections it carries no description. If your clients need field-level feedback, the Loco validation path described under Limits is the tool for it, since a generic 422 does not tell a form which input to highlight.

Returning your own errors (not_found, bad_request, status 201)

Handler-level errors are the third path. Loco defines bad_request as pub fn bad_request<T: Into<String>, U>(msg: T) -> Result<U> { Err(Error::BadRequest(msg.into())) } and not_found as pub fn not_found<T>() -> Result<T> { Err(Error::NotFound) }. Both return an Err carrying a Loco Error, and impl IntoResponse for Error turns that into a response whose body is an ErrorDetail. That struct has three optional fields, error, description and errors, and each is skipped in serialization when it is None. This is why different errors have bodies of different shapes without any custom code from you.

In the lab, the handler returns not_found() for an unknown id and bad_request("title must not be empty") for a blank title:

GET 99: 404 [application/json] {"error":"not_found","description":"Resource was not found"}
POST blank: 400 [application/json] {"error":"Bad Request","description":"title must not be empty"}

NotFound maps to 404 with error set to not_found, and BadRequest(msg) maps to 400 with your message in description. The internal error variants collapse to a 500 with the body {"error":"internal_server_error","description":"Internal Server Error"}, so that internal details never leak to clients. The match in Loco that does this has no _ fallback arm on purpose. Otherwise a variant added in a later release would silently inherit some default response, and the compiler would never ask anyone to decide what it should look like.

Success with a status other than 200 uses the render builder. format::json(t) returns a Result<Response> with status 200, while format::render() returns a builder whose .status(code) sets the status before .json(t) finishes the response. The create handler uses .status(201) because 201 tells the client that a resource was created. The test shows POST ok: 201 [application/json] {"id":4,"title":"ship it"}. The id is always 4 because the lab has no storage, so treat that value as a placeholder.

The mistake to avoid is validating the empty title by hand in every handler and returning a bare string or a custom status. Returning bad_request instead keeps the response in the same JSON shape as every other error that Loco renders, so a client needs one parser for handler errors.

Testing the contract in-process

A request test starts your application's router inside the test process and sends HTTP requests to it without opening a network port. The function loco_rs::testing::prelude::request::<App, _, _> takes a closure, boots the app, and gives the closure a test client and the application context. Nothing external has to be running, so the test is fast and repeatable. The #[serial] attribute from the serial_test crate makes tests that boot the app run one at a time rather than concurrently.

The lab test sends nine requests and asserts the status of each. Here is the shape of one check, with a small helper that prints status, content type and body so the transcript below can be read as a table:

fn show(label: &str, code: u16, ctype: &str, body: &str) {
    println!("{label}: {code} [{ctype}] {body}");
}

let r = request.get("/api/notes/abc").await;
show("GET abc", r.status_code().as_u16(), r.header("content-type").to_str().unwrap(), &r.text());
assert_eq!(r.status_code(), 400);

The request is built with request.get(path) and awaited, which yields a response with status_code(), header(name) and text() methods. The helper prints the content type next to the status, and that is how the difference between the plain-text and JSON failures became visible. The final line asserts only the status, so as written this test would pass whether the body were JSON or text. A test meant to pin the contract should also assert on r.header("content-type"), otherwise a later change from Axum's Query to a custom extractor would alter the format without failing anything. Running the test with cargo test --test mod notes -- --nocapture prints the full transcript:

GET 2: 200 [application/json] {"id":2,"title":"read the route table"}
GET 99: 404 [application/json] {"error":"not_found","description":"Resource was not found"}
GET abc: 400 [text/plain; charset=utf-8] Invalid URL: Cannot parse `abc` to a `u32`
GET limit=lots: 400 [text/plain; charset=utf-8] Failed to deserialize query string: limit: invalid digit found in string
POST ok: 201 [application/json] {"id":4,"title":"ship it"}
POST blank: 400 [application/json] {"error":"Bad Request","description":"title must not be empty"}
POST missing field: 422 [application/json] {"error":"Bad Request"}
POST no json ctype: 415 [application/json] {"error":"Bad Request"}
test requests::notes::notes_contract ... ok

Running the whole suite with cargo test passes four tests: the health and home tests that ship with a new app, this one, and the strict-route test from the next section.

Putting it together: one error format, by decision

Return to the opening problem. The app has three bad-input cases, and the transcript shows the formats: path and query failures are text sentences, body failures are JSON with a generic message, and handler errors are JSON with a useful description. Two formats in one API is the default outcome of mixing Axum's and Loco's extractors. You can choose that outcome on purpose instead of finding it in production.

If your clients can handle both formats, nothing needs to change, and the detailed text messages may even help during development. If they need a single format, take the raw input in a type that cannot fail to parse and do the conversion inside the handler, where a failure can become a Loco Error. Here is a second lookup route that takes the id as a String:

#[debug_handler]
pub async fn show_strict(Path(raw): Path<String>) -> Result<Response> {
    let Ok(id) = raw.parse::<u32>() else {
        return bad_request(format!("id must be a non-negative integer, got `{raw}`"));
    };
    match all().into_iter().find(|n| n.id == id) {
        Some(n) => format::json(n),
        None => not_found(),
    }
}

Path<String> accepts any segment that decodes to valid text, so a non-numeric id no longer trips Axum's parser and the handler runs. The let ... else line tries the parse and, when it fails, returns bad_request with a message that names the bad value. That failure now travels through Loco's Error and is rendered as the same JSON ErrorDetail as not_found. Note that u32 parsing accepts 0, which is why the message says non-negative rather than positive; a lookup for 0 falls through to not_found(). The route is registered with .add("/strict/{id}", get(show_strict)) in the same routes() function. If many handlers need this, a custom extractor that wraps Axum's Path and maps its rejection to Error::BadRequest gives the same result without repeating the parse; that variant is a design option and was not built here.

The request test is where the decision becomes a contract. This helper asserts the content type as well as the status, so a format change fails the build:

fn expect(label: &str, code: u16, got: &str, body: &str, status: u16, ctype: &str) {
    println!("{label}: {code} [{got}] {body}");
    assert_eq!(code, status, "{label} status");
    assert!(got.starts_with(ctype), "{label}: expected {ctype}, got {got}");
}

#[tokio::test]
#[serial]
async fn strict_path_errors_are_json() {
    request::<App, _, _>(|request, _ctx| async move {
        let r = request.get("/api/notes/strict/abc").await;
        expect("strict abc", r.status_code().as_u16(), r.header("content-type").to_str().unwrap(), &r.text(), 400, "application/json");
        let r = request.get("/api/notes/strict/2").await;
        expect("strict 2", r.status_code().as_u16(), r.header("content-type").to_str().unwrap(), &r.text(), 200, "application/json");
        let r = request.get("/api/notes/abc").await;
        // The un-strict route keeps axum's text/plain rejection; pin that fact too.
        expect("plain abc", r.status_code().as_u16(), r.header("content-type").to_str().unwrap(), &r.text(), 400, "text/plain");
    })
    .await;
}

The first run of this test asserted application/json for the original /api/notes/abc route as well, to check that the helper really catches a format mismatch. It did, and the test failed with this message:

plain abc: 400 [text/plain; charset=utf-8] Invalid URL: Cannot parse `abc` to a `u32`
thread 'requests::notes::strict_path_errors_are_json' (40547815) panicked at tests/requests/notes.rs:55:5:
plain abc: expected application/json, got text/plain; charset=utf-8

A status-only assertion would have passed that same request. The version above pins what each route really does, application/json for the strict route and text/plain for the original, and its run prints:

strict abc: 400 [application/json] {"error":"Bad Request","description":"id must be a non-negative integer, got `abc`"}
strict 2: 200 [application/json] {"id":2,"title":"read the route table"}
plain abc: 400 [text/plain; charset=utf-8] Invalid URL: Cannot parse `abc` to a `u32`
test requests::notes::strict_path_errors_are_json ... ok

Whichever format you choose, write it into a test like this one. Otherwise a later refactor that swaps an extractor will change what clients receive and no test will notice.

Limits

The lab is deliberately small, and a few things it does not cover matter in a real service. The notes live in a constant, so create does not store anything and always returns id 4. The custom-extractor variant of the fix is described, not built. Loco also ships a JsonValidate<T> extractor that uses the validator crate and maps validation failures to a 400 with an errors object, which is the right tool for field-level feedback, but this lesson does not run it. When none of the built-in helpers fits (a 409 Conflict, for example), the Loco error-handling guide shows returning Error::CustomError(StatusCode, ErrorDetail) directly, which keeps the JSON shape with a status you choose. Authentication, pagination beyond limit, and database-backed models are outside the scope. The behaviour and error bodies shown here come from loco-rs 1.2.0 on Axum 0.8, so check the transcript against your own version after an upgrade.

Check yourself

  1. A client posts {"name": "x"} to a route that expects {"title": "..."}. What status and body does it receive, and where can you find the real reason? *Answer: 422 with {"error":"Bad Request"}. The cause appears only in the server's debug-level log under "json rejection".*
  2. Why does GET /api/notes/abc return plain text while GET /api/notes/99 returns JSON? *Answer: Path is Axum's extractor, so its rejection is Axum's text response. The 404 comes from the handler returning not_found(), which Loco's Error renders as JSON.*
  3. What does format::render().status(201).json(..) do that format::json(..) does not? Answer: It sets a status other than 200 before serializing the body.
  4. A test asserts assert_eq!(r.status_code(), 400) for a bad path id. Why is that insufficient to protect an all-JSON error contract? Answer: A text/plain 400 and a JSON 400 both pass. The test must also assert the content-type header.

Sources