Web Application Errors
Web applications often need a consistent error format, even when failures come
from reusable components or external libraries. ntex handles this through an
error domain. Each application state selects its domain with the
State::Error associated type, and every error that can reach the application
must implement WebResponseError for that domain.
The domain type is a marker: it names a set of rendering rules, and it does not have to be an error that handlers return. It can be the application’s own error enum, as in the example below, or a separate empty type.
This means handlers can return ordinary Result values instead of building
error responses by hand. The exact conversion point depends on where the error
comes from: extractor and handler errors become responses inside the route,
while errors returned by filters, middleware, and lower-level services remain
service errors until they leave the application stack.
DefaultError provides plain-text responses for common ntex errors. If your
API needs JSON bodies, extra headers, error codes, or other metadata, define a
custom error domain and choose how each error is rendered. The compiler checks
that every error used by the application has a renderer for the selected
domain, so a missing implementation prevents the application from compiling.
Default Error Handling
Every application state has an error type. You choose it through the State
trait:
use ntex::web;
#[derive(Clone)]
struct AppState;
impl web::State for AppState {
type Error = web::DefaultError;
}
Here, the application uses DefaultError. It already knows how to handle
common ntex errors. The response body is the error’s Display text, sent as
text/plain:
- malformed request data, such as invalid query strings, JSON or form bodies,
and a wrong
Content-Type, returns400 Bad Request; - JSON and form bodies that exceed their configured limit return
413 Payload Too Large; - a malformed form
Content-Lengthreturns411 Length Required; - path segments that fail to deserialize return
404 Not Found; - response serialization failures, such as a
Json<T>responder failing to encode its value, return500 Internal Server Error.
The Bytes and String extractors use their own PayloadError; its default
mapping is 400 Bad Request, including when the configured body limit is
exceeded.
If your application uses () or AppState<T> as its state, DefaultError
is selected automatically. In that case, there is usually nothing else to
configure. DefaultError is only a marker selecting these implementations; it
is not constructed at runtime, and it does not make every Rust error
renderable. A custom error returned by your code still needs a
WebResponseError<St, DefaultError> implementation or an InternalError
wrapper.
Returning Errors from Handlers
A handler can return Result just like any other async Rust function. ntex
uses the successful value as the response, or turns the error into an error
response.
For simple cases, the helpers in web::error let you choose an HTTP status
without defining a new error type:
use ntex::web::{self, App, InternalError};
async fn create_user(
) -> Result<&'static str, InternalError<&'static str>> {
Err(web::error::ErrorBadRequest("Invalid user"))
}
fn main() {
App::default().route(
"/users",
web::post().to(create_user),
);
}
web::error has 39 Error* helpers, one for each common 4xx and 5xx status,
such as ErrorBadRequest, ErrorUnauthorized, ErrorForbidden,
ErrorNotFound, ErrorConflict, and ErrorInternalServerError. Each returns
an InternalError<T>, which renders the given status with T’s Display text
as the body.
These helpers are convenient at an application boundary, but their body is public. Avoid wrapping database errors, tokens, internal paths, or other sensitive details directly. Log the original error and return a message that is safe for the client.
Under the hood, the successful value must implement Responder, while the
error must implement WebResponseError for the application’s domain. The
error is rendered as soon as the handler returns, inside the Result
responder.
Some error types implement WebResponseError for every domain, so they work
with DefaultError and custom domains alike:
InternalError<T>, including everyError*helper;WebError, an error that has already been converted for the domain;ntex::util::Either<A, B>, when bothAandBimplement it;std::convert::Infallible.
InternalError::from_response() is useful when a call site needs a completely
prepared response rather than a status and plain-text body:
use ntex::web::{self, HttpResponse, InternalError};
async fn create_user(
) -> Result<&'static str, InternalError<&'static str>> {
Err(InternalError::from_response(
"duplicate user",
HttpResponse::Conflict()
.header("x-error-code", "user-exists")
.body("A user with that name already exists"),
))
}
Custom Error Domains
For a larger application, you may want all errors to follow rules of your own.
Set State::Error to an application-specific type, then implement
WebResponseError for each error that the application can produce.
This gives you full control over how errors are rendered, including errors
from external components.
WebResponseError requires std::error::Error + 'static, so every error type
must implement the standard Error trait. The example below derives it with
the thiserror crate, which must be added to your Cargo.toml.
The following example returns 409 Conflict when a user already exists and
400 Bad Request when the request contains invalid JSON:
use ntex::web::{self, App, HttpResponse, WebResponseError, error::JsonPayloadError};
#[derive(Clone)]
struct AppState;
#[derive(Debug, thiserror::Error)]
enum ApiError {
#[error("User already exists")]
UserExists,
}
impl web::State for AppState {
type Error = ApiError;
}
impl WebResponseError<AppState, ApiError> for ApiError {
fn error_response(&self, _state: &AppState) -> HttpResponse {
HttpResponse::Conflict().body(self.to_string())
}
}
impl WebResponseError<AppState, ApiError> for JsonPayloadError {
fn error_response(&self, _state: &AppState) -> HttpResponse {
match self {
JsonPayloadError::Overflow => {
HttpResponse::PayloadTooLarge().body("Request body is too large")
}
JsonPayloadError::ContentType => {
HttpResponse::UnsupportedMediaType().body("Expected a JSON request")
}
_ => HttpResponse::BadRequest().body("Invalid JSON"),
}
}
}
#[derive(serde::Deserialize)]
struct User {
name: String,
}
async fn create_user(
user: web::types::Json<User>,
) -> Result<String, ApiError> {
if user.name == "admin" {
Err(ApiError::UserExists)
} else {
Ok(format!("Created {}", user.name))
}
}
fn main() {
App::<AppState>::new().route(
"/users",
web::post().to(create_user),
);
}
There are two possible errors to account for:
- the handler can return
ApiError; - the
Json<User>extractor can returnJsonPayloadErrorbefore the handler is called.
JsonPayloadError is defined by ntex, yet the application can still implement
WebResponseError<AppState, ApiError> for it. Rust’s orphan rules allow this
because AppState and ApiError are local types.
Selecting a custom domain replaces the DefaultError implementations for that
application. Add mappings for every extractor, responder, filter, middleware,
or service error the application uses. This is deliberate: it prevents an API
from silently falling back to a response format or status it did not choose.
Errors can also happen while ntex is creating the response. For example, if a
handler returns Json<T>, your error domain must know how to handle
serde_json::Error. A Form<T> responder similarly requires a mapping for
serde_urlencoded::ser::Error.
The same rule applies outside handlers. Errors from filters and middleware,
at the application, scope, or resource level, must also implement
WebResponseError for the domain. Unlike handler errors, they are wrapped in a
WebError and rendered when they reach the application service.
If a required conversion is missing, the route does not compile. This catches unhandled error cases while you are building the application rather than when a request arrives.
The error_response() method receives the application state, so it can use
shared configuration while building the response. Its default response is
500 Internal Server Error with the error’s Display text as a plain-text
body; override it to choose the status, headers, and body that make sense for
the error. For a JSON API, a response builder’s json() method can serialize a
small error-body struct here.
Where Error Responses Travel
Extractor errors, handler Result errors, and responder serialization errors
are converted while the route is running. They return through resource, scope,
and application middleware as ordinary responses, so response middleware can
inspect or modify them.
Filters, middleware services, and custom web services return errors through
the service layer. ntex type-erases those errors into WebError while they
unwind, then the outer application service calls error_response(). As a
result, middleware using ctx.call(...).await? sees an error, not the final
rendered response. If it must add headers or otherwise inspect that response,
it needs to catch the error and convert it itself.
For normal handlers, return Result<T, E> and let the responder handle the
conversion. Construct WebError directly only when writing lower-level web
services that use the service error path.
Test the Rendered Response
Error handling is part of the HTTP contract, so test the status, headers, and body rather than only testing the Rust error value:
use ntex::http::StatusCode;
use ntex::web::{self, App};
#[derive(Clone)]
struct AppState;
impl web::State for AppState {
type Error = web::DefaultError;
}
async fn create_user(
) -> Result<&'static str, web::InternalError<&'static str>> {
Err(web::error::ErrorConflict("User already exists"))
}
#[ntex::test]
async fn duplicate_user_response() {
let app = web::test::init_service_st(
AppState,
App::<AppState>::new().route(
"/users",
web::post().to(create_user),
),
)
.await;
let request = web::test::TestRequest::post()
.uri("/users")
.to_request();
let response = web::test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::CONFLICT);
}