navree

Errors

The NestJS error format, AppError, mapping your own and database errors to statuses, and how 5xx errors and panics are logged.

On this page

Every error a Navree app sends has the same JSON body, the one NestJS uses: a 404 from a missing route, a 400 from validation, a 429 from the rate limiter, a 500 from a panic. Handlers return AppError; any other error type converts into a 500 with ?, and modules can map their error types to specific statuses. Internal details never reach the client: they go to the log.

Errors are part of navree-http (the http feature). AppError and Result are in the prelude.

The error format

404 Not Found
  • statusCode is the HTTP status.
  • message is the error's detail when it has one, else its title (the reason phrase by default). For validation errors it is a list of every field message.
  • error is the status's reason phrase.
  • errors appears on validation errors only and maps field paths to their messages:
400 Bad Request

Error responses are application/json with X-Content-Type-Options: nosniff. The request id is in the X-Request-Id header, never in the body. Members come in the NestJS order: statusCode, message, error, errors.

Statuses Navree sends itself

StatusWhenmessage
400invalid input (validation), malformed JSON, a path or query value of the wrong typefield messages, or the parser's explanation
401the route needs a user and there is none, or the credentials are invalidUnauthorized, or the auth module's message such as Invalid credentials
403access control refuses, or the CSRF check failsForbidden, Invalid CSRF token
404no route matches, or a record isn't foundNot Found
405the path exists for other methods (with an Allow header)Method Not Allowed
409a unique or foreign-key constraint failssee database errors
413the body is over http.body_limit_bytes (2 MiB by default) or the route's own limitThe request body must not exceed 2097152 bytes. when Content-Length declares it, else the extractor's explanation
415a JSON or form extractor gets another content typethe expected content type
429a rate limit is exceeded (with Retry-After)Too Many Requests, or the login throttle's Too many login attempts. Please try again in N seconds.
500an unexpected error or a panicInternal server error
503maintenance mode, or the request ran out of timethe maintenance message, or The server did not finish the request in time.

Navree itself never answers 422 or 419: input errors are 400s and CSRF failures are 403s, as in NestJS.

Returning errors

Handlers return Result<T>, which is Result<T, AppError>. Build the error with one of its constructors:

src/invoices/controller.rs
409 Conflict
ConstructorStatusmessage
AppError::bad_request("The export is empty.")400the text
AppError::unauthenticated()401Unauthorized
AppError::forbidden()403Forbidden
AppError::not_found()404Not Found
AppError::conflict("...")409the text
AppError::validation(map)400every field message, plus errors
AppError::payload_too_large(limit)413The request body must not exceed {limit} bytes.
AppError::too_many_requests(duration)429Too Many Requests, with Retry-After in whole seconds
AppError::service_unavailable("...")503the text
AppError::internal(error)500Internal server error; error is logged
AppError::from_status(StatusCode::PAYMENT_REQUIRED)anythe reason phrase (Payment Required)
AppError::new(status, code, title)anytitle

And the builder methods:

  • .with_detail("Only the owner can archive a project.") sets the message.
  • .with_header(name, value) adds a response header, such as WWW-Authenticate.
  • .with_source(error) records the cause. It's logged for 5xx responses and never sent.

AppError also has public fields (status, code, title, detail, errors, headers, source). code is a short machine-readable name (not_found, validation_failed) for logs, tests and error mappers; it isn't part of the response body.

Rust

The question mark operator

Any error type that converts into BoxError (every std::error::Error + Send + Sync, and strings) converts into an AppError with ?. It becomes a 500, like anyhow:

Rust
500 Internal Server Error

The error's message and its whole source chain go to the log (see logging server errors); the client sees only Internal server error. A database password in a driver message can't leak this way.

Mapping your own errors

To give your own error type a status, register a mapper in its module with map_error. When a 500 carries a source, Navree walks the source chain and, for each error in it, tries the mappers registered for that error's type. The first one that returns Some replaces the 500. A mapper that returns None passes the error on to the next mapper or the next error in the chain; when nothing matches, the response stays a 500.

src/billing/mod.rs

Now a handler propagates the error with ? and the client gets the right status:

Rust
402 Payment Required

Mappers apply to the whole app, whichever module registers them. They only see 500s that carry a source: an AppError you build with a status yourself is sent as it is.

Navree's own modules register mappers the same way: navree-auth turns its errors into 401s, 403s and 429s (Invalid credentials, Token expired), navree-access turns an unknown role into a 404, and navree-storage turns a missing file into a 404 and an invalid path into a 400.

Database errors

DatabaseModule maps SeaORM's DbErr, so ? on a query does the right thing:

DbErrResponse
RecordNotFound, RecordNotUpdated404 Not Found
a unique constraint violation409 A record with the same unique value already exists.
a foreign key constraint violation409 The change conflicts with a related record.
anything else500

No SQL text or driver message reaches the client. The mapping function is public as navree::db::map_db_error, so your own mapper can reuse it for an error type that wraps DbErr.

The unit of work rolls back whenever the response status is 400 or above, so a request that fails halfway leaves no partial writes.

An error raised while the unit of work commits, after your handler returned, goes through the same mapping: a foreign key that SQLite only checks at commit (DEFERRABLE INITIALLY DEFERRED), or a constraint violation from a change observer, is a 409 too. Any other commit failure is a 500. The handler's own response is discarded in both cases.

Framework errors

Errors from Axum and from Navree's own layers use the same format:

  • A path that matches no route is a 404, and a path that exists for other methods is a 405 with an Allow header.
  • Axum's extractor rejections become NestJS errors. Axum's 422 for well-formed JSON of the wrong shape becomes a 400, like every other input error; the message is Axum's explanation (it names the missing field).
  • Any other error response with an empty or text/plain body (up to 16 KiB) is rewritten into the format, so layers you add keep the shape. A JSON body you build yourself with an error status is left as it is.

Logging server errors

Every 5xx response is logged at ERROR level on the target navree::error, with the request's id, method and path, and the error chain: the status, the title, the detail and every source. This is the Gateway case of the billing example in the JSON log format (one line in the log, formatted here; the spans member is left out):

A navree::error log line

4xx responses aren't logged as errors: they're the client's mistakes. They show up in the request log line with their status.

Every 503 counts as a server error, so while the app is in maintenance mode each refused request also writes a request failed line.

Panics

A panicking handler doesn't take the server down. Navree catches the panic, answers the standard 500 body (the panic message is not sent), and logs it on navree::error with the message request handler panicked and the panic text in a panic field. Rust's default panic hook also prints the panic to stderr.

Catching needs unwinding, which is Rust's default. With panic = "abort" in your Cargo profile, a panic ends the process.

Timeouts

A request that runs longer than http.timeout_secs (30 seconds by default), or than its route's #[timeout("5s")] (see routing), is answered with a 503. The app-wide timeout logs request timed out at WARN on navree::request; a route's own timeout is logged like any other 5xx, on navree::error.

503 Service Unavailable

Error responses from panics, timeouts and the rate limiter still carry X-Request-Id and, for allowed origins, the CORS headers, so browser clients can read them.

Rendering an error yourself

AppError implements IntoResponse, so middleware, guards and handlers can return it as a response. error.body() gives the JSON body as a serde_json::Value, which is handy in tests and in messages to other systems:

Rust

Middleware you add with http_layer should answer errors with AppError, so they keep the format.

Testing

assert_error(status, message) checks the status, the content type, the statusCode, error and message members and the X-Request-Id header. Pass None to skip the message check:

tests/invoices.rs

See HTTP tests.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks