error-envelope 0.2.2

Structured, consistent error responses for Rust APIs. Framework-agnostic with Axum support.
Documentation
# error-envelope

[![Blackwell Systems™](https://raw.githubusercontent.com/blackwell-systems/blackwell-docs-theme/main/badge-trademark.svg)](https://github.com/blackwell-systems)
[![Crates.io](https://img.shields.io/crates/v/error-envelope.svg)](https://crates.io/crates/error-envelope)
[![Docs.rs](https://docs.rs/error-envelope/badge.svg)](https://docs.rs/error-envelope)
[![CI](https://github.com/blackwell-systems/error-envelope/actions/workflows/ci.yml/badge.svg)](https://github.com/blackwell-systems/error-envelope/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Sponsor](https://img.shields.io/badge/Sponsor-Buy%20Me%20a%20Coffee-yellow?logo=buy-me-a-coffee&logoColor=white)](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

| 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 |

## 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

| Code | HTTP Status | Retryable | Use Case |
|------|-------------|-----------|----------|
| `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