# error-envelope
[](https://github.com/blackwell-systems)
[](https://crates.io/crates/error-envelope)
[](https://docs.rs/error-envelope)
[](https://github.com/blackwell-systems/error-envelope/actions/workflows/ci.yml)
[](LICENSE)
[](https://buymeacoffee.com/blackwellsystems)
A tiny Rust crate for consistent HTTP error responses across services.
This is a Rust port of [`err-envelope` (Go)](https://github.com/blackwell-systems/err-envelope), providing feature parity with the Go implementation.
## Overview
- **Consistent error format**: One predictable JSON structure for all HTTP errors
- **Typed error codes**: 18 standard codes as a type-safe enum
- **Axum integration**: Implements IntoResponse for seamless API error handling
- **anyhow support**: Optional feature for From<anyhow::Error> conversion
- **Traceability**: Built-in support for trace IDs and retry hints
- **Minimal dependencies**: Framework-agnostic core with opt-in integrations
```rust
use axum::{extract::Path, Json};
use error_envelope::Error;
async fn get_user(Path(id): Path<String>) -> Result<Json<User>, Error> {
let user = db::find_user(&id).await?; // anyhow error converts automatically
Ok(Json(user))
}
```
## Table of Contents
- [Why](#why)
- [What You Get](#what-you-get)
- [Installation](#installation)
- [Crate Features](#crate-features)
- [Quick Start](#quick-start)
- [Anyhow Integration](#anyhow-integration)
- [Framework Integration](#framework-integration)
- [API Reference](#api-reference)
- [Error Codes](#error-codes)
- [Examples](#examples)
- [Testing](#testing)
- [Design Principles](#design-principles)
## Why
Without a standard, every endpoint returns errors differently:
- `{"error": "bad request"}`
- `{"message": "invalid email"}`
- `{"code": "E123", "details": {...}}`
This forces clients to handle each endpoint specially. `error-envelope` provides a single, predictable error shape.
## What You Get
```json
{
"code": "VALIDATION_FAILED",
"message": "Invalid input",
"details": {
"fields": {
"email": "must be a valid email"
}
},
"trace_id": "a1b2c3d4e5f6",
"retryable": false
}
```
Every field has a purpose: stable codes for logic, messages for humans, details for context, trace IDs for debugging, and retry signals for resilience.
**Rate limiting example:**
```json
{
"code": "RATE_LIMITED",
"message": "Too many requests",
"trace_id": "a1b2c3d4e5f6",
"retryable": true,
"retry_after": "30s"
}
```
The `retry_after` field (human-readable duration) appears when `with_retry_after()` is used.
## Installation
```toml
[dependencies]
error-envelope = "0.2"
```
With optional features:
```toml
[dependencies]
error-envelope = { version = "0.2", features = ["axum-support", "anyhow-support"] }
```
You can enable either or both features depending on your use case.
đź“– **Full API documentation**: [docs.rs/error-envelope](https://docs.rs/error-envelope)
## Crate Features
| `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 |
## Quick Start
```rust
use error_envelope::Error;
fn main() {
let err = Error::not_found("User not found")
.with_details(serde_json::json!({"user_id": "123"}))
.with_trace_id("abc-123");
println!("{}", serde_json::to_string_pretty(&err).unwrap());
}
```
## Anyhow Integration
With the `anyhow-support` feature, `anyhow::Error` automatically converts to `error_envelope::Error`:
```rust
use error_envelope::Error;
async fn handler() -> Result<String, Error> {
// anyhow::Error converts automatically via ?
let result = do_work().await?;
Ok(result)
}
fn do_work() -> anyhow::Result<String> {
anyhow::bail!("something went wrong");
}
```
This makes error-envelope a drop-in replacement for anyhow at HTTP boundaries:
```rust
use axum::{Json, Router, routing::get};
use error_envelope::Error;
async fn api_handler() -> Result<Json<Response>, Error> {
let data = fetch_data().await?; // anyhow error converts automatically
Ok(Json(Response { data }))
}
```
## Framework Integration
### Axum
With the `axum-support` feature, `Error` implements `IntoResponse`:
```rust
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)
```
## API Reference
### Common Constructors
```rust
use error_envelope::Error;
// Generic errors
Error::internal("Database connection failed"); // 500
Error::bad_request("Invalid JSON in body"); // 400
// Auth errors
Error::unauthorized("Missing token"); // 401
Error::forbidden("Insufficient permissions"); // 403
// Resource errors
Error::not_found("User not found"); // 404
Error::method_not_allowed("POST not allowed"); // 405
Error::request_timeout("Client timeout"); // 408
Error::conflict("Email already exists"); // 409
Error::gone("Resource permanently deleted"); // 410
Error::payload_too_large("Upload exceeds 10MB"); // 413
Error::unprocessable_entity("Invalid data format"); // 422
// Infrastructure errors
Error::rate_limited("Too many requests"); // 429
Error::unavailable("Service temporarily down"); // 503
Error::timeout("Database query timed out"); // 504
// Downstream errors
Error::downstream("payments", err); // 502
Error::downstream_timeout("payments", err); // 504
```
### Formatted Constructors
Use the `format!` macro for dynamic error messages:
```rust
use error_envelope::{not_foundf, internalf};
// Using format! macro
let user_id = 123;
let err = not_foundf(format!("user {} not found", user_id));
let db_name = "postgres";
let err = internalf(format!("database {} connection failed", db_name));
```
### Custom Errors
```rust
use error_envelope::{Error, Code};
use std::time::Duration;
// Low-level constructor
let err = Error::new(
Code::Internal,
500,
"Database connection failed"
);
// Add details
let err = err.with_details(serde_json::json!({
"database": "postgres",
"host": "db.example.com"
}));
// Add trace ID
let err = err.with_trace_id("abc123");
// Override retryable
let err = err.with_retryable(true);
// Set retry-after duration
let err = err.with_retry_after(Duration::from_secs(60));
```
### Builder Pattern
All `with_*` methods consume and return `Self`, enabling fluent chaining:
```rust
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));
```
The builder pattern is **immutable by default** in Rust (unlike the Go version which had to implement copy-on-modify).
## Error Codes
| `Internal` | 500 | No | Unexpected server errors |
| `BadRequest` | 400 | No | Malformed requests |
| `ValidationFailed` | 400 | No | Invalid input data |
| `Unauthorized` | 401 | No | Missing/invalid auth |
| `Forbidden` | 403 | No | Insufficient permissions |
| `NotFound` | 404 | No | Resource doesn't exist |
| `MethodNotAllowed` | 405 | No | Invalid HTTP method |
| `RequestTimeout` | 408 | Yes | Client timeout |
| `Conflict` | 409 | No | State conflict (duplicate) |
| `Gone` | 410 | No | Resource permanently deleted |
| `PayloadTooLarge` | 413 | No | Request body too large |
| `UnprocessableEntity` | 422 | No | Semantic validation failed |
| `RateLimited` | 429 | Yes | Too many requests |
| `Canceled` | 499 | No | Client canceled request |
| `Unavailable` | 503 | Yes | Service temporarily down |
| `Timeout` | 504 | Yes | Gateway timeout |
| `DownstreamError` | 502 | Yes | Upstream service failed |
| `DownstreamTimeout` | 504 | Yes | Upstream service timeout |
## Design Principles
**Minimal**: ~500 lines of code, no unnecessary dependencies.
**Framework-agnostic**: Works standalone; integrations are opt-in via features.
**Predictable**: Error codes are stable and semantically meaningful.
**Observable**: Built-in trace IDs and structured details for debugging and logging.
## Examples
See [`examples/axum_server.rs`](examples/axum_server.rs) for a complete Axum server demonstrating:
- Validation errors with field details
- Rate limiting with retry-after
- Downstream error handling
- Trace ID propagation
Run it:
```bash
cargo run --example axum_server --features axum-support
```
Test endpoints:
```bash
curl http://localhost:3000/user?id=123
curl http://localhost:3000/rate-limit
curl http://localhost:3000/validation
```
## Testing
```bash
cargo test --all-features
```
## License
MIT