Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file.
## [0.11.1] - 2025-10-27

### Documentation
- Added a multi-page error-handling wiki (`docs/wiki`) with beginner-friendly
walkthroughs, framework patterns, and comparisons against `thiserror` and
`anyhow`.
- Linked the wiki from the README template so crate consumers can discover it
directly on crates.io and docs.rs.
- Highlighted the native derive macros, typed telemetry, browser logging, and
Turnkey taxonomy across the README template and regenerated README.
- Refreshed the Russian README with the same capability summary and updated the
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`.
- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio`
- Turnkey domain taxonomy and helpers (`turnkey` feature)

👉 Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step
guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes.

---

### TL;DR
Expand Down
3 changes: 3 additions & 0 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`.
- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio`
- Turnkey domain taxonomy and helpers (`turnkey` feature)

👉 Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step
guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes.

---

### TL;DR
Expand Down
144 changes: 144 additions & 0 deletions docs/wiki/error-crate-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# When to use `thiserror`, `anyhow`, or `masterror`

Rust gives you multiple complementary error crates. This page compares how they
behave and shows concrete examples.

## Quick summary

| Crate | Primary goal | Typical usage stage |
|--------------|--------------------------------------------------|---------------------------------|
| `thiserror` | Define strongly typed domain errors with `derive`| Library and boundary layers |
| `anyhow` | Prototype quickly with dynamic context | CLI tools, experiments, glue |
| `masterror` | Ship stable API responses with rich metadata | Web backends, public interfaces |

You can mix them: `masterror` re-exports the derive macro so you keep using
`#[derive(Error)]`, and you can attach `anyhow::Error` as context on
`AppError`.

## Example: modelling a domain error

The following snippet derives a typed error using each crate.

```rust
use masterror::{AppCode, AppError, AppErrorKind, Error};
use serde::Deserialize;
use thiserror::Error as ThisError;

#[derive(Debug, Deserialize)]
struct Payload {
flag: bool,
}

#[derive(Debug, Error)]
#[error("invalid payload: {source}")]
#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::new("INVALID_PAYLOAD"))]
struct PayloadError {
#[from]
#[source]
source: serde_json::Error,
}

fn parse_with_masterror(input: &str) -> masterror::AppResult<Payload> {
let payload: Payload = serde_json::from_str(input).map_err(PayloadError::from)?;
Ok(payload)
}

#[derive(Debug, ThisError)]
#[error("invalid payload: {source}")]
struct PlainPayloadError {
#[from]
source: serde_json::Error,
}

fn parse_with_thiserror(input: &str) -> Result<Payload, PlainPayloadError> {
let payload = serde_json::from_str(input)?;
Ok(payload)
}

fn parse_with_anyhow(input: &str) -> Result<Payload, anyhow::Error> {
let payload = serde_json::from_str::<Payload>(input)
.map_err(|err| anyhow::anyhow!("invalid payload: {err}"))?;
Ok(payload)
}

fn convert_anyhow_into_masterror(err: anyhow::Error) -> AppError {
AppError::internal("unexpected parser failure").with_context(err)
}
```

Observations:

- `thiserror` focuses on ergonomic derives and string formatting, but it does
not impose how callers expose the error to clients.
- `anyhow` stores a dynamic error with a backtrace. It is ideal for prototyping
and small CLIs, but it does not convey HTTP status codes or machine-readable
metadata.
- `masterror` is opinionated about API boundaries. By using `#[app_error]`, the
domain error maps to a stable `AppErrorKind` and `AppCode` automatically.

## Mapping errors at service boundaries

Imagine an Axum handler that validates JSON, queries a database, and reaches an
external API. Each crate offers different trade-offs.

```rust
async fn handler_with_anyhow() -> Result<String, anyhow::Error> {
let payload = parse_with_anyhow("{ }")?;
Ok(format!("flag: {}", payload.flag))
}

async fn handler_with_thiserror() -> Result<String, PlainPayloadError> {
let payload = parse_with_thiserror("{ }")?;
Ok(format!("flag: {}", payload.flag))
}

async fn handler_with_masterror() -> masterror::AppResult<String> {
let payload = parse_with_masterror("{ }")?;
Ok(format!("flag: {}", payload.flag))
}
```

