Structured, traceable, retry-aware HTTP error responses for Rust APIs. Features anyhow and Axum integration with a framework-agnostic core.
- anyhow integration: Automatic conversion from anyhow::Error into error_envelope::Error at the HTTP boundary
- thiserror mapping: Implement From for explicit HTTP semantics (no accidental 500s)
- Axum support: Implements IntoResponse for seamless API error handling
- Consistent error format: One predictable JSON structure for all HTTP errors
- Typed error codes: 18 standard codes as a type-safe enum
- Traceability: Built-in support for trace IDs and retry hints
- Framework-agnostic core: Works standalone; integrations are opt-in via features
The stack: anyhow for propagation β error-envelope at the HTTP boundary β Axum via axum-support
use axum::{extract::Path, Json};
use error_envelope::{Error, validation};
use std::collections::HashMap;
#[derive(serde::Deserialize)]
struct CreateUser { email: String, age: u8 }
#[derive(serde::Serialize)]
struct User { id: String, email: String }
// Automatic conversion from anyhow:
async fn get_user(Path(id): Path<String>) -> Result<Json<User>, Error> {
// db::find_user is your app code (returns anyhow::Result<User>)
let user = db::find_user(&id).await?; // anyhow error converts automatically
Ok(Json(user))
}
// Structured validation errors:
async fn create_user(Json(data): Json<CreateUser>) -> Result<Json<User>, Error> {
let mut errors = HashMap::new();
if !data.email.contains('@') {
errors.insert("email".to_string(), "must be a valid email".to_string());
}
if data.age < 18 {
errors.insert("age".to_string(), "must be 18 or older".to_string());
}
if !errors.is_empty() {
return Err(validation(errors).with_trace_id("abc-123"));
}
Ok(Json(User { id: "123".to_string(), email: data.email }))
}
// On validation error, returns HTTP 400:
// {
// "code": "VALIDATION_FAILED",
// "message": "Invalid input",
// "details": {
// "fields": {
// "email": "must be a valid email",
// "age": "must be 18 or older"
// }
// },
// "trace_id": "abc-123",
// "retryable": false
// }- Why error-envelope
- Installation
- Quick Start
- Examples
- API Reference - Complete API documentation
- Error Codes - All 18 error codes with descriptions
APIs need a formal contract for errors. Without one, clients can't predict error structure:
{"error": "bad request"}String field, no structure.
{"message": "invalid", "code": 400}Different field names, ad-hoc.
{"errors": [{"field": "email"}]}Array structure, incompatible.
Every endpoint becomes a special case. error-envelope establishes a predictable contract: same structure, same fields, every time.
[dependencies]
error-envelope = "0.3"With optional features:
[dependencies]
error-envelope = { version = "0.3", features = ["axum-support", "anyhow-support"] }You can enable either or both features depending on your use case.
π Full API documentation: docs.rs/error-envelope
| Feature | Description |
|---|---|
default |
Core error envelope with no framework dependencies |
axum-support |
Adds IntoResponse implementation for Axum framework integration |
anyhow-support |
Enables From<anyhow::Error> conversion for seamless interop with anyhow |
use error_envelope::Error;
use std::time::Duration;
// Create errors with builder pattern:
let err = Error::rate_limited("too many requests")
.with_trace_id("abc-123")
.with_retry_after(Duration::from_secs(30));That's it. See the hero example above for Axum integration and validation patterns.
With the axum-support feature, Error implements IntoResponse:
use axum::{Json, routing::get, Router};
use error_envelope::Error;
async fn handler() -> Result<Json<User>, Error> {
let user = db::find_user("123").await?;
Ok(Json(user))
}
// Error automatically converts to HTTP response with:
// - Correct status code
// - JSON body with error envelope
// - X-Request-ID header (if trace_id set)
// - Retry-After header (if retry_after set)Common constructors for typical scenarios:
use error_envelope::Error;
// Most common
Error::internal("Database connection failed"); // 500
Error::not_found("User not found"); // 404
Error::unauthorized("Missing token"); // 401
Error::forbidden("Insufficient permissions"); // 403
// Validation
Error::bad_request("Invalid JSON"); // 400
use error_envelope::validation;
let err = validation(field_errors); // 400 with field details
// Infrastructure
Error::rate_limited("Too many requests"); // 429
Error::timeout("Query timeout"); // 504Builder pattern:
let err = Error::rate_limited("too many requests")
.with_details(serde_json::json!({"limit": 100}))
.with_trace_id("trace-123")
.with_retry_after(Duration::from_secs(30));π Full API documentation: API.md - Complete constructor reference, formatted helpers, advanced patterns
18 standard codes as a type-safe enum. Most common:
| Code | HTTP Status | Use Case |
|---|---|---|
Internal |
500 | Unexpected server errors |
NotFound |
404 | Resource doesn't exist |
Unauthorized |
401 | Missing/invalid auth |
ValidationFailed |
400 | Invalid input data |
Timeout |
504 | Gateway timeout (retryable) |
π Complete reference: ERROR_CODES.md - All 18 codes with detailed descriptions, use cases, and retryable behavior
Minimal, framework-agnostic core (~500 lines); integrations behind feature flags. See ARCHITECTURE.md for design rationale.
Complete working examples in the examples/ directory:
domain_errors.rs- Map thiserror domain errors to HTTP errors (From pattern)validation.rs- Field-level validation with structured error detailsrate_limiting.rs- Rate limiting with retry-after hintstracing.rs- Trace ID propagation through middlewareaxum_server.rs- Complete Axum server with all patterns
Run any example:
cargo run --example domain_errors --features axum-support
cargo run --example validation --features axum-support
cargo run --example rate_limiting --features axum-support
cargo run --example tracing --features axum-supportcargo test --all-featuresMIT