Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, returns 400 Bad Request;
  • JSON and form bodies that exceed their configured limit return 413 Payload Too Large;
  • a malformed form Content-Length returns 411 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, return 500 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 every Error* helper;
  • WebError, an error that has already been converted for the domain;
  • ntex::util::Either<A, B>, when both A and B implement 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 return JsonPayloadError before 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);
}