- The `anyhow` version surfaces a stringified error and a backtrace. Clients
receive HTTP 500 unless you write custom mapping logic.
- The `thiserror` version returns a typed error, but you still have to convert it
into an HTTP response yourself.
- The `masterror` version already contains the HTTP 400 classification, a stable
`AppCode`, and optional JSON details.

## Attaching context across crates

You can combine the strengths of each crate. Keep `thiserror` derives for rich
messages, wrap the result in `AppError`, and use `anyhow` for debug traces when
needed.

```rust
fn load_configuration(path: &std::path::Path) -> masterror::AppResult<String> {
let contents = std::fs::read_to_string(path).map_err(|err| {
AppError::internal("failed to read configuration")
.with_code(AppCode::new("CONFIG_IO"))
.with_context(anyhow::Error::from(err))
})?;
Ok(contents)
}
```

`AppError` stores the `anyhow::Error` internally without exposing it to clients.
You still emit clean JSON responses, while logs retain the full diagnostic
payload.

## Why choose `masterror`

1. **Stable contract.** `AppErrorKind` and `ErrorResponse` stay consistent across
services, making cross-team collaboration easier.
2. **Framework adapters.** Ready-to-use integrations with Axum, Actix Web,
`utoipa`, `serde_json`, and others remove boilerplate.
3. **Structured metadata.** Attach retry hints, authentication challenges, and
JSON details without building ad-hoc enums.
4. **Derive support.** Reuse the familiar `thiserror` syntax via
`masterror::Error` and augment it with `#[app_error]` rules.
5. **Context preservation.** Store source errors (including `anyhow::Error`) for
logging, while presenting sanitized messages externally.

Use `anyhow` when speed matters more than structure, `thiserror` when crafting
libraries, and `masterror` when you need predictable, well-documented API
responses.
35 changes: 35 additions & 0 deletions docs/wiki/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Masterror error-handling wiki

This wiki collects step-by-step guides for building reliable error handling in Rust services.
Each page is intentionally short and focused so you can jump straight to the
section that matches your experience level.

- [Rust error handling basics](rust-error-handling-basics.md)
- [Building applications with `masterror`](masterror-application-guide.md)
- [When to reach for `thiserror`, `anyhow`, or `masterror`](error-crate-comparison.md)
- [Patterns and troubleshooting](patterns-and-troubleshooting.md)

## How the wiki is organised

1. **Start with the basics** if you are new to `Result<T, E>` and the `?` operator.
2. **Follow the application guide** to design domain-specific error types with
consistent wire responses.
3. **Read the comparison** to understand how `masterror` complements `thiserror`
and `anyhow` instead of replacing them outright.
4. **Review patterns and troubleshooting** when you need concrete recipes for
mapping third-party errors, logging, and testing.

Each page contains runnable examples. Copy them into a new binary crate or an
`examples/` folder, run `cargo run`, and experiment.

## Related documentation

