diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a875ad..56d1f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 325449f..00d0fa3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.template.md b/README.template.md index 0d5097a..456a20b 100644 --- a/README.template.md +++ b/README.template.md @@ -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 diff --git a/docs/wiki/error-crate-comparison.md b/docs/wiki/error-crate-comparison.md new file mode 100644 index 0000000..2fb3c37 --- /dev/null +++ b/docs/wiki/error-crate-comparison.md @@ -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 { + 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 { + let payload = serde_json::from_str(input)?; + Ok(payload) +} + +fn parse_with_anyhow(input: &str) -> Result { + let payload = serde_json::from_str::(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 { + let payload = parse_with_anyhow("{ }")?; + Ok(format!("flag: {}", payload.flag)) +} + +async fn handler_with_thiserror() -> Result { + let payload = parse_with_thiserror("{ }")?; + Ok(format!("flag: {}", payload.flag)) +} + +async fn handler_with_masterror() -> masterror::AppResult { + 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 { + 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. diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..a482f71 --- /dev/null +++ b/docs/wiki/index.md @@ -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` 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). diff --git a/docs/wiki/masterror-application-guide.md b/docs/wiki/masterror-application-guide.md new file mode 100644 index 0000000..d936189 --- /dev/null +++ b/docs/wiki/masterror-application-guide.md @@ -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` is a convenient alias for `Result`. + +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. diff --git a/docs/wiki/patterns-and-troubleshooting.md b/docs/wiki/patterns-and-troubleshooting.md new file mode 100644 index 0000000..87a2249 --- /dev/null +++ b/docs/wiki/patterns-and-troubleshooting.md @@ -0,0 +1,126 @@ +# Patterns and troubleshooting + +This page collects recipes for common error-handling tasks along with debugging +strategies. + +## Mapping third-party errors + +Prefer typed conversions over string formatting. `masterror` exposes helper +constructors and feature-gated conversions. + +```rust +use masterror::{AppCode, AppError}; + +pub async fn fetch_user(client: &reqwest::Client) -> masterror::AppResult { + let response = client.get("https://example.com/user").send().await.map_err(|err| { + AppError::external_api("failed to reach user service") + .with_code(AppCode::new("UPSTREAM_HTTP")) + .with_context(err) + })?; + + response.text().await.map_err(|err| { + AppError::external_api("failed to decode response body").with_context(err) + }) +} +``` + +Enable the `reqwest` feature to classify timeouts and HTTP status codes +automatically. Similar conversions exist for `sqlx`, `redis`, `validator`, +`config`, and more. + +## Validating inputs + +Surface validation failures as structured data so clients can highlight fields. + +```rust +use masterror::{AppCode, AppError}; +use serde::Deserialize; +use validator::Validate; + +#[derive(Debug, Deserialize, Validate)] +struct CreateUser { + #[validate(length(min = 3))] + username: String, + + #[validate(email)] + email: String, +} + +pub fn validate(payload: &CreateUser) -> masterror::AppResult<()> { + payload.validate().map_err(|err| { + AppError::validation("invalid user payload") + .with_code(AppCode::new("VALIDATION_ERROR")) + .with_details(&err) + }) +} +``` + +`validator::ValidationErrors` implements `Serialize`, so it plugs directly into +`with_details`. + +## Emitting HTTP responses manually + +Sometimes you need to control the HTTP layer yourself (e.g., custom middleware). +Convert `AppError` into `ErrorResponse` and format it however you need. + +```rust +fn to_json(err: &masterror::AppError) -> serde_json::Value { + let response: masterror::ErrorResponse = err.clone().into(); + serde_json::json!({ + "status": response.status.as_u16(), + "code": response.code, + "message": response.message, + "details": response.details, + }) +} +``` + +The clone is cheap because `AppError` uses shared references for heavy context +objects. + +## Capturing reproducible logs + +1. Log errors at the boundary with `tracing::error!`, including `kind`, + `code`, and `retry` metadata. +2. Attach upstream errors via `with_context`. When you need additional metadata, + derive your error type with fields annotated using `#[provide]` from + `masterror::Error`. + +```rust +#[tracing::instrument(skip(err))] +fn log_for_support(err: &masterror::AppError) { + tracing::error!( + kind = ?err.kind, + code = ?err.code, + retry = ?err.retry, + auth = ?err.www_authenticate, + "request failed", + ); +} +``` + +`#[tracing::instrument]` captures spans automatically, so support teams can +reconstruct what happened. + +## Debugging common issues + +| Symptom | Checklist | +|---------|-----------| +| Validation failures return HTTP 500 | Enable the `validator` feature and expose handlers as `AppResult`. | +| JSON response lacks `code` | Call `.with_code(AppCode::new("..."))` or derive it via `#[app_error(code = ...)]`. | +| Logs show duplicated errors | Log once per request at the boundary; do not log again inside helpers. | +| `with_details` fails to compile | Ensure the value implements `Serialize` (derive or implement it manually). | +| Need to inspect nested errors | Call `err.context()` to retrieve captured sources, including `anyhow::Error`. | + +## Testing strategies + +- Unit-test constructors: assert on `AppErrorKind`, `AppCode`, retry hints, and + JSON serialisation. Use `serde_json::to_value` for comparisons. +- Integration-test HTTP handlers: send requests using `axum::Router` or + `actix_web::test::TestServer` and assert on status codes plus JSON bodies. +- Property-based tests (`proptest`) are great for validating validation logic and + parsing code — ensure the error surfaces the expected code even for extreme + inputs. + +Keep tests deterministic and avoid network calls; use mocks or in-memory +services instead. diff --git a/docs/wiki/rust-error-handling-basics.md b/docs/wiki/rust-error-handling-basics.md new file mode 100644 index 0000000..68f4fa1 --- /dev/null +++ b/docs/wiki/rust-error-handling-basics.md @@ -0,0 +1,108 @@ +# Rust error handling basics + +This page explains the building blocks of error handling in Rust. The goal is +to make the rest of the wiki easier to follow, even if you are new to the +language. + +## Terminology + +- **`Result`** is an enum with two variants: `Ok(T)` holds a successful + value, and `Err(E)` holds an error. Every function that can fail should return + a `Result`. +- **`?` operator** unwraps an `Ok` value or returns early with the `Err` variant. + It works with any `Result` or `Option` and is the primary way to propagate + errors. +- **`std::error::Error` trait** describes types that behave like errors. Most + libraries implement it for their failure types. Implementing `Error` allows + your type to integrate with logging, conversions, and `anyhow`. +- **`From`/`Into` conversions** are how one error turns into another. + When the `?` operator sees an `Err`, it uses `From` to convert between error + types automatically. + +## Writing a fallible function + +The following example downloads JSON from an in-memory HTTP server and parses a +field. It uses standard library errors and propagates them with `?`. + +```rust +use std::collections::HashMap; + +fn read_flag(data: &str) -> Result { + let payload: HashMap = serde_json::from_str(data)?; + let flag = payload + .get("feature_enabled") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + Ok(flag) +} + +fn parse_response(response: &str) -> Result { + let enabled = read_flag(response)?; + Ok(enabled) +} + +#[derive(Debug, thiserror::Error)] +#[error("failed to parse feature flag: {source}")] +pub struct ReadFlagError { + #[from] + source: serde_json::Error, +} + +fn main() -> Result<(), ReadFlagError> { + let json = r#"{ "feature_enabled": true }"#; + let flag = parse_response(json)?; + assert!(flag); + Ok(()) +} +``` + +Key observations: + +1. `read_flag` returns `Result` because JSON parsing can + fail. Nothing special is required — the compiler enforces handling the error. +2. `parse_response` returns a custom `ReadFlagError` that wraps the parsing + error. The `?` operator converts the JSON error into `ReadFlagError` via the + `#[from]` attribute. +3. `main` uses `Result` as its return type. If an error occurs, the program exits + with a non-zero status code and prints the error. + +## Recovering from errors + +Not every error should bubble up. Use `match`, `if let`, or helper methods to +inspect and recover when possible. + +```rust +fn recover_or_default(data: &str) -> bool { + match read_flag(data) { + Ok(flag) => flag, + Err(err) => { + tracing::warn!(error = %err, "invalid feature flag payload"); + false + } + } +} +``` + +Rust encourages explicit recovery paths, so code remains predictable even when a +failure happens. + +## Mapping one error into another + +Applications frequently hide implementation details behind domain-specific +errors. `map_err` is a lightweight way to translate errors without introducing +new types. + +```rust +fn read_flag_for_user(data: &str, user_id: u64) -> Result { + read_flag(data).map_err(|err| { + masterror::AppError::bad_request( + format!("user {user_id} sent invalid JSON: {err}"), + ) + }) +} +``` + +`map_err` receives the original error and lets you convert it into an +application-level type — here we produce an HTTP 400 error using `masterror`'s +helper. The next pages expand on this technique and show how to avoid allocating +new `String`s by using structured conversions.