Responses
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Whatever a handler returns
must implement IntoResponse, a one-method trait that turns a value into an http::Response<axum::body::Body>. Because
so many types already implement it — including tuples that combine a status, headers and a body — most handlers
never build a Response by hand. Errors are a special case of responses and have their own page,
Error Handling.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), IntoResponse runs right after the handler
returns and turns its value into an http::Response. That response then travels back out through the middleware
added with Router::layer, the outer tower layers and hyper, which writes it to the connection. A streaming body
(SSE, files) keeps flowing after the handler has finished; rejections from extractors and errors from fallible layers
become responses through the same trait.
IntoResponse for common types
| Return type | Status | Notes |
|---|---|---|
|
200 |
empty body |
|
200 |
|
|
200 |
|
|
that status |
empty body |
|
200 |
|
|
200 |
|
|
303 / 307 / 308 |
|
|
204 |
added in 0.8, the self-describing form of |
|
200 |
|
|
200 |
a Server-Sent Events stream (see SSE & Streaming) |
|
as built |
full control |
|
200 |
any body, including a stream |
|
either |
the |
|
200 |
headers only |
|
— |
inserts |
Source: axum: Building responses
The three Redirect constructors map to the three redirect semantics: to (303 See Other) after a form POST, so the
browser follows with a GET; temporary (307) and permanent (308) preserve the method and body.
Tuples: status, parts, body
A tuple is a response when its last element implements IntoResponse and the elements before it implement
IntoResponseParts. Optionally, the first element can be a StatusCode (or an http::response::Parts), which
overrides the status. The allowed shapes are:
-
(StatusCode, B) -
(StatusCode, P1, ..., Pn, B)and(P1, ..., Pn, B)with up to 16 parts -
(http::response::Parts, P1, ..., B)and(http::Response<()>, P1, ..., B)to start from an existing response
Header arrays [(K, V); N], HeaderMap, TypedHeader<H>, cookie jars, Extension<T> and AppendHeaders are
response parts.
use axum::{
Json, Router,
http::{HeaderName, StatusCode, header},
response::{AppendHeaders, Html, IntoResponse, NoContent, Redirect},
routing::{delete, get, post},
};
use serde::Serialize;
#[derive(Serialize)]
struct Book {
isbn: &'static str,
title: &'static str,
}
// 201 Created + Location header + JSON body
async fn create_book() -> impl IntoResponse {
(
StatusCode::CREATED,
[(header::LOCATION, "/api/v1/books/9780441013593")],
Json(Book { isbn: "9780441013593", title: "Dune" }),
)
}
// several headers, one of them repeated
async fn cover() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")],
AppendHeaders([
(HeaderName::from_static("x-shelf"), "science-fiction"),
(HeaderName::from_static("x-shelf"), "classics"),
]),
"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
}
async fn remove_book() -> NoContent {
NoContent
}
async fn old_catalogue() -> Redirect {
Redirect::permanent("/catalogue")
}
async fn catalogue() -> Html<&'static str> {
Html("<h1>Catalogue</h1>")
}
fn app() -> Router {
Router::new()
.route("/api/v1/books", post(create_book))
.route("/api/v1/books/{isbn}", delete(remove_book))
.route("/covers/{isbn}", get(cover))
.route("/books.html", get(old_catalogue))
.route("/catalogue", get(catalogue))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: IntoResponseParts and AppendHeaders
$ curl -si -X POST localhost:3000/api/v1/books
HTTP/1.1 201 Created
content-type: application/json
location: /api/v1/books/9780441013593
content-length: 39
{"isbn":"9780441013593","title":"Dune"}
$ curl -sI localhost:3000/covers/1 | grep -i x-shelf
x-shelf: science-fiction
x-shelf: classics
Source: axum: IntoResponseParts and AppendHeaders
Header arrays insert (replacing an existing header of the same name), while AppendHeaders appends, which matters
for Set-Cookie, Link and Vary. Parts are applied left to right, after the body has set its own content-type,
so a header array can override the content type of a String or Json body. A header value that is not valid
("\n", for example) turns the response into a 500 rather than panicking.
Implementing IntoResponse for your types
Implement the trait for domain types that always map to the same response:
use axum::{
Json, Router,
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use serde::Serialize;
#[derive(Serialize)]
struct BookPage {
items: Vec<String>,
page: u32,
total_pages: u32,
}
impl IntoResponse for BookPage {
fn into_response(self) -> Response {
let link = format!("</api/v1/books?page={}>; rel=\"next\"", self.page + 1);
let has_next = self.page < self.total_pages;
let mut response = Json(self).into_response();
if has_next {
response.headers_mut().insert(header::LINK, link.parse().expect("valid header value"));
}
response
}
}
async fn list() -> BookPage {
BookPage { items: vec!["Dune".into()], page: 1, total_pages: 3 }
}
async fn teapot() -> Response {
// building a Response manually is always possible
Response::builder().status(StatusCode::IM_A_TEAPOT).body("no coffee here".into()).unwrap()
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/v1/books", get(list)).route("/coffee", get(teapot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: IntoResponse
Implement IntoResponseParts instead when a type should contribute headers or extensions to any response, such as
a RequestId that sets an x-request-id header.
Returning different response types
A function has one return type, so returning a Json on one branch and a StatusCode on another needs a common type.
The options, from most to least common:
use axum::{
Json, Router,
extract::Path,
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
use axum_extra::either::Either;
// 1. Result: success and error types both implement IntoResponse
async fn by_result(Path(id): Path<u32>) -> Result<Json<u32>, StatusCode> {
if id == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(Json(id)) }
}
// 2. Response: convert each branch with .into_response()
async fn by_response(Path(id): Path<u32>) -> Response {
match id {
0 => Redirect::to("/books").into_response(),
1 => Html("<p>Dune</p>").into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
// 3. Either: two (or up to eight) concrete types, no conversion calls
async fn by_either(Path(id): Path<u32>) -> Either<Html<&'static str>, Redirect> {
if id == 1 { Either::E1(Html("<p>Dune</p>")) } else { Either::E2(Redirect::to("/books")) }
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/a/{id}", get(by_result))
.route("/b/{id}", get(by_response))
.route("/c/{id}", get(by_either));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
impl IntoResponse is convenient but opaque: all branches must still return the same concrete type, because
impl Trait in return position stands for one hidden type, so mixing Html and Redirect under impl IntoResponse
fails with E0308 ("mismatched types"). The axum documentation also
notes that impl IntoResponse hides the type from tests and from #[debug_handler]; prefer concrete types in library
code.
ErrorResponse and response::Result
axum::response::Result<T> is Result<T, ErrorResponse>, where ErrorResponse can be created from any
IntoResponse with ? or .into(). It lets a handler use ? on several error types that already implement
IntoResponse without defining an enum:
use axum::{
Json, Router,
extract::Path,
http::StatusCode,
response::{ErrorResponse, Result},
routing::get,
};
fn parse_isbn(text: &str) -> Result<u64, (StatusCode, &'static str)> {
if text.len() == 13 { text.parse().map_err(|_| (StatusCode::BAD_REQUEST, "digits only")) } else { Err((StatusCode::BAD_REQUEST, "13 digits")) }
}
#[allow(clippy::result_large_err)] // ErrorResponse holds a whole Response (about 128 bytes)
async fn show(Path(isbn): Path<String>) -> Result<Json<u64>> {
let isbn = parse_isbn(&isbn)?; // (StatusCode, &str) -> ErrorResponse
if isbn % 2 == 0 {
return Err(ErrorResponse::from(StatusCode::NOT_FOUND));
}
Ok(Json(isbn))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/books/{isbn}", get(show));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: response::Result
Clippy’s result_large_err lint fires on such handlers because the error variant is a whole Response; that is
harmless here, but it is one more reason why production services use a typed AppError so that every failure is
logged and mapped
consistently (see Error Handling).
Bodies and streaming
axum::body::Body is axum’s single body type: a type-erased http_body::Body of Bytes frames. It can be built from
strings and bytes, from any other http_body::Body (Body::new), or from a Stream of Result<impl Into<Bytes>, E>
with Body::from_stream. A streamed body is sent with transfer-encoding: chunked (HTTP/1.1) as items arrive, so the
handler returns immediately and memory stays flat no matter how large the response is:
use std::convert::Infallible;
use std::time::Duration;
use axum::{Router, body::Body, http::header, response::IntoResponse, routing::get};
use futures::StreamExt;
use tokio_stream::wrappers::IntervalStream;
async fn export_catalogue() -> impl IntoResponse {
let titles = ["Dune", "The Dispossessed", "Kindred"];
// one CSV line every 100 ms -- imagine rows streamed from the database
let rows = IntervalStream::new(tokio::time::interval(Duration::from_millis(100)))
.zip(futures::stream::iter(titles))
.map(|(_, title)| Ok::<_, Infallible>(format!("{title}\n")));
([(header::CONTENT_TYPE, "text/csv")], Body::from_stream(rows))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/exports/titles.csv", get(export_catalogue));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: Body::from_stream
If the stream yields an Err, the connection is closed mid-response; the client sees a truncated body, not an error
status (the status was already sent). Validate everything that can fail before starting the stream. Streaming files,
NDJSON and SSE are covered on Server-Sent Events & Streaming
and File Uploads; middleware that needs a concrete body type can use
ResponseAxumBodyLayer (0.8.5) to map any body back to Body.
Conditional responses: ETag and If-None-Match
An ETag is a validator for one version of a resource. A client that has a cached copy sends it back in
If-None-Match; if the resource has not changed, the server answers 304 Not Modified with no body (RFC 9110,
section 13). Computing the tag from the representation’s bytes is the simplest correct approach:
use std::hash::{DefaultHasher, Hash, Hasher};
use axum::{
Router,
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
fn etag_for(body: &str) -> String {
let mut hasher = DefaultHasher::new();
body.hash(&mut hasher);
format!("\"{:016x}\"", hasher.finish()) // strong validator: quoted, no W/ prefix
}
async fn book(headers: HeaderMap) -> Response {
let body = r#"{"isbn":"9780441013593","title":"Dune","year":1965}"#;
let etag = etag_for(body);
let matches = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v == "*" || v.split(',').any(|t| t.trim().trim_start_matches("W/") == etag));
if matches {
return (StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response();
}
([(header::ETAG, etag.as_str()), (header::CONTENT_TYPE, "application/json")], body).into_response()
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/v1/books/9780441013593", get(book));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: RFC 9110: If-None-Match
$ curl -si localhost:3000/api/v1/books/9780441013593 | grep etag
etag: "a799a717c5539914"
$ curl -si -H 'if-none-match: "a799a717c5539914"' localhost:3000/api/v1/books/9780441013593 | head -1
HTTP/1.1 304 Not Modified
Source: RFC 9110: If-None-Match
DefaultHasher is fine for a demo but its output may change between Rust releases; use a stable hash (such as a SHA-256
prefix or the row’s version column) so tags survive deployments. For writes, the mirror image is If-Match, which
prevents lost updates — see JSON REST APIs. Static files get strong ETags from
tower-http 0.7’s ServeDir automatically.
Where the books are outdated
Yoon (Beginning Axum, chapter 2) returns StatusCode::NO_CONTENT for deletions; axum 0.8 adds NoContent as a
clearer alternative (both are fine). Code written before 0.8.5 may rely on Redirect::to panicking on an invalid URI;
it now produces a 500 response instead. Older material builds responses with axum::body::boxed and BoxBody, which
were replaced by axum::body::Body in 0.7.