- [`README.md`](../README.md) and [`docs.rs/masterror`](https://docs.rs/masterror)
for API reference and feature lists.
- [`masterror-derive`](../../masterror-derive/README.md) to explore the derive
macro internals and advanced formatting capabilities.
- [`masterror-template`](../../masterror-template/README.md) for the shared
template parser used by the derive macros.

Feedback and suggestions are welcome — open an issue or discussion on
[GitHub](https://github.com/RAprogramm/masterror).
163 changes: 163 additions & 0 deletions docs/wiki/masterror-application-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Building applications with `masterror`

`masterror` provides a stable taxonomy for API-driven services. This page shows
how to define domain errors, propagate them through business logic, and surface
them as structured responses.

## Core types at a glance

- `AppErrorKind` categorises a failure (`BadRequest`, `Unauthorized`,
`Validation`, `Internal`, ...). Each kind maps to a conservative HTTP status.
- `AppCode` is an optional machine-readable identifier for your API clients.
- `AppError` bundles a kind, developer message, optional `AppCode`, optional
structured details, and retry/authentication hints.
- `AppResult<T>` is a convenient alias for `Result<T, AppError>`.

Use the helpers to construct errors without allocating intermediate `String`s.

```rust
use masterror::{AppError, AppErrorKind, AppResult};

pub fn ensure_flag(flag: bool) -> AppResult<()> {
if !flag {
return Err(AppError::bad_request("flag must be enabled"));
}
Ok(())
}

pub fn get_secret(flag: bool) -> AppResult<&'static str> {
ensure_flag(flag)?;
Ok("swordfish")
}
```

`AppError::bad_request` returns an HTTP 400 response. Other helpers include
`AppError::internal`, `AppError::timeout`, `AppError::unauthorized`, and more.

## Attaching codes and structured details

Attach machine-friendly metadata so clients can branch on errors without parsing
text.

```rust
use masterror::{AppCode, AppError};
use serde::Serialize;

#[derive(Debug, Serialize)]
struct MissingField<'a> {
field: &'a str,
}

pub fn parse_payload(json: &str) -> masterror::AppResult<&str> {
let payload: serde_json::Value = serde_json::from_str(json).map_err(|err| {
AppError::bad_request("payload must be valid JSON")
.with_code(AppCode::new("INVALID_JSON"))
.with_details(&MissingField { field: "feature_flag" })
.with_context(err)
})?;

payload
.get("feature_flag")
.and_then(|value| value.as_str())
.ok_or_else(|| {
AppError::bad_request("feature_flag string is required")
.with_code(AppCode::new("MISSING_FIELD"))
})
}
```

`with_context` stores the original `serde_json::Error` for logging; clients only
see the sanitized message, code, and JSON details.

## Deriving domain errors

Combine `masterror::Error` derive macros with `#[app_error]` to convert domain
errors into `AppError` automatically.

```rust
use masterror::{AppCode, AppErrorKind, Error};

#[derive(Debug, Error)]
#[error("database query failed: {source}")]
#[app_error(kind = AppErrorKind::Database, code = AppCode::new("DB_FAILURE"))]
pub struct DatabaseFailure {
#[from]
#[source]
source: sqlx_core::Error,
}

pub async fn load_user(pool: &sqlx_core::pool::PoolConnection<'_, sqlx_core::Postgres>)
-> Result<(), DatabaseFailure>
{
Err(sqlx_core::Error::RowNotFound)?;
Ok(())
}
```

Whenever `DatabaseFailure` is converted into `AppError`, the derived impl picks
`AppErrorKind::Database` and attaches the `DB_FAILURE` code. No manual mapping is
required in handlers.

## Framework integrations

Enable the relevant feature to integrate with HTTP frameworks:

- `axum`: `AppError` implements `IntoResponse` to emit JSON bodies that follow
`ErrorResponse` (status, code, message, optional details/retry info).
- `actix`: `AppError` implements `ResponseError` with the same JSON schema.
- `openapi`: `ErrorResponse` gains `utoipa::ToSchema` so your OpenAPI spec stays
in sync.

Example Axum handler:

```rust
use axum::{routing::get, Router};
use masterror::AppError;

async fn handler() -> masterror::AppResult<&'static str> {
Err(AppError::unauthorized("missing token"))
}

fn app() -> Router {
Router::new().route("/", get(handler))
}
```

Axum automatically converts the error into an HTTP 401 JSON payload.

## Logging and telemetry

`AppError` implements `std::error::Error`. Use `tracing` to log errors once, at
module boundaries (e.g., HTTP middleware or background task entry points).

```rust
fn log_error(err: &masterror::AppError) {
tracing::error!(kind = ?err.kind, code = ?err.code, "request failed");
if let Some(context) = err.context() {
tracing::debug!(?context, "captured error context");
}
}
```

Avoid logging the same error multiple times — the structured data already
contains everything needed for observability dashboards.

## Testing error behaviour

Write unit tests that assert on the `AppErrorKind`, optional `AppCode`, and the
serialised `ErrorResponse` payload.

```rust
#[test]
fn missing_field_is_bad_request() {
let err = parse_payload("{}").unwrap_err();
assert!(matches!(err.kind, AppErrorKind::BadRequest));
assert_eq!(err.code.unwrap().as_str(), "MISSING_FIELD");

let response: masterror::ErrorResponse = err.clone().into();
assert_eq!(response.status.as_u16(), 400);
}
```

Cloning is cheap because `AppError` stores data on the stack and shares context
via `Arc` under the hood. Use these assertions to guarantee stable APIs.
Loading
Loading