From ef502059ab3aa25a91412e01b1cb42f50e2b6873 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 12:25:39 +0700 Subject: [PATCH 1/2] #448 docs: add docs site infrastructure and English wiki --- .github/workflows/site.yml | 55 +++++++ .github/workflows/wiki.yml | 37 +++++ REUSE.toml | 10 ++ site/build.py | 173 +++++++++++++++++++++ site/landing.html | 95 ++++++++++++ wiki/Best-Practices-en.md | 162 +++++++++++++++++++ wiki/Context-and-Metadata-en.md | 182 ++++++++++++++++++++++ wiki/Derive-Macros-en.md | 259 +++++++++++++++++++++++++++++++ wiki/Error-Kinds-and-Codes-en.md | 172 ++++++++++++++++++++ wiki/Feature-Flags-en.md | 131 ++++++++++++++++ wiki/Getting-Started-en.md | 193 +++++++++++++++++++++++ wiki/Home-en.md | 131 ++++++++++++++++ wiki/Integrations-en.md | 200 ++++++++++++++++++++++++ wiki/Migration-en.md | 138 ++++++++++++++++ wiki/No-Std-en.md | 115 ++++++++++++++ wiki/Observability-en.md | 157 +++++++++++++++++++ wiki/Web-Frameworks-en.md | 209 +++++++++++++++++++++++++ wiki/_Footer.md | 12 ++ wiki/_Sidebar.md | 78 ++++++++++ 19 files changed, 2509 insertions(+) create mode 100644 .github/workflows/site.yml create mode 100644 .github/workflows/wiki.yml create mode 100644 REUSE.toml create mode 100644 site/build.py create mode 100644 site/landing.html create mode 100644 wiki/Best-Practices-en.md create mode 100644 wiki/Context-and-Metadata-en.md create mode 100644 wiki/Derive-Macros-en.md create mode 100644 wiki/Error-Kinds-and-Codes-en.md create mode 100644 wiki/Feature-Flags-en.md create mode 100644 wiki/Getting-Started-en.md create mode 100644 wiki/Home-en.md create mode 100644 wiki/Integrations-en.md create mode 100644 wiki/Migration-en.md create mode 100644 wiki/No-Std-en.md create mode 100644 wiki/Observability-en.md create mode 100644 wiki/Web-Frameworks-en.md create mode 100644 wiki/_Footer.md create mode 100644 wiki/_Sidebar.md diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 0000000..9100f2b --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +name: Docs Site + +on: + push: + branches: [main] + paths: ["wiki/**", "site/**", ".github/workflows/site.yml"] + pull_request: + paths: ["wiki/**", "site/**", ".github/workflows/site.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build site from wiki + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install mdBook + uses: taiki-e/install-action@v2 + with: + tool: mdbook@0.5.3 + + - name: Build site + run: python3 site/build.py --out "$RUNNER_TEMP/site" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: ${{ runner.temp }}/site + + deploy: + name: Deploy to GitHub Pages + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 0000000..51cbbd4 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +name: Publish Wiki + +on: + push: + branches: [main] + paths: ["wiki/**"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + name: Sync wiki/ to GitHub wiki + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Push wiki contents + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git clone "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.wiki.git" /tmp/wiki + rsync -a --delete --exclude .git wiki/ /tmp/wiki/ + cd /tmp/wiki + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Wiki already up to date" + exit 0 + fi + git commit -m "docs: sync wiki from ${GITHUB_SHA}" + git push diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..aa00082 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,10 @@ +version = 1 +SPDX-PackageName = "masterror" +SPDX-PackageSupplier = "RAprogramm " +SPDX-PackageDownloadLocation = "https://github.com/RAprogramm/masterror" + +[[annotations]] +path = ["wiki/**"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 RAprogramm " +SPDX-License-Identifier = "MIT" diff --git a/site/build.py b/site/build.py new file mode 100644 index 0000000..52e56d8 --- /dev/null +++ b/site/build.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +"""Build the multilingual documentation site from wiki/ with mdBook. + +Parses wiki/_Sidebar.md to discover languages and page order, generates one +mdBook per language, rewrites wiki-style links to book-relative links, and +assembles the final site with a landing page at the output root. + +Usage: python3 site/build.py --out +""" + +import argparse +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.parse import unquote + +REPO_URL = "https://github.com/RAprogramm/masterror" +WIKI_URL = REPO_URL + "/wiki/" +LANGUAGES = { + "English": "en", + "Русский": "ru", + "한국어": "ko", +} + +HOME_RE = re.compile(r"^\*\*\[(.+?)\]\((\S+?)\)\*\*$") +PART_RE = re.compile(r"^\*\*(.+?)\*\*$") +PAGE_RE = re.compile(r"^- \[(.+?)\]\((\S+?)\)$") +HEADING_RE = re.compile(r"^## (.+)$") +LINK_RE = re.compile(r"\]\(([^)\s]+)\)") + + +def slug_of(url): + return unquote(url.rsplit("/", 1)[-1]) + + +def parse_sidebar(text): + """Return {lang: [(kind, title, slug?), ...]} in sidebar order.""" + books = {} + current = None + for raw in text.splitlines(): + line = raw.strip() + heading = HEADING_RE.match(line) + if heading: + current = None + for name, code in LANGUAGES.items(): + if name in heading.group(1): + current = code + books[code] = [] + continue + if current is None: + continue + m = HOME_RE.match(line) + if m: + books[current].append(("home", m.group(1), slug_of(m.group(2)))) + continue + m = PART_RE.match(line) + if m: + books[current].append(("part", m.group(1), None)) + continue + m = PAGE_RE.match(line) + if m: + books[current].append(("page", m.group(1), slug_of(m.group(2)))) + return books + + +def rewrite_links(text, lang, slug_lang): + """Rewrite wiki links: same book -> page.md, other book -> ../lang/page.html.""" + + def repl(match): + target = match.group(1) + if target.startswith(WIKI_URL): + target = target[len(WIKI_URL):] + elif "://" in target or target.startswith(("#", "mailto:")): + return match.group(0) + slug, _, anchor = target.partition("#") + slug = unquote(slug) + if slug not in slug_lang: + return match.group(0) + suffix = "#" + anchor if anchor else "" + if slug_lang[slug] == lang: + return "](" + slug + ".md" + suffix + ")" + return "](../" + slug_lang[slug] + "/" + slug + ".html" + suffix + ")" + + return LINK_RE.sub(repl, text) + + +def book_toml(lang): + return ( + "[book]\n" + 'title = "masterror"\n' + 'language = "' + lang + '"\n' + 'src = "src"\n' + "\n" + "[output.html]\n" + 'site-url = "/masterror/' + lang + '/"\n' + 'git-repository-url = "' + REPO_URL + '"\n' + 'edit-url-template = "' + REPO_URL + '/edit/main/wiki/{path}"\n' + "\n" + "[output.html.playground]\n" + "runnable = false\n" + ) + + +def build_book(lang, items, wiki_dir, work_dir, out_dir, slug_lang): + book_dir = work_dir / lang + src_dir = book_dir / "src" + src_dir.mkdir(parents=True) + summary = ["# Summary", ""] + for kind, title, slug in items: + if kind == "home": + summary += ["[" + title + "](" + slug + ".md)", ""] + elif kind == "part": + summary += ["# " + title, ""] + else: + summary.append("- [" + title + "](" + slug + ".md)") + (src_dir / "SUMMARY.md").write_text("\n".join(summary) + "\n", encoding="utf-8") + for kind, _title, slug in items: + if kind == "part": + continue + text = (wiki_dir / (slug + ".md")).read_text(encoding="utf-8") + (src_dir / (slug + ".md")).write_text( + rewrite_links(text, lang, slug_lang), encoding="utf-8" + ) + (book_dir / "book.toml").write_text(book_toml(lang), encoding="utf-8") + subprocess.run( + ["mdbook", "build", "--dest-dir", str(out_dir / lang)], + cwd=book_dir, + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parent.parent + wiki_dir = repo_root / "wiki" + out_dir = args.out.resolve() + + books = parse_sidebar((wiki_dir / "_Sidebar.md").read_text(encoding="utf-8")) + missing = sorted(set(LANGUAGES.values()) - set(books)) + if missing: + sys.exit("languages missing from wiki/_Sidebar.md: " + ", ".join(missing)) + + slug_lang = { + slug: lang + for lang, items in books.items() + for kind, _title, slug in items + if kind != "part" + } + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + with tempfile.TemporaryDirectory() as work: + for lang, items in books.items(): + build_book(lang, items, wiki_dir, Path(work), out_dir, slug_lang) + + shutil.copy(repo_root / "site" / "landing.html", out_dir / "index.html") + shutil.copy(repo_root / "images" / "materror.png", out_dir / "logo.png") + print("site built at", out_dir) + + +if __name__ == "__main__": + main() diff --git a/site/landing.html b/site/landing.html new file mode 100644 index 0000000..5c370da --- /dev/null +++ b/site/landing.html @@ -0,0 +1,95 @@ + + + + + + + + +masterror — Documentation + + + + + +

masterror

+

Framework-agnostic application error types

+ + + + diff --git a/wiki/Best-Practices-en.md b/wiki/Best-Practices-en.md new file mode 100644 index 0000000..3f9654b --- /dev/null +++ b/wiki/Best-Practices-en.md @@ -0,0 +1,162 @@ +# Best Practices + +Patterns that keep `masterror`-based services predictable: typed domain +errors, one stable code taxonomy, transport mapping at the edge, and public +messages that never leak internals. + +## Derive domain errors, map them once + +Model each bounded context as an enum with `#[derive(Error)]` and declare the +`AppError` mapping inline with `#[app_error(...)]`. The derive generates +`Display`, `From<...>` for wrapped sources, and the conversion into +`AppError`/`AppCode` — no hand-written `match` at every call site. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +pub enum UserError { + #[error("user {0} not found")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] + NotFound(u64), + + #[error("email already registered")] + #[app_error(kind = AppErrorKind::Conflict, code = AppCode::Conflict, message)] + DuplicateEmail, + + #[error("storage failure")] + #[app_error(kind = AppErrorKind::Database, code = AppCode::Database)] + Storage(#[from] std::io::Error) +} + +let app: AppError = UserError::DuplicateEmail.into(); +assert_eq!(app.kind, AppErrorKind::Conflict); +``` + +Include `message` only on variants whose `Display` output is safe to show to +clients. Omit it (as on `Storage`) to keep the text internal — the client sees +only the kind's title. Full attribute reference: +[Derive Macros](Derive-Macros-en). + +## One `AppCode` taxonomy per service + +`AppCode` is your public API contract; clients branch on it. Keep the set +small, documented and per-service: + +- Prefer the built-in codes (`NOT_FOUND`, `CONFLICT`, `VALIDATION`, ...) — + they already carry canonical HTTP/gRPC/problem-type mappings. +- Mint custom codes centrally, not inline at call sites: + +```rust +use masterror::AppCode; + +pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED"); +``` + +`AppCode::new` is `const` and panics at compile time on anything that is not +SCREAMING_SNAKE_CASE; use `AppCode::try_new` for runtime strings. Renaming a +code is a breaking API change — treat additions like adding an enum variant. + +## Map to transports at the edge only + +Domain and service layers return `AppResult` and know nothing about HTTP. +The single `IntoResponse`/`ResponseError`/`Status` implementation in the crate +does the mapping in the handler layer: + +```rust,ignore +async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult { + repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database +} +``` + +Never hand-construct status codes in business logic and never implement a +second response conversion — the stable `AppErrorKind → status` table in +[Web Frameworks](Web-Frameworks-en) is the one source of truth. + +## Redact sensitive data, keep telemetry + +Two independent knobs: + +- **Message redaction** — `err.redactable()` (or `redact(message)` in + `#[masterror(...)]`) hides `detail` from wire payloads while logs keep it. +- **Field redaction** — per-field policy applied when metadata is serialized: + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::bad_request("Invalid credentials") + .with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash)) + .with_field(field::str("card", "4111111111111111").with_redaction(FieldRedaction::Last4)) + .with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact)); +``` + +`Hash` keeps correlational value (same input → same digest) without exposing +the raw string; `Last4` suits card/token suffixes; `Redact` removes the value +entirely. Default is `None`. Redact anything user-identifying by default and +opt out consciously, not the other way around. + +## `Context` vs derive + +- **Derive** when the error type is part of your domain vocabulary: it has + variants, appears in signatures, and its mapping is static. +- **`Context`** (via `ResultExt::ctx`) when wrapping an infrastructure error + ad hoc at a call site and the classification depends on the operation, not + the type: + +```rust +# #[cfg(feature = "std")] { +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_state() -> masterror::AppResult> { + std::fs::read("/var/lib/app/state.bin").ctx(|| { + Context::new(AppErrorKind::Internal) + .with(field::str("path", "/var/lib/app/state.bin")) + .track_caller() + }) +} +# } +``` + +`ctx` is lazy — the closure runs only on the error path. Use plain +`.context("message")` when a human-readable note is all you need. Details: +[Context & Metadata](Context-and-Metadata-en). + +## Test kinds and codes, not strings + +Assert on the stable taxonomy, never on formatted messages: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ProblemJson}; + +let err = AppError::not_found("user 42 missing"); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.code, AppCode::NotFound); + +let problem = ProblemJson::from_ref(&err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +- `ProblemJson::from_ref` lets integration tests assert the exact wire + contract without spinning up a server. +- `mapping_for_code(&code)` exposes the canonical HTTP status, gRPC code and + problem-type URI for table-driven tests. +- For redaction tests, assert `problem.detail.is_none()` on a `redactable()` + error and check `metadata().iter_with_redaction()` policies. + +## Public message vs internal telemetry + +A useful rule for every error you construct: + +| Channel | Contents | +|---|---| +| `message` / `detail` | Human-oriented, non-sensitive, stable enough to show a user | +| `Metadata` fields | IDs, attempts, endpoints, durations — for logs/metrics, with redaction policies | +| `source` chain | Raw underlying errors — logged, **never** serialized to clients | + +`masterror` enforces the last row (sources are never written to wire +payloads), but the first two are your responsibility: if a string contains +anything you would not print in a browser, put it in metadata with a +redaction policy or mark the error `redactable()`. + +See also: [Derive Macros](Derive-Macros-en) · [Context & Metadata](Context-and-Metadata-en) · [Error Kinds & Codes](Error-Kinds-and-Codes-en) · [Migration](Migration-en) diff --git a/wiki/Context-and-Metadata-en.md b/wiki/Context-and-Metadata-en.md new file mode 100644 index 0000000..605f786 --- /dev/null +++ b/wiki/Context-and-Metadata-en.md @@ -0,0 +1,182 @@ +# Context and Metadata + +`masterror` replaces string-glued context (`format!("failed to X: {e}")`) with three structured mechanisms: the `Context` builder, typed `Metadata` fields, and redaction policies enforced at the transport boundary. + +## ResultExt: promoting foreign errors + +`ResultExt` is implemented for every `Result` where `E: Error + Send + Sync + 'static` and offers two methods: + +### `.context(msg)` — anyhow-style + +Wraps the error with a message; the original error becomes the source: + +```rust +use masterror::ResultExt; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); +``` + +If the underlying error is already a `masterror::Error`, `.context()` preserves its classification: kind, code, metadata, edit policy, retry advice and details are carried over, only the message is replaced and the original error is kept as the source. + +### `.ctx(|| Context)` — full control + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn validate() -> Result<(), std::io::Error> { + Err(std::io::Error::other("boom")) +} + +let err = validate() + .ctx(|| { + Context::new(AppErrorKind::Validation) + .with(field::str("phase", "validate")) + .redact(true) + .track_caller() + }) + .unwrap_err(); + +assert_eq!(err.kind, AppErrorKind::Validation); +assert!(err.metadata().get("phase").is_some()); +``` + +The closure is only evaluated on the error path. + +## The Context builder + +| Method | Effect | +|---|---| +| `Context::new(kind)` | Target category; the `AppCode` defaults to the canonical mapping for that kind | +| `.code(AppCode)` | Override the public code | +| `.category(kind)` | Change the category; keeps the code in sync unless it was overridden | +| `.with(field)` | Attach a metadata `Field` | +| `.redact(bool)` | Toggle message redaction (`MessageEditPolicy::Redact` / `Preserve`) | +| `.redact_field(name, FieldRedaction)` | Override the redaction policy of a named field | +| `.track_caller()` | Record the call site as `caller.file`, `caller.line`, `caller.column` metadata | + +## Metadata fields + +`Metadata` is a sorted, inline-allocated map of typed fields (0–4 fields stay on the stack). Build fields with the `masterror::field` module: + +| Builder | `FieldValue` variant | +|---|---| +| `field::str("key", value)` | `Str(Cow<'static, str>)` | +| `field::i64("key", -1)` | `I64` | +| `field::u64("key", 42)` | `U64` | +| `field::f64("key", 0.5)` | `F64` | +| `field::bool("key", true)` | `Bool` | +| `field::uuid("key", uuid)` | `Uuid` | +| `field::duration("key", dur)` | `Duration` | +| `field::ip("key", addr)` | `Ip` (v4 or v6) | +| `field::json("key", json!({...}))` | `Json` (requires `serde_json` feature) | + +Attach fields when constructing errors or through `Context`: + +```rust +use core::time::Duration; +use masterror::{AppError, FieldValue, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::duration("elapsed", Duration::from_millis(1500))) + .with_field(field::u64("attempt", 2)); + +assert_eq!(err.metadata().len(), 3); +assert_eq!(err.metadata().get("attempt"), Some(&FieldValue::U64(2))); + +for (name, value) in err.metadata().iter() { + println!("{name}={value}"); +} +``` + +`with_fields(iter)` extends from an iterator, `with_metadata(meta)` replaces the container, and `Metadata::insert` returns the previous value when a key is overwritten. + +## Redaction policies + +### Message policy: `MessageEditPolicy` + +`Preserve` (default) keeps the public message; `Redact` tells transports to strip it. Set it with `.redactable()` on an error, `.redact(true)` on a `Context`, or `redact(message)` in `#[masterror(...)]`: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); +``` + +### Field policy: `FieldRedaction` + +Each field carries its own policy applied when metadata is serialized into `ProblemJson`: + +| Policy | Effect on the public payload | +|---|---| +| `None` | Value preserved as-is | +| `Redact` | Field removed entirely | +| `Hash` | Value replaced with a SHA-256 digest | +| `Last4` | All but the last four characters masked | + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::internal("payment failed") + .with_field(field::str("card_number", "4111111111111111")) + .redact_field("card_number", FieldRedaction::Last4); +``` + +Common secret-like names get a safe default automatically when the field is created: names containing `password`, `secret`, `authorization`, `cookie`, `session`, `jwt`, `bearer`, `otp`, `pin` default to `Redact`; token/key-like names (`api_token`, `refresh_token`, `key`, `apikey`) default to `Hash`; card/account segments combined with a number-like segment (`card_number`, `iban_no`, `account_id`) default to `Last4`. Detection is case-insensitive. Explicit `redact_field`/`with_redaction` always wins. + +## Error chains + +Errors keep their full causal chain. `chain()` iterates from the error itself down to the root cause; `root_cause()` jumps straight to the deepest error: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let app_err = AppError::internal("db down").with_context(io_err); + +let chain: Vec<_> = app_err.chain().collect(); +assert_eq!(chain.len(), 2); +assert_eq!(app_err.root_cause().to_string(), "disk offline"); +``` + +`with_context(...)` is the preferred way to attach an upstream error: it accepts owned errors or shared `Arc` values and reuses existing allocations. `with_source(...)` / `with_source_arc(...)` are the lower-level equivalents. + +## Downcasting + +Inspect the attached source with `is` and `downcast_ref`/`downcast_mut`: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let err = AppError::internal("boom").with_context(io_err); + +assert!(err.is::()); + +if let Some(io) = err.downcast_ref::() { + assert_eq!(io.to_string(), "disk offline"); +} +``` + +- `is::()` — `true` when the immediate source is of type `E` (does not walk the whole chain). +- `downcast_ref::()` — borrow the source as `E`. +- `downcast::()` / `downcast_mut::()` — currently stubs (`downcast` always returns `Err(self)`, `downcast_mut` always returns `None`), so prefer `downcast_ref`. + +For deeper matches, walk `chain()` and use `source.is::()` / `source.downcast_ref::()` on each element. + +## Backtraces + +With the `backtrace` feature, `err.backtrace()` returns a lazily captured `std::backtrace::Backtrace` honouring `RUST_BACKTRACE`, and `with_backtrace(bt)` attaches an explicit capture. Backtraces are shared via `Arc` when errors are re-wrapped, so `.context()` chains do not re-capture. + +--- + +See also: [Getting Started](Getting-Started-en) · [Error Kinds and Codes](Error-Kinds-and-Codes-en) · [Derive Macros](Derive-Macros-en) · [Observability](Observability-en) · [Best Practices](Best-Practices-en) diff --git a/wiki/Derive-Macros-en.md b/wiki/Derive-Macros-en.md new file mode 100644 index 0000000..73dd201 --- /dev/null +++ b/wiki/Derive-Macros-en.md @@ -0,0 +1,259 @@ +# Derive Macros + +`masterror` ships two derives via the bundled `masterror-derive` crate: + +- **`#[derive(Error)]`** — a drop-in replacement for `thiserror::Error` (same `#[error]`, `#[from]`, `#[source]`, `#[backtrace]` attributes) extended with `#[app_error(...)]` conversions and `#[provide(...)]` telemetry. +- **`#[derive(Masterror)]`** — builds on the same syntax and wires a domain error directly into `masterror::Error` with metadata, redaction policy and transport mapping tables via `#[masterror(...)]`. + +Both are re-exported from the root: `use masterror::{Error, Masterror};`. + +## `#[error("...")]` templates + +The template drives the generated `Display` implementation. Placeholders reference fields by name (`{field}`), tuple index (`{0}`) or explicit arguments. Parsing is handled by the shared `masterror-template` crate and mirrors `thiserror` semantics. + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{kind}: {message}")] +struct NamedError { + kind: &'static str, + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{0} -> {1:?}")] +struct TupleError(&'static str, u8); +``` + +### Formatter traits and specs + +Placeholders support the full formatter palette — `{x:?}`, `{x:#?}`, `{x:x}`, `{x:#X}`, `{x:b}`, `{x:o}`, `{x:e}`, `{x:E}`, `{x:p}` — and display-only specs such as `{value:>8}` or `{ratio:.3}` are forwarded verbatim. For programmatic template inspection, `masterror::error::template` exposes `ErrorTemplate`, `TemplateFormatter` and `TemplateFormatterKind`. + +### Format arguments and projections + +Templates accept named and positional arguments, including expressions on `self` and field projections with the `.field` shortcut: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{formatted}", formatted = self.message.to_uppercase())] +struct FormatArgExpressionError { + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{}, {label}, {}", label = self.label, self.first, self.second)] +struct MixedImplicitArgsError { + label: &'static str, + first: &'static str, + second: &'static str +} + +#[derive(Debug, Error)] +#[error("{value}", value = .value)] +struct FieldShortcutError { + value: &'static str +} +``` + +### `transparent` and `fmt = ...` + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("inner failure")] +struct Inner; + +// Forwards Display and source() to the single wrapped field +#[derive(Debug, Error)] +#[error(transparent)] +struct Wrapper(#[from] Inner); + +// Delegate rendering to a function: fields first, formatter last +fn render(count: &usize, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "count={count}") +} + +#[derive(Debug, Error)] +#[error(fmt = crate::render)] +struct CustomFormat { + count: usize +} +``` + +`transparent` requires exactly one field and cannot be combined with `fmt` or a template string. `fmt = path` points at a function receiving references to all fields plus the `Formatter`. + +## Field attributes + +| Attribute | Effect | +|---|---| +| `#[source]` | Field is returned from `source()`. `Option` is supported. | +| `#[from]` | Generates `From` for the wrapper; implies `#[source]` on the same field. | +| `#[backtrace]` | Field holds a `std::backtrace::Backtrace` (or `Option`) surfaced via error introspection, or delegates to the source's backtrace when combined with `#[source]`. | + +Inference: a field literally named `source` is treated as the source automatically, and a field of type `std::backtrace::Backtrace` (or `Option`) is picked up as the backtrace without an attribute. + +Enums accept per-variant `#[error]` and per-variant `#[from]`/`#[source]`/`#[backtrace]`: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("leaf failure")] +struct LeafError; + +#[derive(Debug, Error)] +enum EnumError { + #[error("unit failure")] + Unit, + #[error("{code}")] + Code { + code: u16, + #[source] + cause: LeafError + }, + #[error(transparent)] + Wrapped(#[from] LeafError) +} +``` + +## `#[app_error(...)]` — conversions into AppError + +Records how the derived error translates into `AppError`/`AppCode`. Options: `kind` (required), `code` (optional), `message` (flag). + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); + +let code: AppCode = MissingFlag { name: "other" }.into(); +assert_eq!(code, AppCode::BadRequest); +``` + +- `kind = ...` selects the `AppErrorKind`; generates `From for AppError`. +- `code = ...` additionally generates `From for AppCode`. +- `message` forwards the `Display` output as the public message; omit it to keep the message internal. + +Enums choose a mapping per variant, and the derive still emits a single `From for AppError`. + +## `#[provide(...)]` — typed telemetry + +Exposes typed context through `std::error::Request` (nightly `error_generic_member_access`; compiled in automatically when available). `Option` fields only register a provider when populated: + +```rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64 +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot +} +``` + +Consumers extract the snapshot with `std::error::request_ref::(&err)` on the domain error. + +## `#[derive(Masterror)]` — end-to-end domain errors + +`#[derive(Masterror)]` generates `Display`, `std::error::Error`, `From for masterror::Error` **and** compile-time transport mapping tables, all configured by one `#[masterror(...)]` attribute: + +```rust +use masterror::{ + AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +``` + +### `#[masterror(...)]` options + +| Option | Meaning | +|---|---| +| `code = AppCode::...` | Public machine-readable code | +| `category = AppErrorKind::...` | Semantic category (drives HTTP status) | +| `message` | Expose the formatted `Display` output as the safe public message | +| `redact(message)` | Set `MessageEditPolicy::Redact` so transports strip the message | +| `redact(fields("name" = hash, "card" = last4))` | Override per-field metadata policies: `hash`, `last4`, `redact`, `none` | +| `telemetry(expr, ...)` | Expressions evaluating to `Option`; populated fields are inserted into `Metadata`. Use `telemetry()` for none | +| `map.grpc = ` | gRPC status code (matches `tonic::Code` discriminants) | +| `map.problem = ""` | RFC 7807 `type` URI | + +### Generated mapping tables + +For structs the derive emits associated constants; for enums it emits an array and slices aggregating the per-variant mappings: + +| Shape | Constants | +|---|---| +| Struct | `T::HTTP_MAPPING: HttpMapping`, `T::GRPC_MAPPING: Option`, `T::PROBLEM_MAPPING: Option` | +| Enum | `T::HTTP_MAPPINGS: [HttpMapping; N]`, `T::GRPC_MAPPINGS: &'static [GrpcMapping]`, `T::PROBLEM_MAPPINGS: &'static [ProblemMapping]` | + +The descriptor types live in `masterror::mapping` (`HttpMapping::status()` derives the HTTP code from the kind; `GrpcMapping::status()` returns the `i32`; `ProblemMapping::type_uri()` returns the URI). + +`#[from]`, `#[source]` and `#[backtrace]` keep working under `#[derive(Masterror)]`; sources and captured backtraces are attached to the resulting `masterror::Error` automatically, and `Arc`-wrapped sources are reused without extra cloning. + +## Choosing between the derives + +| Need | Use | +|---|---| +| `Display` + `source` + `From`, thiserror-style | `#[derive(Error)]` | +| Also convert into `AppError`/`AppCode` | `#[derive(Error)]` + `#[app_error(...)]` | +| Typed context via `std::error::Request` | add `#[provide(...)]` | +| Metadata, redaction policy, gRPC/problem+json tables | `#[derive(Masterror)]` + `#[masterror(...)]` | + +--- + +See also: [Getting Started](Getting-Started-en) · [Error Kinds and Codes](Error-Kinds-and-Codes-en) · [Context and Metadata](Context-and-Metadata-en) · [Migration](Migration-en) diff --git a/wiki/Error-Kinds-and-Codes-en.md b/wiki/Error-Kinds-and-Codes-en.md new file mode 100644 index 0000000..a876218 --- /dev/null +++ b/wiki/Error-Kinds-and-Codes-en.md @@ -0,0 +1,172 @@ +# Error Kinds and Codes + +Two types form the backbone of the taxonomy: + +- **`AppErrorKind`** — the *internal*, semantic category of a failure. Small, stable, framework-agnostic. Controls the default HTTP status. +- **`AppCode`** — the *public*, machine-readable code exposed to clients as a SCREAMING_SNAKE_CASE string (e.g. `"NOT_FOUND"`). Part of the wire contract. + +Every `AppError` carries both. `AppCode::from(kind)` gives the canonical 1:1 mapping, and `AppError::with_code(...)` overrides the public code without changing the category. + +## AppErrorKind taxonomy + +| Variant | Meaning | HTTP | +|---|---|---| +| `NotFound` | Resource does not exist or is not visible to the caller | 404 | +| `Validation` | Structured input failed validation | 422 | +| `Conflict` | State conflict (unique key violation, version mismatch) | 409 | +| `Unauthorized` | Authentication required or failed | 401 | +| `Forbidden` | Authenticated but not allowed | 403 | +| `NotImplemented` | Operation not supported by this deployment | 501 | +| `BadRequest` | Malformed request or missing parameters | 400 | +| `TelegramAuth` | Telegram authentication flow failed | 401 | +| `InvalidJwt` | JWT expired, malformed or has wrong signature/claims | 401 | +| `RateLimited` | Client exceeded rate limits or quota | 429 | +| `Timeout` | Operation did not complete in time | 504 | +| `Network` | Network-level error (DNS, connect, TLS) | 503 | +| `DependencyUnavailable` | External dependency down or degraded | 503 | +| `Internal` | Unexpected server-side failure | 500 | +| `Database` | Database failure (query, connection, migration) | 500 | +| `Service` | Generic service-layer/business-logic failure | 500 | +| `Config` | Missing or invalid configuration | 500 | +| `Turnkey` | Turnkey subsystem failure | 500 | +| `Serialization` | Failed to encode data | 500 | +| `Deserialization` | Failed to decode data | 500 | +| `ExternalApi` | Upstream API returned an error | 500 | +| `Queue` | Queue publish/consume/ack failure | 500 | +| `Cache` | Cache read/write/encoding failure | 500 | + +```rust +use masterror::AppErrorKind; + +let kind = AppErrorKind::NotFound; +assert_eq!(kind.http_status(), 404); // always available, u16 +assert_eq!(kind.label(), "Not found"); // human-readable title +// With the `axum` feature: kind.status_code() -> axum::http::StatusCode +``` + +Design rules baked into the mapping: infrastructure and I/O issues default to 5xx; `Unauthorized` (401) means authentication failed, `Forbidden` (403) means authentication succeeded but access was denied; use `Network` for connect/build failures and `ExternalApi` for upstream HTTP status errors. + +## AppCode + +`AppCode` ships constants matching every kind (`AppCode::NotFound` → `"NOT_FOUND"`, `AppCode::RateLimited` → `"RATE_LIMITED"`, …) plus `AppCode::UserAlreadyExists` (`"USER_ALREADY_EXISTS"`, mapped as a conflict). It is `#[non_exhaustive]` and supports caller-defined codes: + +```rust +use std::str::FromStr; +use masterror::AppCode; + +// Compile-time literal — panics at compile-time evaluation if not SCREAMING_SNAKE_CASE +const INVALID_JSON: AppCode = AppCode::new("INVALID_JSON"); + +// Runtime value — validated, returns Result +let dynamic = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code"); +assert_eq!(dynamic.as_str(), "THIRD_PARTY_FAILURE"); + +// Parsing round-trips through the same validation +let parsed = AppCode::from_str("NOT_FOUND").expect("known code"); +assert_eq!(parsed, AppCode::NotFound); +``` + +Valid codes contain only `A-Z`, `0-9` and single `_` separators, and serialize as plain JSON strings. + +## HTTP / gRPC / problem+json mapping table + +`CODE_MAPPINGS` (and the `mapping_for_code` lookup) define the canonical transport mapping for every built-in code. Unknown custom codes fall back to `INTERNAL` (500 / gRPC 13): + +| AppCode | HTTP | gRPC | problem `type` | +|---|---|---|---| +| `NOT_FOUND` | 404 | `NOT_FOUND` (5) | `https://errors.masterror.rs/not-found` | +| `VALIDATION` | 422 | `INVALID_ARGUMENT` (3) | `.../validation` | +| `CONFLICT` | 409 | `ALREADY_EXISTS` (6) | `.../conflict` | +| `USER_ALREADY_EXISTS` | 409 | `ALREADY_EXISTS` (6) | `.../user-already-exists` | +| `UNAUTHORIZED` | 401 | `UNAUTHENTICATED` (16) | `.../unauthorized` | +| `FORBIDDEN` | 403 | `PERMISSION_DENIED` (7) | `.../forbidden` | +| `NOT_IMPLEMENTED` | 501 | `UNIMPLEMENTED` (12) | `.../not-implemented` | +| `BAD_REQUEST` | 400 | `INVALID_ARGUMENT` (3) | `.../bad-request` | +| `RATE_LIMITED` | 429 | `RESOURCE_EXHAUSTED` (8) | `.../rate-limited` | +| `TELEGRAM_AUTH` | 401 | `UNAUTHENTICATED` (16) | `.../telegram-auth` | +| `INVALID_JWT` | 401 | `UNAUTHENTICATED` (16) | `.../invalid-jwt` | +| `INTERNAL` | 500 | `INTERNAL` (13) | `.../internal` | +| `DATABASE` | 500 | `INTERNAL` (13) | `.../database` | +| `SERVICE` | 500 | `INTERNAL` (13) | `.../service` | +| `CONFIG` | 500 | `INTERNAL` (13) | `.../config` | +| `TURNKEY` | 500 | `INTERNAL` (13) | `.../turnkey` | +| `TIMEOUT` | 504 | `DEADLINE_EXCEEDED` (4) | `.../timeout` | +| `NETWORK` | 503 | `UNAVAILABLE` (14) | `.../network` | +| `DEPENDENCY_UNAVAILABLE` | 503 | `UNAVAILABLE` (14) | `.../dependency-unavailable` | +| `SERIALIZATION` | 500 | `INTERNAL` (13) | `.../serialization` | +| `DESERIALIZATION` | 500 | `INTERNAL` (13) | `.../deserialization` | +| `EXTERNAL_API` | 500 | `UNAVAILABLE` (14) | `.../external-api` | +| `QUEUE` | 500 | `UNAVAILABLE` (14) | `.../queue` | +| `CACHE` | 500 | `UNAVAILABLE` (14) | `.../cache` | + +gRPC values match `tonic::Code` discriminants, so the `tonic` feature converts directly. + +```rust +use masterror::{AppCode, mapping_for_code}; + +let mapping = mapping_for_code(&AppCode::Timeout); +assert_eq!(mapping.http_status(), 504); +assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); +assert_eq!(mapping.grpc().value, 4); +assert_eq!(mapping.problem_type(), "https://errors.masterror.rs/timeout"); +``` + +## Retry and authentication hints + +Transport adapters translate two optional hints into HTTP headers: + +```rust +use std::time::Duration; +use masterror::{AppError, AppErrorKind, ProblemJson}; + +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_secs(30) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); + +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); // -> Retry-After header +assert!(problem.www_authenticate.is_some()); // -> WWW-Authenticate header +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); +``` + +On `ErrorResponse` the equivalent builders are `with_retry_after_secs`, `with_retry_after_duration` and `with_www_authenticate`. + +## Redaction semantics + +`AppError` messages are meant to be safe for clients, but you can mark an error as redactable so the boundary strips it: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("host db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); // message stripped +assert!(problem.metadata.is_none()); // metadata stripped too +``` + +When `edit_policy` is `Redact`, `ProblemJson` drops `detail`, `details` and the entire `metadata` section. Individual metadata fields additionally carry their own `FieldRedaction` policy (`None`, `Redact`, `Hash`, `Last4`) applied during serialization — see [Context and Metadata](Context-and-Metadata-en). Error sources (`source_ref()`) are never serialized regardless of policy. + +## Wire payloads + +**`ProblemJson`** — RFC 7807 `application/problem+json`, produced by `ProblemJson::from_app_error` (owned) or `ProblemJson::from_ref` (borrowed). Fields: `type`, `title` (kind label), `status`, `detail`, optional `details`, `code`, `grpc` (`{name, value}`), `metadata`, plus non-serialized `retry_after`/`www_authenticate` for headers. + +**`ErrorResponse`** — legacy flat JSON payload: `status`, `code`, `message`, optional `details`, `retry`, `www_authenticate`. With the `openapi` feature it derives `utoipa::ToSchema`. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ErrorResponse}; + +let app_err = AppError::new(AppErrorKind::NotFound, "user_not_found"); +let resp: ErrorResponse = (&app_err).into(); +assert_eq!(resp.status, 404); +assert_eq!(resp.code, AppCode::NotFound); +``` + +Prefer `ProblemJson` for new APIs; `ErrorResponse` remains for services already committed to the flat shape. + +--- + +See also: [Getting Started](Getting-Started-en) · [Derive Macros](Derive-Macros-en) · [Context and Metadata](Context-and-Metadata-en) · [Web Frameworks](Web-Frameworks-en) diff --git a/wiki/Feature-Flags-en.md b/wiki/Feature-Flags-en.md new file mode 100644 index 0000000..154c1f0 --- /dev/null +++ b/wiki/Feature-Flags-en.md @@ -0,0 +1,131 @@ +# Feature Flags + +`masterror` keeps the default build lean: only `std` is enabled out of the box. Everything else — web transports, telemetry, library integrations — is opt-in. This page is the complete reference for every flag declared in `Cargo.toml`. + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +# or with features: +# masterror = { version = "0.28", features = [ +# "std", "axum", "actix", "openapi", +# "serde_json", "tracing", "metrics", "backtrace", +# "colored", "sqlx", "sqlx-migrate", "reqwest", +# "redis", "validator", "config", "tokio", +# "multipart", "teloxide", "init-data", "tonic", +# "frontend", "turnkey", "benchmarks" +# ] } +``` + +## Core + +| Flag | What it enables | Extra deps | +|---|---|---| +| `std` *(default)* | Standard library support; required by all runtime integrations. Disable for `no_std` (see [No-Std](No-Std-en)) | — | + +## Web transports + +| Flag | What it enables | Extra deps | +|---|---|---| +| `axum` | `IntoResponse` for `AppError` and `ProblemJson` with RFC 7807 JSON bodies; `AppErrorKind::status_code()` | `axum` (json, multipart), `serde_json` | +| `actix` | Actix Web `ResponseError` for `AppError` and `Responder` for `ProblemJson` | `actix-web` | +| `multipart` | Maps `axum::extract::multipart::MultipartError` → `BadRequest` (implies `axum`) | via `axum` | +| `openapi` | `utoipa::ToSchema` for `ErrorResponse` and `AppCode` so error payloads appear in OpenAPI specs | `utoipa` | +| `serde_json` | Structured JSON `details` on `AppError`/`ErrorResponse`/`ProblemJson`; `FieldValue::Json` and `field::json` | `serde_json` | +| `tonic` | Conversion of errors into `tonic::Status` with sanitized metadata; exports `StatusConversionError` | `tonic` | + +## Telemetry and observability + +| Flag | What it enables | Extra deps | +|---|---|---| +| `tracing` | Structured `tracing` events emitted when errors are constructed | `tracing`, `log`, `log-mdc` | +| `metrics` | Increments an `error_total{code,category}` counter for each `AppError` | `metrics` | +| `backtrace` | Lazy `std::backtrace::Backtrace` capture (honours `RUST_BACKTRACE`), `with_backtrace()` builder | — | +| `colored` | Colored multi-line terminal output with automatic TTY detection; richer `Display` for `AppError` | `owo-colors` | + +## Async and IO integrations + +Each integration flag adds a `From<...> for AppError` conversion that classifies the library error into the taxonomy: + +| Flag | Conversion | Extra deps | +|---|---|---| +| `sqlx` | `sqlx_core::Error` → `NotFound` / `Database` (lean `sqlx-core`, no drivers or TLS) | `sqlx-core` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` → `Database` (full `sqlx` with `migrate` feature only) | `sqlx` | +| `redis` | `redis::RedisError` → `Cache` | `redis` | +| `reqwest` | `reqwest::Error` → `Timeout` / `Network` / `ExternalApi` | `reqwest` | +| `tokio` | `tokio::time::error::Elapsed` → `Timeout` | `tokio` (time) | +| `validator` | `validator::ValidationErrors` → `Validation` | `validator` | +| `config` | `config::ConfigError` → `Config` | `config` | + +`sqlx` and `sqlx-migrate` are split deliberately: error classification only needs `sqlx-core`, while migration error mapping pulls the full `sqlx` crate. + +## Messaging and bots + +| Flag | Conversion | Extra deps | +|---|---|---| +| `teloxide` | `teloxide_core::RequestError` → `RateLimited` / `Network` / `ExternalApi` / `Deserialization` / `Internal` | `teloxide-core` | +| `init-data` | `init_data_rs::InitDataError` → `TelegramAuth` (Telegram Mini Apps init-data validation) | `init-data-rs` | + +## Front-end and domain + +| Flag | What it enables | Extra deps | +|---|---|---| +| `frontend` | `frontend` module: convert errors to `wasm_bindgen::JsValue` and emit `console.error` logs in WASM/browser contexts | `wasm-bindgen`, `js-sys`, `serde-wasm-bindgen` | +| `turnkey` | `turnkey` module: `TurnkeyErrorKind`, `TurnkeyError`, `classify_turnkey_error` and conversions into `AppError` | — | +| `benchmarks` | Criterion benchmark suite and CI baseline tooling (local profiling only) | — | + +## Baseline conversions (always available) + +Without any feature flag, `AppError` already converts from: + +| Source | Target kind | +|---|---| +| `std::io::Error` | `Internal` | +| `String` | `BadRequest` | + +## Recipes + +REST API on Axum with problem+json, OpenAPI docs and tracing: + +```toml +masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] } +``` + +Database service with sqlx, migrations and metrics: + +```toml +masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] } +``` + +gRPC service: + +```toml +masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] } +``` + +Telegram bot with Mini App auth: + +```toml +masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] } +``` + +WASM front-end: + +```toml +masterror = { version = "0.28", features = ["frontend", "serde_json"] } +``` + +`no_std` embedded or library target: + +```toml +masterror = { version = "0.28", default-features = false } +``` + +## Notes + +- All integration flags imply `std` except `sqlx` and `sqlx-migrate`, which stay `std`-independent at the flag level. +- `axum` and `actix` pull `serde_json` transitively because their response bodies are JSON. +- Feature flags never change the wire contract of `ErrorResponse`/`ProblemJson` fields that are already enabled — they only add capabilities (e.g. `serde_json` upgrades `details` from plain text to structured JSON) or trait implementations. + +--- + +See also: [Getting Started](Getting-Started-en) · [Web Frameworks](Web-Frameworks-en) · [Integrations](Integrations-en) · [Observability](Observability-en) · [No-Std](No-Std-en) diff --git a/wiki/Getting-Started-en.md b/wiki/Getting-Started-en.md new file mode 100644 index 0000000..3a6b3ea --- /dev/null +++ b/wiki/Getting-Started-en.md @@ -0,0 +1,193 @@ +# Getting Started + +This page walks through installing `masterror`, returning your first `AppError`, short-circuiting with `ensure!`/`fail!`, using the prelude and writing your first derive. + +## Installation + +The default build enables only the `std` feature — no web framework, no telemetry backends: + +```toml +[dependencies] +masterror = "0.28" +``` + +Enable integrations as you need them (see [Feature Flags](Feature-Flags-en) for the full list): + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] } +``` + +MSRV is **1.96**. The crate forbids `unsafe` and supports `no_std` when built with `default-features = false`. + +## Your first error + +`AppError` couples a semantic category (`AppErrorKind`) with an optional public message. `AppResult` is an alias for `Result`: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn do_work(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::new(AppErrorKind::BadRequest, "Flag must be set")); + } + Ok(()) +} + +let err = do_work(false).unwrap_err(); +assert!(matches!(err.kind, AppErrorKind::BadRequest)); +assert_eq!(err.kind.http_status(), 400); +``` + +Every kind has a named constructor, so you rarely spell out `AppErrorKind` at call sites: + +```rust +use masterror::AppError; + +let _ = AppError::not_found("user not found"); // 404 +let _ = AppError::validation("invalid email"); // 422 +let _ = AppError::unauthorized("token expired"); // 401 +let _ = AppError::forbidden("no access"); // 403 +let _ = AppError::conflict("already exists"); // 409 +let _ = AppError::rate_limited("slow down"); // 429 +let _ = AppError::internal("unexpected failure"); // 500 +let _ = AppError::service("orchestration failed"); // 500 +let _ = AppError::timeout("upstream timed out"); // 504 +let _ = AppError::bare(masterror::AppErrorKind::NotFound); // no message +``` + +Attach structured metadata and an upstream source without giving up typing: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::i64("attempt", 2)) + .with_context(std::io::Error::other("connection reset")); + +assert_eq!(err.metadata().len(), 2); +assert!(err.source_ref().is_some()); +``` + +The source is available for logs and `chain()` traversal but is **never serialized to clients**. + +## ensure! and fail! + +`ensure!` and `fail!` are typed alternatives to `anyhow::ensure!`/`anyhow::bail!`. The error expression is evaluated lazily, so the success path performs no formatting and no allocation: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("flag must be set")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("token expired")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +``` + +`ensure!` also accepts a verbose form for complex conditions: + +```rust +use masterror::{AppError, AppResult}; + +fn bounded(value: i32, max: i32) -> AppResult<()> { + masterror::ensure!( + cond = value <= max, + else = AppError::service("value too large") + ); + Ok(()) +} +``` + +## The prelude + +`masterror::prelude` re-exports just the core types (`AppError`, `AppErrorKind`, `AppCode`, `AppResult`, `ErrorResponse`, plus the `turnkey` helpers when that feature is on): + +```rust +use masterror::prelude::*; + +fn handler(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::bad_request("Flag must be set")); + } + Ok(()) +} +``` + +Framework trait implementations (Axum `IntoResponse`, Actix `Responder`) are activated by feature flags and need no extra imports. + +## Adding context to foreign errors + +`ResultExt` promotes any `Result` into `AppResult`: + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +// Simple, anyhow-style message: +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); + +// Full control over category, code, metadata and redaction: +let err = read_config() + .ctx(|| Context::new(AppErrorKind::Config).with(field::str("path", "app.toml"))) + .unwrap_err(); +assert_eq!(err.kind, AppErrorKind::Config); +``` + +See [Context and Metadata](Context-and-Metadata-en) for the full `Context` API. + +## Your first derive + +`#[derive(Error)]` mirrors `thiserror` syntax, and `#[app_error(...)]` adds the conversion into `AppError`: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal, message)] +pub struct DomainError { + #[from] + #[source] + source: std::io::Error +} + +fn load() -> Result<(), DomainError> { + Err(std::io::Error::other("disk offline").into()) +} + +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let app: AppError = err.into(); +assert!(matches!(app.kind, AppErrorKind::Internal)); +``` + +- `#[error("...")]` defines the `Display` template with `{field}` placeholders. +- `#[from]` generates `From` for the wrapper. +- `#[source]` forwards the inner error through `source()`. +- `#[app_error(kind = ..., code = ..., message)]` generates `From for AppError` (and `for AppCode`); the `message` flag exposes the `Display` output as the public message. + +Enums work the same way with per-variant `#[error]` and `#[app_error]` attributes. When you also need metadata, redaction policy and gRPC/problem+json mapping tables, reach for `#[derive(Masterror)]` — covered in [Derive Macros](Derive-Macros-en). + +## Where to go next + +- Map errors to HTTP responses in Axum or Actix — [Web Frameworks](Web-Frameworks-en) +- Understand the taxonomy and wire contract — [Error Kinds and Codes](Error-Kinds-and-Codes-en) +- Enable integrations for sqlx, redis, reqwest — [Feature Flags](Feature-Flags-en) + +--- + +See also: [Feature Flags](Feature-Flags-en) · [Error Kinds and Codes](Error-Kinds-and-Codes-en) · [Derive Macros](Derive-Macros-en) · [Context and Metadata](Context-and-Metadata-en) · [Migration](Migration-en) diff --git a/wiki/Home-en.md b/wiki/Home-en.md new file mode 100644 index 0000000..7e97707 --- /dev/null +++ b/wiki/Home-en.md @@ -0,0 +1,131 @@ +
+ +# masterror + +**Framework-agnostic application error types with stable codes, HTTP/gRPC mappings and built-in telemetry** + +[![English](https://img.shields.io/badge/🇬🇧_English-blue?style=for-the-badge)](#) +[![Русский](https://img.shields.io/badge/🇷🇺_Русский-gray?style=for-the-badge)](Главная) +[![한국어](https://img.shields.io/badge/🇰🇷_한국어-gray?style=for-the-badge)](홈) + +[![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) +[![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) +![MSRV](https://img.shields.io/badge/MSRV-1.96-blue) +![License](https://img.shields.io/badge/License-MIT-informational) + +
+ +--- + +## What is masterror? + +`masterror` is an error-handling workspace for Rust services that need more than `Display` and `source()`. Where `thiserror` stops at deriving trait implementations and `anyhow` stops at type-erased propagation, `masterror` carries an error all the way to the transport boundary: + +- **`AppError`** — a rich error value with a semantic category (`AppErrorKind`), a stable machine-readable code (`AppCode`), an optional safe public message, structured metadata and transport hints (`Retry-After`, `WWW-Authenticate`). +- **Conservative HTTP and gRPC mappings** — every kind and code maps deterministically to an HTTP status, a `tonic::Code` discriminant and an RFC 7807 `type` URI. +- **Typed telemetry** — metadata is stored as typed fields (strings, integers, floats, durations, IPs, UUIDs, JSON) with per-field redaction policies, not ad-hoc `String` maps. +- **Native derives** — `#[derive(Error)]` mirrors `thiserror` syntax, while `#[app_error(...)]` and `#[derive(Masterror)]` wire domain errors into `AppError` with codes, categories, redaction and mapping tables. +- **Redaction by design** — sources are never serialized to clients; messages, details and metadata fields can be redacted, hashed or masked at the boundary. + +No `unsafe`, pinned MSRV, `no_std` support with the default `std` feature disabled. + +## The problem it solves + +| Concern | `thiserror` | `anyhow` | `masterror` | +|---|---|---|---| +| `Display` / `source()` derives | Yes | — | Yes (same syntax) | +| Type-erased propagation with context | — | Yes | Yes (`.ctx()` / `.context()`) | +| Stable machine-readable error codes | Manual | Manual | `AppCode`, part of the wire contract | +| HTTP status mapping | Manual | Manual | `AppErrorKind::http_status()`, stable table | +| gRPC status mapping | Manual | Manual | `CODE_MAPPINGS`, `tonic::Status` conversion | +| RFC 7807 `problem+json` | Manual | Manual | `ProblemJson::from_app_error` | +| Structured, typed metadata | — | — | `Metadata` + `field::*` builders | +| Redaction of secrets at the boundary | — | — | `MessageEditPolicy`, `FieldRedaction` | +| tracing / metrics / backtrace emission | — | — | Feature-gated, automatic on construction | + +A `thiserror`-derived enum tells you *what happened*. `masterror` also decides *what the client sees* (status, code, safe message, problem+json), *what operators see* (structured fields, tracing events, counters) and *what never leaks* (sources, redacted fields). + +## Feature highlights + +| Area | What you get | +|---|---| +| Core taxonomy | `AppError`, `AppErrorKind` (23 stable categories), `AppCode` (SCREAMING_SNAKE_CASE codes, custom codes supported), `AppResult` | +| Derives | `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error(...)]`, `#[masterror(...)]`, `#[provide(...)]` telemetry providers | +| Control flow | `ensure!` / `fail!` — typed, allocation-free early returns | +| Context | `ResultExt::ctx` / `ResultExt::context`, `Context` builder with caller tracking | +| Wire payloads | `ErrorResponse` (legacy JSON), `ProblemJson` (RFC 7807) with retry and auth hints | +| Transports | Axum `IntoResponse`, Actix `ResponseError`/`Responder`, `tonic::Status`, WASM `JsValue`, OpenAPI schema | +| Integrations | `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `teloxide`, Telegram Mini Apps init data, Turnkey | +| Observability | `tracing` events, `metrics` counters, lazy `backtrace` capture, colored terminal output, `DisplayMode` (prod/staging/local) | + +## Quick example + +```rust +use masterror::{AppError, AppErrorKind, AppResult, ProblemJson, field}; + +fn find_user(id: u64) -> AppResult<()> { + masterror::ensure!(id != 0, AppError::bad_request("id must be non-zero")); + + Err(AppError::not_found("user not found") + .with_field(field::u64("user_id", id)) + .with_field(field::str("request_id", "abc123"))) +} + +let err = find_user(42).unwrap_err(); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.kind.http_status(), 404); + +let problem = ProblemJson::from_app_error(err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +assert_eq!(problem.grpc.expect("grpc").name, "NOT_FOUND"); +``` + +Or declare a domain error once and let the derive handle the mapping: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); +``` + +## Workspace crates + +| Crate | Role | +|---|---| +| [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata, transports, integrations, prelude | +| [`masterror-derive`](https://crates.io/crates/masterror-derive) | Proc-macros behind `#[derive(Error)]` and `#[derive(Masterror)]` (pulled in automatically) | +| [`masterror-template`](https://crates.io/crates/masterror-template) | Shared `#[error("...")]` template parser | + +## Documentation + +**Getting started** + +- [Getting Started](Getting-Started-en) — installation, first errors, macros, first derive +- [Feature Flags](Feature-Flags-en) — complete flag reference with dependencies + +**Core concepts** + +- [Error Kinds and Codes](Error-Kinds-and-Codes-en) — taxonomy, HTTP/gRPC tables, problem+json +- [Derive Macros](Derive-Macros-en) — `#[derive(Error)]`, `#[derive(Masterror)]` and their attributes +- [Context and Metadata](Context-and-Metadata-en) — `Context`, `ResultExt`, fields, redaction, chains + +**Integrations** + +- [Web Frameworks](Web-Frameworks-en) — Axum, Actix, tonic +- [Integrations](Integrations-en) — sqlx, redis, reqwest, validator and friends +- [Observability](Observability-en) — tracing, metrics, backtraces, display modes + +**Advanced** + +- [No-Std](No-Std-en) — running without the standard library +- [Best Practices](Best-Practices-en) — patterns for services and libraries +- [Migration](Migration-en) — moving from `thiserror` / `anyhow` diff --git a/wiki/Integrations-en.md b/wiki/Integrations-en.md new file mode 100644 index 0000000..923383a --- /dev/null +++ b/wiki/Integrations-en.md @@ -0,0 +1,200 @@ +# Integrations + +Optional feature flags add `From<...>` conversions from popular third-party +error types into `masterror::Error`, so a single `?` at the call site produces +a classified error with structured metadata. Every conversion picks a stable +[`AppErrorKind`](Error-Kinds-and-Codes-en) and attaches +telemetry fields (never secrets) for observability. + +## Conversion matrix + +| Feature | Source type | Resulting `AppErrorKind` | +|---|---|---| +| `sqlx` | `sqlx_core::error::Error` | `NotFound`, `Conflict`, `Validation`, `Timeout`, `DependencyUnavailable`, `Config`, `BadRequest`, `Serialization`, `Deserialization`, `Network`, `Database`, `Internal` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` | `Database` (with migration phase metadata) | +| `redis` | `redis::RedisError` | `Cache` (default), `Timeout`, `DependencyUnavailable` | +| `reqwest` | `reqwest::Error` | `Timeout`, `Network`, `RateLimited`, `DependencyUnavailable`, `ExternalApi` | +| `validator` | `validator::ValidationErrors` | `Validation` | +| `config` | `config::ConfigError` | `Config` (with `config.phase` metadata) | +| `tokio` | `tokio::time::error::Elapsed` | `Timeout` | +| `serde_json` | `serde_json::Error` | `Serialization` (I/O), `Deserialization` (syntax/data/EOF) | +| `teloxide` | `teloxide_core::RequestError` | `ExternalApi`, `Unauthorized`, `RateLimited`, `Network`, `Deserialization`, `Internal` | +| `init-data` | `init_data_rs::InitDataError` | `TelegramAuth` | +| `tonic` | `masterror::Error` → `tonic::Status` | outbound mapping, see below | +| `multipart` | `axum::extract::multipart::MultipartError` | `BadRequest` (see [Web Frameworks](Web-Frameworks-en)) | + +## sqlx and sqlx-migrate + +`sqlx` depends only on `sqlx-core` (no drivers, no TLS). Key mappings: + +- `Error::RowNotFound` → `NotFound` +- Pool timeout → `Timeout`; pool closed and I/O failures → + `DependencyUnavailable`; TLS errors → `Network` +- Constraint violations are classified by `sqlx` error kind: unique and + foreign-key violations → `Conflict`, not-null / check violations → + `Validation`, anything else → `Database` +- Encode → `Serialization`, decode → `Deserialization` + +Database errors capture SQLSTATE and constraint names as metadata. Known +SQLSTATE codes override the public [`AppCode`](Error-Kinds-and-Codes-en): +`23505` → `USER_ALREADY_EXISTS`, `23503` → `CONFLICT`, `23502`/`23514` → +`VALIDATION`. Transient SQLSTATEs (`40001` serialization failure, `55P03` lock +not available) attach retry hints. + +```rust,ignore +use masterror::{AppErrorKind, Error}; + +async fn load_user(pool: &sqlx::PgPool, id: i64) -> Result { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; // RowNotFound becomes AppErrorKind::NotFound + Ok(user) +} +``` + +`sqlx-migrate` pulls full `sqlx` (still without default features) and maps +`sqlx::migrate::MigrateError` to `Database`, recording the migration phase and +version in metadata. + +## redis + +All `redis::RedisError` values map to `Cache` by default; timeout-flavoured +errors become `Timeout` and connection failures become `DependencyUnavailable`. +Error category and code are preserved as metadata. + +## reqwest + +Treats `reqwest` as a client of an external HTTP API: + +- `is_timeout()` → `Timeout` +- `is_connect()` / `is_request()` → `Network` +- HTTP status errors: `429` → `RateLimited`, `408` → `Timeout`, `5xx` → + `DependencyUnavailable`, others → `ExternalApi` +- everything else → `ExternalApi` + +Metadata records the endpoint, status and low-level flags; the URL is marked +for hashing/redaction in public payloads. + +## validator + +`validator::ValidationErrors` → `Validation`, with aggregate context in +metadata: failing field names (`validation.fields`), field and error counts, +and the first validation codes (`validation.codes`): + +```rust,ignore +use masterror::AppResult; +use validator::Validate; + +#[derive(Validate)] +struct Payload { + #[validate(length(min = 5))] + name: String +} + +fn check(p: &Payload) -> AppResult<()> { + p.validate()?; // ValidationErrors -> AppErrorKind::Validation + Ok(()) +} +``` + +## config, tokio, serde_json + +- `config::ConfigError` → `Config`, with a `config.phase` metadata field + (`not_found`, `file_parse`, `type`, ...) identifying the failing + stage. +- `tokio::time::error::Elapsed` → `Timeout` with a + `timeout.source = "tokio::time::timeout"` metadata field. The error carries + no custom message, so clients see the kind's fixed title + `"Operation timed out"`. +- `serde_json::Error` is classified via `Error::classify()`: I/O → + `Serialization`; syntax, data and EOF → `Deserialization`. + +## teloxide + +`teloxide_core::RequestError` mapping: + +| Variant | `AppErrorKind` | +|---|---| +| `Api` | `ExternalApi` (invalid token → `Unauthorized`) | +| `MigrateToChatId` | `ExternalApi` | +| `RetryAfter` | `RateLimited` | +| `Network` | `Network` | +| `InvalidJson` | `Deserialization` | +| `Io` | `Internal` | + +## init-data (Telegram Mini Apps) + +Every `init_data_rs::InitDataError` variant (missing/invalid hash, expired +payload, signature failures) maps to `TelegramAuth`, keeping Mini App +authentication failures distinct from generic bad requests. + +## tonic (outbound gRPC) + +`tonic` converts in the opposite direction: `masterror::Error` → +`tonic::Status` via `From`. The [`AppCode`](Error-Kinds-and-Codes-en) is mapped +to the canonical `tonic::Code` through the same `CODE_MAPPINGS` table used for +HTTP. The status carries metadata entries `app-code`, `app-http-status` and +`app-problem-type`, plus retry and `www-authenticate` hints when present. +Redactable errors have their message replaced by the kind label and their +metadata stripped. + +```rust,ignore +use masterror::AppError; +use tonic::{Code, Status}; + +let status = Status::from(AppError::not_found("missing")); +assert_eq!(status.code(), Code::NotFound); +``` + +## frontend (WASM / browser) + +The `frontend` feature adds the `masterror::frontend::BrowserConsoleExt` trait +for `AppError` and `ErrorResponse`, backed by `wasm-bindgen`: + +- `to_js_value()` — serialize the error into a `wasm_bindgen::JsValue` +- `log_to_browser_console()` — emit it via `console.error` + +Both are functional on `wasm32` targets; on native targets they return +`BrowserConsoleError::UnsupportedTarget`. Failure modes (console missing, +`console.error` not callable, serialization failure) are covered by the +`BrowserConsoleError` enum. + +```rust,ignore +use masterror::{AppError, frontend::BrowserConsoleExt}; + +let err = AppError::not_found("user not found"); +err.log_to_browser_console()?; +``` + +## turnkey + +The `turnkey` feature exposes a small stable domain taxonomy in +`masterror::turnkey`: + +| `TurnkeyErrorKind` | `AppErrorKind` | +|---|---| +| `UniqueLabel` | `Conflict` | +| `RateLimited` | `RateLimited` | +| `Timeout` | `Timeout` | +| `Auth` | `Unauthorized` | +| `Network` | `Network` | +| `Service` | `Turnkey` | + +`TurnkeyError::new(kind, msg)` builds a domain error; `From for +AppError` and `From for AppErrorKind` perform the mapping. +`classify_turnkey_error(&str)` heuristically classifies a raw provider message +(case-insensitive, word-boundary aware) into a `TurnkeyErrorKind`: + +```rust +use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error}; +use masterror::{AppError, AppErrorKind}; + +let kind = classify_turnkey_error("429 rate-limit reached"); +assert_eq!(kind, TurnkeyErrorKind::RateLimited); + +let app: AppError = TurnkeyError::new(kind, "quota exceeded").into(); +assert_eq!(app.kind, AppErrorKind::RateLimited); +``` + +See also: [Feature Flags](Feature-Flags-en) · [Web Frameworks](Web-Frameworks-en) · [Error Kinds & Codes](Error-Kinds-and-Codes-en) · [Observability](Observability-en) diff --git a/wiki/Migration-en.md b/wiki/Migration-en.md new file mode 100644 index 0000000..f996281 --- /dev/null +++ b/wiki/Migration-en.md @@ -0,0 +1,138 @@ +# Migration + +`masterror` is designed as a drop-in successor to both `thiserror` (derive +syntax) and `anyhow` (ergonomics). Most migrations are a dependency swap plus +incremental adoption of the typed features. Runnable walkthroughs: +[`examples/migrate_from_thiserror.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_thiserror.rs) +and +[`examples/migrate_from_anyhow.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_anyhow.rs). + +## From thiserror + +Step 1 is mechanical — change the import; the derive syntax is compatible: + +```diff +-use thiserror::Error; ++use masterror::Error; +``` + +### Attribute compatibility + +| thiserror | masterror | Notes | +|---|---|---| +| `#[error("...")]` with `{field}` placeholders | same | 1:1, including positional `{0}` | +| Format specs `:>8`, `:.3`, `:x`, `:p`, `:e` | same | `TemplateFormatter` mirrors thiserror's formatter detection | +| `#[error(transparent)]` | same | enforces single-field wrappers forwarding `Display`/`source` | +| `#[from]` | same | generates `From<...>`, validates wrapper shape | +| `#[source]` | same | wires the `source()` chain | +| `#[backtrace]` | same | honoured on fields | +| — | `#[app_error(kind = ..., code = ..., message)]` | **added**: generates `From for AppError` (and `AppCode`); `message` forwards `Display` as the public message | +| — | `#[provide(ref = T, value = T)]` | **added**: typed telemetry via `std::error::Request`; `Option` fields provide only when `Some` | +| — | `#[derive(Masterror)]` + `#[masterror(...)]` | **added**: full mapping with `category`, `redact(message, fields(...))`, `telemetry(...)`, `map.grpc`, `map.problem` | + +Existing enums keep compiling unchanged. What you gain by annotating them: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("user {user_id} not found")] +#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] +struct UserMissing { + user_id: u64 +} + +let app: AppError = UserMissing { user_id: 42 }.into(); +assert_eq!(app.kind, AppErrorKind::NotFound); +``` + +Enums map per variant — each variant carries its own `#[app_error(...)]`, and +the derive emits a single `From for AppError`. + +Recommended order: (1) swap the import, (2) add `#[app_error]` to types that +cross the API boundary, (3) replace hand-written `From for +AppError` impls with the generated ones, (4) adopt `#[masterror(...)]` where +you need redaction or metadata. + +## From anyhow + +| anyhow | masterror | Notes | +|---|---|---| +| `anyhow::Result` | `masterror::AppResult` | alias for `Result` | +| `anyhow::Error` | `masterror::AppError` / `Error` | carries kind, code, metadata instead of a blob | +| `.context("msg")` | `.context("msg")` | identical, via `masterror::ResultExt` | +| `.with_context(\|\| ...)` | `.ctx(\|\| Context::new(kind)...)` | lazy like anyhow, but builds a **typed** `Context` (kind, code, fields, redaction) | +| `bail!(err)` | `fail!(err)` | takes a typed error expression, no format machinery | +| `ensure!(cond, "msg {x}")` | `ensure!(cond, AppError::...)` | condition + typed error; no formatting on the success path | +| `err.chain()` | `err.chain()` | same iterator over the source chain | +| `err.root_cause()` | `err.root_cause()` | same | +| `err.is::()` / `downcast` / `downcast_ref` / `downcast_mut` | same names on `AppError` | downcasting parity | +| `#[from]`-style wrapping | `From<...>` impls behind [feature flags](Integrations-en) | sqlx/redis/reqwest/... arrive pre-classified | + +`.context()` works exactly as you expect: + +```rust +use masterror::{AppResult, ResultExt}; + +fn read_config(path: &str) -> AppResult { + let content = std::fs::read_to_string(path).context("Failed to read config file")?; + Ok(content) +} +``` + +`ensure!`/`fail!` trade anyhow's string formatting for typed errors: + +```rust +use masterror::{AppError, AppResult, ensure, fail, field}; + +fn parse(content: &str, path: &str) -> AppResult<()> { + ensure!( + !content.is_empty(), + AppError::bad_request("Config file is empty") + .with_field(field::str("path", path.to_owned())) + ); + if content.starts_with("invalid") { + fail!(AppError::bad_request("Invalid config format")); + } + Ok(()) +} +``` + +The error expression is evaluated only when the guard trips, so the happy path +stays allocation-free — same guarantee anyhow gives, plus a machine-readable +code. + +### Where anyhow has no equivalent + +Migrating buys you capabilities that have no anyhow counterpart: + +- **Typed taxonomy** — `AppErrorKind` (internal) and `AppCode` (public, + SCREAMING_SNAKE_CASE) instead of stringly-typed context. See + [Error Kinds & Codes](Error-Kinds-and-Codes-en). +- **Transport mappings** — RFC 7807 `problem+json` for Axum/Actix and + `tonic::Status` for gRPC, derived from the same code table. See + [Web Frameworks](Web-Frameworks-en). +- **Telemetry** — automatic `tracing` events, `error_total{code,category}` + metrics and lazy backtraces at the boundary. See + [Observability](Observability-en). +- **Redaction** — `redactable()` messages and per-field `Hash`/`Last4`/`Redact` + policies, honoured by every transport. See + [Best Practices](Best-Practices-en). +- **Structured metadata** — typed `field::str/u64/duration/ip/...` instead of + formatting values into the message. + +### What to watch for + +- anyhow's `ensure!(cond, "format {}", x)` formatted-message form has no + direct twin: construct the error explicitly + (`AppError::bad_request(format!(...))`) or, better, use a static message + plus metadata fields. +- `anyhow::Error` accepts any `E: Error + Send + Sync`. In masterror you + choose a kind at wrap time (`Context::new(kind)` or a `From` conversion) — + that decision point is the feature, not friction: it is where + classification happens. +- Both `ensure!` and `fail!` expand to `return Err(...)`, so they work in any + function returning `Result<_, E>` where your expression is already the + error type — no `Into` conversion is inserted. + +See also: [Getting Started](Getting-Started-en) · [Derive Macros](Derive-Macros-en) · [Context & Metadata](Context-and-Metadata-en) · [Best Practices](Best-Practices-en) diff --git a/wiki/No-Std-en.md b/wiki/No-Std-en.md new file mode 100644 index 0000000..6092653 --- /dev/null +++ b/wiki/No-Std-en.md @@ -0,0 +1,115 @@ +# no_std Support + +`masterror` builds without the Rust standard library. The crate root declares +`#![cfg_attr(not(feature = "std"), no_std)]`, and the default `std` feature is +the only thing standing between you and an embedded/WASM-friendly build: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +``` + +## alloc is required + +`masterror` is `no_std` but **not** `no_alloc`. The crate unconditionally +declares `extern crate alloc` and uses `Cow<'static, str>`, `String`, `Arc` +and `BTreeMap` for messages, metadata and source chains. Your target needs a +global allocator; pure `core`-only environments are not supported. + +## What works without `std` + +The entire framework-agnostic core: + +| Area | Available in `no_std` | +|---|---| +| Core types | `Error` / `AppError`, `AppResult`, `AppErrorKind`, `AppCode` | +| Metadata | `Metadata`, `Field`, `FieldValue`, `FieldRedaction`, `field::*` helpers | +| Context | `Context`, `ResultExt::{ctx, context}` | +| Control flow | `ensure!`, `fail!` | +| Derives | `#[derive(Error)]`, `#[derive(Masterror)]` with all attributes | +| Wire types | `ProblemJson`, `ErrorResponse`, `CODE_MAPPINGS`, `mapping_for_code` | +| Introspection | `chain()`, `root_cause()`, `is`/`downcast`/`downcast_ref`/`downcast_mut`, `render_message()` | +| Serde | `serde` with `alloc` (JSON serialization of wire types) | + +Error sources work through **`core::error::Error`**: the crate implements and +consumes `core::error::Error` (aliased internally as `CoreError`) instead of +`std::error::Error`, so `with_source(...)`, source chains and downcasting are +fully functional in `no_std` builds. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, field}; + +let err = AppError::new(AppErrorKind::Timeout, "deadline exceeded") + .with_field(field::u64("attempt", 3)); + +assert_eq!(err.code, AppCode::Timeout); +assert_eq!(err.metadata().len(), 1); +``` + +## What requires `std` + +Every runtime integration explicitly re-enables `std` in its feature +definition. From `Cargo.toml`: + +- `tracing`, `metrics`, `backtrace`, `colored` +- `axum`, `actix`, `multipart`, `tonic`, `openapi` +- `serde_json`, `redis`, `validator`, `config`, `tokio`, `reqwest`, + `teloxide`, `init-data`, `frontend`, `turnkey` + +`backtrace` needs `std::backtrace::Backtrace` and environment access; +`colored` needs TTY detection; the web and client integrations need their +host crates, which are themselves `std`-only. + +## CI feature matrix + +The `no_std` CI job (`.github/workflows/ci.yml`) checks these combinations on +every pull request and push to `main`: + +| Job | Command | Verifies | +|---|---|---| +| `bare` | `cargo check --no-default-features` | true `no_std` + `alloc` build | +| `std-only` | `cargo check --features std` | default std surface | +| `tracing` | `cargo check --no-default-features --features tracing` | single telemetry feature builds standalone | +| `metrics` | `cargo check --no-default-features --features metrics` | same for metrics | +| `colored` | `cargo check --no-default-features --features colored` | same for colored | +| `all-features` | `cargo check --all-features` | full feature union | + +Note the semantics: only the `bare` job is a genuine `no_std` compilation. +`tracing = [..., "std"]`, `metrics = [..., "std"]` and +`colored = [..., "std"]` transitively re-enable `std`, so those jobs verify +that each telemetry feature is self-sufficient when defaults are off — not +that telemetry works without the standard library. If you need telemetry, you +need `std`. + +## Practical setup + +Library crates that want to stay transport-agnostic and `no_std`-compatible: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } + +[features] +std = ["masterror/std"] +``` + +The binary or service crate then turns on `std` plus whatever integrations it +needs: + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "tracing", "metrics"] } +``` + +Because `AppErrorKind`, `AppCode` and the wire types live in the `no_std` +core, domain crates can classify errors and even build `ProblemJson` payloads +while the HTTP mapping happens only in the service crate — see +[Best Practices](Best-Practices-en). + +## Toolchain + +The crate targets edition 2024 with `rust-version = "1.96"` in `Cargo.toml`. +`core::error::Error` (the foundation of `no_std` source chains) has been +stable since Rust 1.81, so no nightly features are involved. + +See also: [Feature Flags](Feature-Flags-en) · [Getting Started](Getting-Started-en) · [Best Practices](Best-Practices-en) diff --git a/wiki/Observability-en.md b/wiki/Observability-en.md new file mode 100644 index 0000000..63c0f2b --- /dev/null +++ b/wiki/Observability-en.md @@ -0,0 +1,157 @@ +# Observability + +`masterror` treats telemetry as part of the error lifecycle. Each `AppError` +tracks a dirty flag; telemetry is emitted once per state change — at +construction, after mutation, or when the error crosses a transport boundary +(Axum `IntoResponse`, Actix `error_response()`, tonic `Status` conversion). +You rarely call anything manually. + +## Feature flags + +| Feature | Adds | +|---|---| +| `tracing` | Structured `tracing` event per error, `trace_id` via `log-mdc` | +| `metrics` | `error_total{code,category}` counter via the `metrics` crate | +| `backtrace` | Lazy `std::backtrace::Backtrace` capture gated by `RUST_BACKTRACE` | +| `colored` | ANSI-colored terminal styling with TTY detection | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["tracing", "metrics", "backtrace"] } +``` + +## Tracing + +With `tracing` enabled, each error emits one ERROR-level event with target +`masterror::error`: + +| Field | Content | +|---|---| +| `code` | `AppCode` string, e.g. `NOT_FOUND` | +| `category` | `AppErrorKind` label, e.g. `Database` | +| `message` | Public message, if any | +| `retry_seconds` | Retry advice, if set | +| `redactable` | Whether the message is redacted at transport boundaries | +| `metadata_len` | Number of attached metadata fields | +| `www_authenticate` | Authentication challenge, if set | +| `trace_id` | Pulled from the `log-mdc` context key `trace_id`, if present | + +The emission is subscriber-aware: if no subscriber is interested in +ERROR-level events for the target, the event stays pending and is retried on +the next flush, so nothing is lost when a subscriber is installed late. + +To correlate errors with requests, store a trace ID in the MDC in your request +middleware: + +```rust,ignore +log_mdc::insert("trace_id", request_id); +``` + +Every error constructed while the key is set carries it in the event. + +## Metrics + +With `metrics` enabled, each newly-dirty error increments: + +```text +error_total{code="NOT_FOUND", category="NotFound"} +``` + +Both labels are stable strings (`AppCode::as_str()` and the `AppErrorKind` +label), so dashboards and alerts survive refactors of your domain types. Wire +any `metrics` recorder (Prometheus, StatsD, ...) as usual; `masterror` only +uses `metrics::counter!`. + +## Backtraces + +With `backtrace` enabled, a `Backtrace` snapshot is captured lazily when +telemetry is flushed — not on every construction. Capture is controlled by +`RUST_BACKTRACE`: unset, empty, `0`, `off` and `false` disable it; anything +else enables it. The preference is read once and cached per process. + +```rust +# #[cfg(feature = "backtrace")] { +use masterror::AppError; + +let err = AppError::internal("db down"); +if let Some(bt) = err.backtrace() { + eprintln!("{bt}"); +} +# } +``` + +You can also attach a pre-captured trace with +`AppError::with_backtrace(backtrace)`, which takes priority over lazy capture. + +## Manual flushing with `.log()` + +Constructors and conversions emit telemetry automatically. After mutating an +error (adding fields, changing retry advice) you can force re-emission: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("upstream degraded") + .with_field(field::str("upstream", "billing")); +err.log(); +``` + +`log()` is idempotent per state: if nothing changed since the last emission, +it does nothing. The HTTP/gRPC adapters flush the same way at the boundary, +so an error that is constructed, enriched and then returned from an Axum +handler emits once per state — once at construction and once at the boundary +for the enriched state — never twice for the same state. + +## Inspecting the chain + +Independent of features, `AppError` exposes the tools log pipelines need: + +```rust +# #[cfg(feature = "std")] { +use std::io::Error as IoError; +use masterror::AppError; + +let err = AppError::internal("db down").with_context(IoError::other("disk offline")); + +assert_eq!(err.chain().count(), 2); +let _root = err.root_cause(); +assert!(err.metadata().is_empty()); +# } +``` + +`metadata().iter_with_redaction()` yields `(key, value, policy)` triples so a +logging layer can honour field redaction — see +[Context & Metadata](Context-and-Metadata-en). + +## Colored terminal output + +The `colored` feature adds `masterror::colored::style` for CLI tools. Colors +are applied only when stderr is a TTY, `NO_COLOR` is unset, `TERM` is not +`dumb` and the terminal supports ANSI; otherwise the text passes through +unchanged. Detection is cached per process. + +| Function | Style | Use for | +|---|---|---| +| `error_kind_critical` | red | critical failure kinds | +| `error_kind_warning` | yellow | recoverable/warning kinds | +| `error_code` | cyan | machine codes | +| `error_message` | bright white | main message | +| `source_context` | dimmed | secondary/source info | +| `metadata_key` | green | structured field names | + +```rust +# #[cfg(feature = "colored")] { +use masterror::colored::style; + +eprintln!( + "{}: {}", + style::error_code("ERR_DB_001"), + style::error_message("Database connection failed") +); +# } +``` + +See the runnable demo in +[`examples/colored_cli.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/colored_cli.rs). + +See also: [Feature Flags](Feature-Flags-en) · [Context & Metadata](Context-and-Metadata-en) · [Web Frameworks](Web-Frameworks-en) · [Best Practices](Best-Practices-en) diff --git a/wiki/Web-Frameworks-en.md b/wiki/Web-Frameworks-en.md new file mode 100644 index 0000000..99e9281 --- /dev/null +++ b/wiki/Web-Frameworks-en.md @@ -0,0 +1,209 @@ +# Web Frameworks + +`masterror` maps errors to HTTP at the transport boundary. Domain code returns +[`AppResult`](Error-Kinds-and-Codes-en); the framework adapter converts the +error into an RFC 7807 `application/problem+json` response, flushes telemetry +and applies redaction. There is exactly one `IntoResponse` / +`ResponseError` implementation for `AppError` in the crate — you never write +the mapping by hand. + +## Feature flags + +| Feature | Enables | +|---|---| +| `axum` | `IntoResponse` for `AppError`, `ProblemJson`, `ErrorResponse`; pulls `serde_json` | +| `actix` | `ResponseError` for `AppError`; `Responder` for `ProblemJson`, `ErrorResponse` | +| `multipart` | `From` for `Error` (implies `axum`) | +| `openapi` | `utoipa` schema for `ErrorResponse` | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum"] } # or ["actix"] +``` + +## Wire format + +Both adapters serialize [`ProblemJson`](https://docs.rs/masterror/latest/masterror/struct.ProblemJson.html): + +| Field | Type | Notes | +|---|---|---| +| `type` | string URI | Canonical problem class, e.g. `https://errors.masterror.rs/not-found` | +| `title` | string | Short summary derived from `AppErrorKind` | +| `status` | number | HTTP status code | +| `detail` | string? | Public message; **omitted when the error is redactable** | +| `details` | object? | Structured details (`serde_json` feature) | +| `code` | string | Stable machine-readable `AppCode`, e.g. `NOT_FOUND` | +| `grpc` | object? | `{ name, value }` gRPC mapping for multi-protocol clients | +| `metadata` | object? | Sanitized fields from `Metadata`; omitted when redacted | + +Transport hints become headers, not body fields: + +- `AppError::with_retry_after_secs(n)` → `Retry-After: n` +- `AppError::with_www_authenticate(challenge)` → `WWW-Authenticate: challenge` + +Internal sources (`std::error::Error` chain) are logged only and never +serialized to clients. + +## Axum + +The `axum` feature implements `IntoResponse` for `AppError`, `ProblemJson` and +`ErrorResponse`, plus an inherent `AppError::http_status()` returning +`axum::http::StatusCode` derived from the error kind. Converting to a response +flushes telemetry (tracing event, metrics counter, lazy backtrace) — see +[Observability](Observability-en). + +```rust +use axum::{Router, routing::get}; +use masterror::{AppError, AppResult}; + +async fn handler() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +let app: Router = Router::new().route("/demo", get(handler)); +``` + +A `401` with hints: + +```rust +use masterror::AppError; + +let err = AppError::unauthorized("missing token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer realm=\"api\""); +``` + +produces status `401`, headers `Retry-After: 7` and +`WWW-Authenticate: Bearer realm="api"`, and body: + +```json +{ + "type": "https://errors.masterror.rs/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "missing token", + "code": "UNAUTHORIZED", + "grpc": { "name": "UNAUTHENTICATED", "value": 16 } +} +``` + +### Domain errors in handlers + +The pattern from +[`examples/axum-rest-api`](https://github.com/RAprogramm/masterror/tree/main/examples/axum-rest-api): +derive a domain enum, convert it to `AppError` once, then reuse the crate's +`IntoResponse`. + +```rust +use axum::response::{IntoResponse, Response}; +use masterror::{AppError, Error}; + +#[derive(Debug, Error, Clone)] +pub enum UserError { + #[error("user not found")] + NotFound, + #[error("email already exists")] + DuplicateEmail, + #[error("invalid email format")] + InvalidEmail +} + +impl From for AppError { + fn from(err: UserError) -> Self { + match err { + UserError::NotFound => AppError::not_found(err.to_string()), + UserError::DuplicateEmail => AppError::conflict(err.to_string()), + UserError::InvalidEmail => AppError::validation(err.to_string()) + } + } +} + +impl IntoResponse for UserError { + fn into_response(self) -> Response { + AppError::from(self).into_response() + } +} +``` + +With `#[app_error(kind = ..., code = ...)]` on the derive, the `From +for AppError` impl is generated for you — see [Derive Macros](Derive-Macros-en). + +## Actix Web + +The `actix` feature implements `actix_web::ResponseError` for `AppError`, so +handlers returning `AppResult` work out of the box. `error_response()` +emits telemetry and builds the same problem+json payload via +`ProblemJson::from_ref`. + +```rust,ignore +use actix_web::{App, HttpServer, get}; +use masterror::{AppError, AppResult}; + +#[get("/forbidden")] +async fn forbidden() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| App::new().service(forbidden)) + .bind(("127.0.0.1", 8080))? + .run() + .await +} +``` + +The client receives `403` with: + +```json +{ + "type": "https://errors.masterror.rs/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "no access", + "code": "FORBIDDEN", + "grpc": { "name": "PERMISSION_DENIED", "value": 7 } +} +``` + +`ProblemJson` and `ErrorResponse` also implement `Responder`, so a handler can +return them directly. Status mapping uses the same stable +`AppErrorKind → StatusCode` table as Axum. + +## Multipart + +`multipart` (implies `axum`) converts +`axum::extract::multipart::MultipartError` into `Error` with +`AppErrorKind::BadRequest`, preserving the parser message: + +```rust,ignore +use axum::extract::multipart::Multipart; +use masterror::{AppErrorKind, Error}; + +async fn upload(mut multipart: Multipart) -> Result<(), Error> { + while let Some(field) = multipart.next_field().await? { + let _ = field.bytes().await?; + } + Ok(()) +} +``` + +Malformed client payloads surface as `400 Bad Request` instead of a 500. + +## Building responses manually + +For tests or custom transports, construct the payload without a framework: + +```rust +use masterror::{AppError, ProblemJson}; + +let problem = ProblemJson::from_app_error(AppError::not_found("resource not found")); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +`ProblemJson::from_ref(&err)` borrows instead of consuming, and +`ProblemJson::from_error_response(resp)` upgrades the legacy `ErrorResponse` +wire type. + +See also: [Error Kinds & Codes](Error-Kinds-and-Codes-en) · [Integrations](Integrations-en) · [Observability](Observability-en) · [Feature Flags](Feature-Flags-en) diff --git a/wiki/_Footer.md b/wiki/_Footer.md new file mode 100644 index 0000000..35ea6a9 --- /dev/null +++ b/wiki/_Footer.md @@ -0,0 +1,12 @@ +--- + +
+ +[![Crates.io](https://img.shields.io/crates/v/masterror.svg?style=flat-square)](https://crates.io/crates/masterror) +[![Documentation](https://img.shields.io/docsrs/masterror?style=flat-square)](https://docs.rs/masterror) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/RAprogramm/masterror/blob/main/LICENSES/MIT.txt) +[![GitHub](https://img.shields.io/badge/GitHub-repo-black?style=flat-square&logo=github)](https://github.com/RAprogramm/masterror) + +[🇬🇧 English](Home-en) | [🇷🇺 Русский](Главная) | [🇰🇷 한국어](홈) + +
diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..1d5cb31 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,78 @@ +## 🌐 Language + +[🇬🇧 English](https://github.com/RAprogramm/masterror/wiki/Home-en) | [🇷🇺 Русский](https://github.com/RAprogramm/masterror/wiki/Главная) | [🇰🇷 한국어](https://github.com/RAprogramm/masterror/wiki/홈) + +--- + +## 🇬🇧 English + +**[Home](https://github.com/RAprogramm/masterror/wiki/Home-en)** + +**Getting Started** +- [Getting Started](https://github.com/RAprogramm/masterror/wiki/Getting-Started-en) +- [Feature Flags](https://github.com/RAprogramm/masterror/wiki/Feature-Flags-en) + +**Core Concepts** +- [Error Kinds & Codes](https://github.com/RAprogramm/masterror/wiki/Error-Kinds-and-Codes-en) +- [Derive Macros](https://github.com/RAprogramm/masterror/wiki/Derive-Macros-en) +- [Context & Metadata](https://github.com/RAprogramm/masterror/wiki/Context-and-Metadata-en) + +**Integrations** +- [Web Frameworks](https://github.com/RAprogramm/masterror/wiki/Web-Frameworks-en) +- [Integrations](https://github.com/RAprogramm/masterror/wiki/Integrations-en) +- [Observability](https://github.com/RAprogramm/masterror/wiki/Observability-en) + +**Advanced** +- [no_std](https://github.com/RAprogramm/masterror/wiki/No-Std-en) +- [Best Practices](https://github.com/RAprogramm/masterror/wiki/Best-Practices-en) +- [Migration](https://github.com/RAprogramm/masterror/wiki/Migration-en) + +--- + +## 🇷🇺 Русский + +**[Главная](https://github.com/RAprogramm/masterror/wiki/Главная)** + +**Начало работы** +- [Начало работы](https://github.com/RAprogramm/masterror/wiki/Начало-работы) +- [Флаги возможностей](https://github.com/RAprogramm/masterror/wiki/Флаги-возможностей) + +**Основы** +- [Виды и коды ошибок](https://github.com/RAprogramm/masterror/wiki/Виды-и-коды-ошибок) +- [Derive-макросы](https://github.com/RAprogramm/masterror/wiki/Derive-макросы) +- [Контекст и метаданные](https://github.com/RAprogramm/masterror/wiki/Контекст-и-метаданные) + +**Интеграции** +- [Веб-фреймворки](https://github.com/RAprogramm/masterror/wiki/Веб-фреймворки) +- [Интеграции](https://github.com/RAprogramm/masterror/wiki/Интеграции) +- [Наблюдаемость](https://github.com/RAprogramm/masterror/wiki/Наблюдаемость) + +**Продвинутое** +- [Без std](https://github.com/RAprogramm/masterror/wiki/Без-std) +- [Лучшие практики](https://github.com/RAprogramm/masterror/wiki/Лучшие-практики) +- [Миграция](https://github.com/RAprogramm/masterror/wiki/Миграция) + +--- + +## 🇰🇷 한국어 + +**[홈](https://github.com/RAprogramm/masterror/wiki/홈)** + +**시작하기** +- [시작하기](https://github.com/RAprogramm/masterror/wiki/시작하기) +- [기능 플래그](https://github.com/RAprogramm/masterror/wiki/기능-플래그) + +**핵심 개념** +- [오류 종류와 코드](https://github.com/RAprogramm/masterror/wiki/오류-종류와-코드) +- [Derive 매크로](https://github.com/RAprogramm/masterror/wiki/Derive-매크로) +- [컨텍스트와 메타데이터](https://github.com/RAprogramm/masterror/wiki/컨텍스트와-메타데이터) + +**통합** +- [웹 프레임워크](https://github.com/RAprogramm/masterror/wiki/웹-프레임워크) +- [통합](https://github.com/RAprogramm/masterror/wiki/통합) +- [관측성](https://github.com/RAprogramm/masterror/wiki/관측성) + +**고급** +- [no_std 지원](https://github.com/RAprogramm/masterror/wiki/no_std-지원) +- [모범 사례](https://github.com/RAprogramm/masterror/wiki/모범-사례) +- [마이그레이션](https://github.com/RAprogramm/masterror/wiki/마이그레이션) From 66e15d2670a8b0df84b6b0be7c0e39c0c1f3974d Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 12:32:44 +0700 Subject: [PATCH 2/2] #448 docs: add Russian and Korean wiki translations --- ...20\272\321\200\320\276\321\201\321\213.md" | 259 ++++++++++++++++++ ...e-\353\247\244\355\201\254\353\241\234.md" | 259 ++++++++++++++++++ "wiki/no_std-\354\247\200\354\233\220.md" | 114 ++++++++ "wiki/\320\221\320\265\320\267-std.md" | 117 ++++++++ ...20\262\320\276\321\200\320\272\320\270.md" | 211 ++++++++++++++ ...21\210\320\270\320\261\320\276\320\272.md" | 172 ++++++++++++ ...20\260\320\262\320\275\320\260\321\217.md" | 131 +++++++++ ...21\200\320\260\321\206\320\270\320\270.md" | 201 ++++++++++++++ ...20\260\320\275\320\275\321\213\320\265.md" | 182 ++++++++++++ ...20\272\321\202\320\270\320\272\320\270.md" | 169 ++++++++++++ ...21\200\320\260\321\206\320\270\321\217.md" | 142 ++++++++++ ...20\274\320\276\321\201\321\202\321\214.md" | 163 +++++++++++ ...20\260\320\261\320\276\321\202\321\213.md" | 193 +++++++++++++ ...20\276\321\201\321\202\320\265\320\271.md" | 131 +++++++++ .../\352\264\200\354\270\241\354\204\261.md" | 158 +++++++++++ ...5-\355\224\214\353\236\230\352\267\270.md" | 131 +++++++++ ...70\353\240\210\354\235\264\354\205\230.md" | 137 +++++++++ ...0\353\262\224-\354\202\254\353\241\200.md" | 163 +++++++++++ ...34\354\236\221\355\225\230\352\270\260.md" | 193 +++++++++++++ ...0\354\231\200-\354\275\224\353\223\234.md" | 172 ++++++++++++ ...10\354\236\204\354\233\214\355\201\254.md" | 209 ++++++++++++++ ...00\353\215\260\354\235\264\355\204\260.md" | 182 ++++++++++++ "wiki/\355\206\265\355\225\251.md" | 200 ++++++++++++++ "wiki/\355\231\210.md" | 131 +++++++++ 24 files changed, 4120 insertions(+) create mode 100644 "wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" create mode 100644 "wiki/Derive-\353\247\244\355\201\254\353\241\234.md" create mode 100644 "wiki/no_std-\354\247\200\354\233\220.md" create mode 100644 "wiki/\320\221\320\265\320\267-std.md" create mode 100644 "wiki/\320\222\320\265\320\261-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272\320\270.md" create mode 100644 "wiki/\320\222\320\270\320\264\321\213-\320\270-\320\272\320\276\320\264\321\213-\320\276\321\210\320\270\320\261\320\276\320\272.md" create mode 100644 "wiki/\320\223\320\273\320\260\320\262\320\275\320\260\321\217.md" create mode 100644 "wiki/\320\230\320\275\321\202\320\265\320\263\321\200\320\260\321\206\320\270\320\270.md" create mode 100644 "wiki/\320\232\320\276\320\275\321\202\320\265\320\272\321\201\321\202-\320\270-\320\274\320\265\321\202\320\260\320\264\320\260\320\275\320\275\321\213\320\265.md" create mode 100644 "wiki/\320\233\321\203\321\207\321\210\320\270\320\265-\320\277\321\200\320\260\320\272\321\202\320\270\320\272\320\270.md" create mode 100644 "wiki/\320\234\320\270\320\263\321\200\320\260\321\206\320\270\321\217.md" create mode 100644 "wiki/\320\235\320\260\320\261\320\273\321\216\320\264\320\260\320\265\320\274\320\276\321\201\321\202\321\214.md" create mode 100644 "wiki/\320\235\320\260\321\207\320\260\320\273\320\276-\321\200\320\260\320\261\320\276\321\202\321\213.md" create mode 100644 "wiki/\320\244\320\273\320\260\320\263\320\270-\320\262\320\276\320\267\320\274\320\276\320\266\320\275\320\276\321\201\321\202\320\265\320\271.md" create mode 100644 "wiki/\352\264\200\354\270\241\354\204\261.md" create mode 100644 "wiki/\352\270\260\353\212\245-\355\224\214\353\236\230\352\267\270.md" create mode 100644 "wiki/\353\247\210\354\235\264\352\267\270\353\240\210\354\235\264\354\205\230.md" create mode 100644 "wiki/\353\252\250\353\262\224-\354\202\254\353\241\200.md" create mode 100644 "wiki/\354\213\234\354\236\221\355\225\230\352\270\260.md" create mode 100644 "wiki/\354\230\244\353\245\230-\354\242\205\353\245\230\354\231\200-\354\275\224\353\223\234.md" create mode 100644 "wiki/\354\233\271-\355\224\204\353\240\210\354\236\204\354\233\214\355\201\254.md" create mode 100644 "wiki/\354\273\250\355\205\215\354\212\244\355\212\270\354\231\200-\353\251\224\355\203\200\353\215\260\354\235\264\355\204\260.md" create mode 100644 "wiki/\355\206\265\355\225\251.md" create mode 100644 "wiki/\355\231\210.md" diff --git "a/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" "b/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" new file mode 100644 index 0000000..ee9f478 --- /dev/null +++ "b/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" @@ -0,0 +1,259 @@ +# Derive-макросы + +`masterror` поставляет два derive-макроса через прилагаемый крейт `masterror-derive`: + +- **`#[derive(Error)]`** — прямая замена `thiserror::Error` (те же атрибуты `#[error]`, `#[from]`, `#[source]`, `#[backtrace]`), расширенная конверсиями `#[app_error(...)]` и телеметрией `#[provide(...)]`. +- **`#[derive(Masterror)]`** — строится на том же синтаксисе и встраивает доменную ошибку напрямую в `masterror::Error` с метаданными, политикой редактирования и таблицами транспортных отображений через `#[masterror(...)]`. + +Оба реэкспортируются из корня: `use masterror::{Error, Masterror};`. + +## Шаблоны `#[error("...")]` + +Шаблон определяет генерируемую реализацию `Display`. Плейсхолдеры ссылаются на поля по имени (`{field}`), индексу кортежа (`{0}`) или через явные аргументы. Разбор выполняет общий крейт `masterror-template`, семантика повторяет `thiserror`. + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{kind}: {message}")] +struct NamedError { + kind: &'static str, + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{0} -> {1:?}")] +struct TupleError(&'static str, u8); +``` + +### Трейты форматирования и спецификаторы + +Плейсхолдеры поддерживают полный набор форматтеров — `{x:?}`, `{x:#?}`, `{x:x}`, `{x:#X}`, `{x:b}`, `{x:o}`, `{x:e}`, `{x:E}`, `{x:p}` — а display-спецификаторы вроде `{value:>8}` или `{ratio:.3}` пробрасываются как есть. Для программного анализа шаблонов `masterror::error::template` предоставляет `ErrorTemplate`, `TemplateFormatter` и `TemplateFormatterKind`. + +### Аргументы форматирования и проекции + +Шаблоны принимают именованные и позиционные аргументы, включая выражения над `self` и проекции полей через сокращение `.field`: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{formatted}", formatted = self.message.to_uppercase())] +struct FormatArgExpressionError { + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{}, {label}, {}", label = self.label, self.first, self.second)] +struct MixedImplicitArgsError { + label: &'static str, + first: &'static str, + second: &'static str +} + +#[derive(Debug, Error)] +#[error("{value}", value = .value)] +struct FieldShortcutError { + value: &'static str +} +``` + +### `transparent` и `fmt = ...` + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("inner failure")] +struct Inner; + +// Forwards Display and source() to the single wrapped field +#[derive(Debug, Error)] +#[error(transparent)] +struct Wrapper(#[from] Inner); + +// Delegate rendering to a function: fields first, formatter last +fn render(count: &usize, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "count={count}") +} + +#[derive(Debug, Error)] +#[error(fmt = crate::render)] +struct CustomFormat { + count: usize +} +``` + +`transparent` требует ровно одно поле и не сочетается с `fmt` или строкой шаблона. `fmt = path` указывает на функцию, принимающую ссылки на все поля и `Formatter` последним аргументом. + +## Атрибуты полей + +| Атрибут | Эффект | +|---|---| +| `#[source]` | Поле возвращается из `source()`. Поддерживается `Option`. | +| `#[from]` | Генерирует `From` для обёртки; подразумевает `#[source]` на том же поле. | +| `#[backtrace]` | Поле хранит `std::backtrace::Backtrace` (или `Option`), доступный через интроспекцию ошибки, либо делегирует к бэктрейсу источника в сочетании с `#[source]`. | + +Автовывод: поле с именем `source` автоматически считается источником, а поле типа `std::backtrace::Backtrace` (или `Option`) распознаётся как бэктрейс без атрибута. + +Enum принимают `#[error]` и `#[from]`/`#[source]`/`#[backtrace]` для каждого варианта: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("leaf failure")] +struct LeafError; + +#[derive(Debug, Error)] +enum EnumError { + #[error("unit failure")] + Unit, + #[error("{code}")] + Code { + code: u16, + #[source] + cause: LeafError + }, + #[error(transparent)] + Wrapped(#[from] LeafError) +} +``` + +## `#[app_error(...)]` — конверсии в AppError + +Описывает, как производная ошибка транслируется в `AppError`/`AppCode`. Опции: `kind` (обязательная), `code` (опциональная), `message` (флаг). + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); + +let code: AppCode = MissingFlag { name: "other" }.into(); +assert_eq!(code, AppCode::BadRequest); +``` + +- `kind = ...` выбирает `AppErrorKind`; генерирует `From for AppError`. +- `code = ...` дополнительно генерирует `From for AppCode`. +- `message` пробрасывает вывод `Display` как публичное сообщение; опустите его, чтобы сообщение осталось внутренним. + +Enum выбирают отображение для каждого варианта, при этом derive всё равно генерирует единственную реализацию `From for AppError`. + +## `#[provide(...)]` — типизированная телеметрия + +Предоставляет типизированный контекст через `std::error::Request` (nightly `error_generic_member_access`; компилируется автоматически при доступности). Поля `Option` регистрируют провайдер, только когда заполнены: + +```rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64 +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot +} +``` + +Потребители извлекают снимок вызовом `std::error::request_ref::(&err)` на доменной ошибке. + +## `#[derive(Masterror)]` — сквозные доменные ошибки + +`#[derive(Masterror)]` генерирует `Display`, `std::error::Error`, `From for masterror::Error` **и** таблицы транспортных отображений на этапе компиляции — всё конфигурируется одним атрибутом `#[masterror(...)]`: + +```rust +use masterror::{ + AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +``` + +### Опции `#[masterror(...)]` + +| Опция | Значение | +|---|---| +| `code = AppCode::...` | Публичный машиночитаемый код | +| `category = AppErrorKind::...` | Семантическая категория (определяет HTTP-статус) | +| `message` | Сделать отформатированный вывод `Display` безопасным публичным сообщением | +| `redact(message)` | Установить `MessageEditPolicy::Redact`, чтобы транспорты удаляли сообщение | +| `redact(fields("name" = hash, "card" = last4))` | Переопределить политики метаданных для полей: `hash`, `last4`, `redact`, `none` | +| `telemetry(expr, ...)` | Выражения, вычисляющиеся в `Option`; заполненные поля вставляются в `Metadata`. `telemetry()` — если полей нет | +| `map.grpc = ` | Код статуса gRPC (совпадает с дискриминантами `tonic::Code`) | +| `map.problem = ""` | URI `type` по RFC 7807 | + +### Генерируемые таблицы отображений + +Для структур derive порождает ассоциированные константы; для enum — массив и срезы, агрегирующие отображения по вариантам: + +| Форма | Константы | +|---|---| +| Структура | `T::HTTP_MAPPING: HttpMapping`, `T::GRPC_MAPPING: Option`, `T::PROBLEM_MAPPING: Option` | +| Enum | `T::HTTP_MAPPINGS: [HttpMapping; N]`, `T::GRPC_MAPPINGS: &'static [GrpcMapping]`, `T::PROBLEM_MAPPINGS: &'static [ProblemMapping]` | + +Типы-дескрипторы живут в `masterror::mapping` (`HttpMapping::status()` выводит HTTP-код из категории; `GrpcMapping::status()` возвращает `i32`; `ProblemMapping::type_uri()` возвращает URI). + +`#[from]`, `#[source]` и `#[backtrace]` продолжают работать под `#[derive(Masterror)]`; источники и захваченные бэктрейсы автоматически прикрепляются к результирующему `masterror::Error`, а источники, обёрнутые в `Arc`, переиспользуются без дополнительного клонирования. + +## Выбор между derive-макросами + +| Потребность | Используйте | +|---|---| +| `Display` + `source` + `From` в стиле thiserror | `#[derive(Error)]` | +| Плюс конверсия в `AppError`/`AppCode` | `#[derive(Error)]` + `#[app_error(...)]` | +| Типизированный контекст через `std::error::Request` | добавьте `#[provide(...)]` | +| Метаданные, политика редактирования, таблицы gRPC/problem+json | `#[derive(Masterror)]` + `#[masterror(...)]` | + +--- + +См. также: [Начало работы](Начало-работы) · [Виды и коды ошибок](Виды-и-коды-ошибок) · [Контекст и метаданные](Контекст-и-метаданные) · [Миграция](Миграция) diff --git "a/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" "b/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" new file mode 100644 index 0000000..070ef55 --- /dev/null +++ "b/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" @@ -0,0 +1,259 @@ +# Derive 매크로 + +`masterror`는 번들된 `masterror-derive` 크레이트를 통해 두 가지 파생을 제공합니다: + +- **`#[derive(Error)]`** — `thiserror::Error`의 드롭인 대체품(동일한 `#[error]`, `#[from]`, `#[source]`, `#[backtrace]` 속성)으로, `#[app_error(...)]` 변환과 `#[provide(...)]` 텔레메트리로 확장되었습니다. +- **`#[derive(Masterror)]`** — 동일한 구문을 기반으로 `#[masterror(...)]`를 통해 메타데이터, 리덕션 정책, 전송 매핑 테이블과 함께 도메인 오류를 `masterror::Error`에 직접 연결합니다. + +둘 다 루트에서 재수출됩니다: `use masterror::{Error, Masterror};`. + +## `#[error("...")]` 템플릿 + +템플릿은 생성되는 `Display` 구현을 결정합니다. 플레이스홀더는 필드 이름(`{field}`), 튜플 인덱스(`{0}`) 또는 명시적 인수를 참조합니다. 파싱은 공유 `masterror-template` 크레이트가 처리하며 `thiserror` 의미론을 미러링합니다. + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{kind}: {message}")] +struct NamedError { + kind: &'static str, + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{0} -> {1:?}")] +struct TupleError(&'static str, u8); +``` + +### 포매터 트레이트와 스펙 + +플레이스홀더는 전체 포매터 팔레트를 지원하며 — `{x:?}`, `{x:#?}`, `{x:x}`, `{x:#X}`, `{x:b}`, `{x:o}`, `{x:e}`, `{x:E}`, `{x:p}` — `{value:>8}`이나 `{ratio:.3}` 같은 디스플레이 전용 스펙은 그대로 전달됩니다. 프로그래밍 방식의 템플릿 검사를 위해 `masterror::error::template`은 `ErrorTemplate`, `TemplateFormatter`, `TemplateFormatterKind`를 노출합니다. + +### 포맷 인수와 프로젝션 + +템플릿은 `self`에 대한 표현식과 `.field` 단축 표기를 통한 필드 프로젝션을 포함하여 이름 있는 인수와 위치 인수를 지원합니다: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{formatted}", formatted = self.message.to_uppercase())] +struct FormatArgExpressionError { + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{}, {label}, {}", label = self.label, self.first, self.second)] +struct MixedImplicitArgsError { + label: &'static str, + first: &'static str, + second: &'static str +} + +#[derive(Debug, Error)] +#[error("{value}", value = .value)] +struct FieldShortcutError { + value: &'static str +} +``` + +### `transparent`와 `fmt = ...` + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("inner failure")] +struct Inner; + +// Forwards Display and source() to the single wrapped field +#[derive(Debug, Error)] +#[error(transparent)] +struct Wrapper(#[from] Inner); + +// Delegate rendering to a function: fields first, formatter last +fn render(count: &usize, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "count={count}") +} + +#[derive(Debug, Error)] +#[error(fmt = crate::render)] +struct CustomFormat { + count: usize +} +``` + +`transparent`는 정확히 하나의 필드를 요구하며 `fmt`나 템플릿 문자열과 함께 사용할 수 없습니다. `fmt = path`는 모든 필드에 대한 참조와 `Formatter`를 받는 함수를 가리킵니다. + +## 필드 속성 + +| 속성 | 효과 | +|---|---| +| `#[source]` | 필드가 `source()`에서 반환됩니다. `Option`가 지원됩니다. | +| `#[from]` | 래퍼에 대한 `From`을 생성합니다. 같은 필드에 `#[source]`를 함축합니다. | +| `#[backtrace]` | 필드가 오류 인트로스펙션을 통해 노출되는 `std::backtrace::Backtrace`(또는 `Option`)를 보유하거나, `#[source]`와 결합되면 소스의 백트레이스에 위임합니다. | + +추론: 문자 그대로 `source`라는 이름의 필드는 자동으로 소스로 취급되며, `std::backtrace::Backtrace`(또는 `Option`) 타입의 필드는 속성 없이도 백트레이스로 인식됩니다. + +열거형은 변형별 `#[error]`와 변형별 `#[from]`/`#[source]`/`#[backtrace]`를 지원합니다: + +```rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("leaf failure")] +struct LeafError; + +#[derive(Debug, Error)] +enum EnumError { + #[error("unit failure")] + Unit, + #[error("{code}")] + Code { + code: u16, + #[source] + cause: LeafError + }, + #[error(transparent)] + Wrapped(#[from] LeafError) +} +``` + +## `#[app_error(...)]` — AppError로의 변환 + +파생된 오류가 `AppError`/`AppCode`로 어떻게 변환되는지 기록합니다. 옵션: `kind`(필수), `code`(선택), `message`(플래그). + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); + +let code: AppCode = MissingFlag { name: "other" }.into(); +assert_eq!(code, AppCode::BadRequest); +``` + +- `kind = ...`는 `AppErrorKind`를 선택하며 `From for AppError`를 생성합니다. +- `code = ...`는 추가로 `From for AppCode`를 생성합니다. +- `message`는 `Display` 출력을 공개 메시지로 전달합니다. 메시지를 내부용으로 유지하려면 생략하세요. + +열거형은 변형별로 매핑을 선택하며, 파생은 여전히 단일 `From for AppError`를 발행합니다. + +## `#[provide(...)]` — 타입 기반 텔레메트리 + +`std::error::Request`(nightly `error_generic_member_access`; 사용 가능할 때 자동으로 컴파일에 포함됨)를 통해 타입 기반 컨텍스트를 노출합니다. `Option` 필드는 값이 채워졌을 때만 프로바이더를 등록합니다: + +```rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64 +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot +} +``` + +소비자는 도메인 오류에 대해 `std::error::request_ref::(&err)`로 스냅샷을 추출합니다. + +## `#[derive(Masterror)]` — 엔드투엔드 도메인 오류 + +`#[derive(Masterror)]`는 `Display`, `std::error::Error`, `From for masterror::Error` **그리고** 컴파일 타임 전송 매핑 테이블까지 생성하며, 모두 하나의 `#[masterror(...)]` 속성으로 구성합니다: + +```rust +use masterror::{ + AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +``` + +### `#[masterror(...)]` 옵션 + +| 옵션 | 의미 | +|---|---| +| `code = AppCode::...` | 공개 기계 판독 가능 코드 | +| `category = AppErrorKind::...` | 의미론적 범주 (HTTP 상태 결정) | +| `message` | 포매팅된 `Display` 출력을 안전한 공개 메시지로 노출 | +| `redact(message)` | 전송에서 메시지를 제거하도록 `MessageEditPolicy::Redact` 설정 | +| `redact(fields("name" = hash, "card" = last4))` | 필드별 메타데이터 정책 재정의: `hash`, `last4`, `redact`, `none` | +| `telemetry(expr, ...)` | `Option`로 평가되는 표현식. 값이 있는 필드는 `Metadata`에 삽입됩니다. 없을 때는 `telemetry()` 사용 | +| `map.grpc = ` | gRPC 상태 코드 (`tonic::Code` 판별값과 일치) | +| `map.problem = ""` | RFC 7807 `type` URI | + +### 생성되는 매핑 테이블 + +구조체의 경우 파생은 연관 상수를 발행하고, 열거형의 경우 변형별 매핑을 집계하는 배열과 슬라이스를 발행합니다: + +| 형태 | 상수 | +|---|---| +| 구조체 | `T::HTTP_MAPPING: HttpMapping`, `T::GRPC_MAPPING: Option`, `T::PROBLEM_MAPPING: Option` | +| 열거형 | `T::HTTP_MAPPINGS: [HttpMapping; N]`, `T::GRPC_MAPPINGS: &'static [GrpcMapping]`, `T::PROBLEM_MAPPINGS: &'static [ProblemMapping]` | + +디스크립터 타입은 `masterror::mapping`에 있습니다 (`HttpMapping::status()`는 종류에서 HTTP 코드를 파생하고, `GrpcMapping::status()`는 `i32`를 반환하며, `ProblemMapping::type_uri()`는 URI를 반환합니다). + +`#[from]`, `#[source]`, `#[backtrace]`는 `#[derive(Masterror)]`에서도 계속 동작합니다. 소스와 캡처된 백트레이스는 결과 `masterror::Error`에 자동으로 첨부되며, `Arc`로 감싼 소스는 추가 복제 없이 재사용됩니다. + +## 파생 선택 가이드 + +| 필요 | 사용 | +|---|---| +| thiserror 스타일의 `Display` + `source` + `From` | `#[derive(Error)]` | +| `AppError`/`AppCode`로의 변환까지 | `#[derive(Error)]` + `#[app_error(...)]` | +| `std::error::Request`를 통한 타입 기반 컨텍스트 | `#[provide(...)]` 추가 | +| 메타데이터, 리덕션 정책, gRPC/problem+json 테이블 | `#[derive(Masterror)]` + `#[masterror(...)]` | + +--- + +함께 보기: [시작하기](시작하기) · [오류 종류와 코드](오류-종류와-코드) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [마이그레이션](마이그레이션) diff --git "a/wiki/no_std-\354\247\200\354\233\220.md" "b/wiki/no_std-\354\247\200\354\233\220.md" new file mode 100644 index 0000000..d69f817 --- /dev/null +++ "b/wiki/no_std-\354\247\200\354\233\220.md" @@ -0,0 +1,114 @@ +# no_std 지원 + +`masterror`는 Rust 표준 라이브러리 없이 빌드됩니다. 크레이트 루트는 +`#![cfg_attr(not(feature = "std"), no_std)]`를 선언하며, 기본 `std` 기능이 +임베디드/WASM 친화적 빌드와 여러분 사이에 있는 유일한 장벽입니다: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +``` + +## alloc 필요 + +`masterror`는 `no_std`이지만 `no_alloc`은 **아닙니다**. 크레이트는 +무조건 `extern crate alloc`을 선언하고 메시지, 메타데이터 및 소스 체인에 +`Cow<'static, str>`, `String`, `Arc` 및 `BTreeMap`을 사용합니다. 타깃에 +전역 할당자가 필요합니다. 순수 `core` 전용 환경은 지원되지 않습니다. + +## `std` 없이 동작하는 것 + +프레임워크에 독립적인 코어 전체: + +| 영역 | `no_std`에서 사용 가능 | +|---|---| +| 코어 타입 | `Error` / `AppError`, `AppResult`, `AppErrorKind`, `AppCode` | +| 메타데이터 | `Metadata`, `Field`, `FieldValue`, `FieldRedaction`, `field::*` 헬퍼 | +| 컨텍스트 | `Context`, `ResultExt::{ctx, context}` | +| 제어 흐름 | `ensure!`, `fail!` | +| 파생 | 모든 속성을 지원하는 `#[derive(Error)]`, `#[derive(Masterror)]` | +| 와이어 타입 | `ProblemJson`, `ErrorResponse`, `CODE_MAPPINGS`, `mapping_for_code` | +| 인트로스펙션 | `chain()`, `root_cause()`, `is`/`downcast`/`downcast_ref`/`downcast_mut`, `render_message()` | +| Serde | `alloc`과 함께 `serde` (와이어 타입의 JSON 직렬화) | + +오류 소스는 **`core::error::Error`**를 통해 동작합니다: 크레이트는 +`std::error::Error` 대신 `core::error::Error`(내부적으로 `CoreError`로 +별칭됨)를 구현하고 소비하므로, `with_source(...)`, 소스 체인 및 다운캐스팅이 +`no_std` 빌드에서 완전히 동작합니다. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, field}; + +let err = AppError::new(AppErrorKind::Timeout, "deadline exceeded") + .with_field(field::u64("attempt", 3)); + +assert_eq!(err.code, AppCode::Timeout); +assert_eq!(err.metadata().len(), 1); +``` + +## `std`가 필요한 것 + +모든 런타임 통합은 기능 정의에서 명시적으로 `std`를 다시 활성화합니다. +`Cargo.toml` 기준: + +- `tracing`, `metrics`, `backtrace`, `colored` +- `axum`, `actix`, `multipart`, `tonic`, `openapi` +- `serde_json`, `redis`, `validator`, `config`, `tokio`, `reqwest`, + `teloxide`, `init-data`, `frontend`, `turnkey` + +`backtrace`는 `std::backtrace::Backtrace`와 환경 변수 접근이 필요하고, +`colored`는 TTY 감지가 필요하며, 웹 및 클라이언트 통합은 그 자체가 `std` +전용인 호스트 크레이트가 필요합니다. + +## CI 기능 매트릭스 + +`no_std` CI 잡(`.github/workflows/ci.yml`)은 모든 풀 리퀘스트와 `main` +푸시마다 다음 조합을 검사합니다: + +| 잡 | 명령 | 검증 내용 | +|---|---|---| +| `bare` | `cargo check --no-default-features` | 진정한 `no_std` + `alloc` 빌드 | +| `std-only` | `cargo check --features std` | 기본 std 표면 | +| `tracing` | `cargo check --no-default-features --features tracing` | 단일 텔레메트리 기능의 독립 빌드 | +| `metrics` | `cargo check --no-default-features --features metrics` | metrics도 동일 | +| `colored` | `cargo check --no-default-features --features colored` | colored도 동일 | +| `all-features` | `cargo check --all-features` | 전체 기능 합집합 | + +의미에 주의하세요: `bare` 잡만이 진정한 `no_std` 컴파일입니다. +`tracing = [..., "std"]`, `metrics = [..., "std"]` 및 +`colored = [..., "std"]`는 전이적으로 `std`를 다시 활성화하므로, 해당 잡들은 +기본 기능이 꺼져 있을 때 각 텔레메트리 기능이 자족적인지 검증할 뿐 — +텔레메트리가 표준 라이브러리 없이 동작한다는 뜻은 아닙니다. 텔레메트리가 +필요하면 `std`가 필요합니다. + +## 실용적 구성 + +전송에 독립적이고 `no_std` 호환을 유지하려는 라이브러리 크레이트: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } + +[features] +std = ["masterror/std"] +``` + +바이너리 또는 서비스 크레이트는 `std`와 필요한 통합을 함께 켭니다: + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "tracing", "metrics"] } +``` + +`AppErrorKind`, `AppCode` 및 와이어 타입이 `no_std` 코어에 있기 때문에, +도메인 크레이트는 오류를 분류하고 `ProblemJson` 페이로드까지 빌드할 수 +있으며, HTTP 매핑은 서비스 크레이트에서만 이루어집니다 — +[모범 사례](모범-사례)를 참조하세요. + +## 툴체인 + +크레이트는 `Cargo.toml`에서 `rust-version = "1.96"`으로 에디션 2024를 +대상으로 합니다. `no_std` 소스 체인의 기반인 `core::error::Error`는 +Rust 1.81부터 안정화되었으므로 nightly 기능은 전혀 사용되지 않습니다. + +함께 보기: [기능 플래그](기능-플래그) · [시작하기](시작하기) · [모범 사례](모범-사례) diff --git "a/wiki/\320\221\320\265\320\267-std.md" "b/wiki/\320\221\320\265\320\267-std.md" new file mode 100644 index 0000000..d39cfb0 --- /dev/null +++ "b/wiki/\320\221\320\265\320\267-std.md" @@ -0,0 +1,117 @@ +# Поддержка no_std + +`masterror` собирается без стандартной библиотеки Rust. Корень крейта +объявляет `#![cfg_attr(not(feature = "std"), no_std)]`, и включённая по +умолчанию функция `std` — единственное, что отделяет вас от сборки, дружелюбной +к embedded/WASM: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +``` + +## alloc обязателен + +`masterror` — это `no_std`, но **не** `no_alloc`. Крейт безусловно объявляет +`extern crate alloc` и использует `Cow<'static, str>`, `String`, `Arc` и +`BTreeMap` для сообщений, метаданных и цепочек источников. Вашей целевой +платформе нужен глобальный аллокатор; окружения только с `core` не +поддерживаются. + +## Что работает без `std` + +Всё фреймворк-независимое ядро: + +| Область | Доступно в `no_std` | +|---|---| +| Основные типы | `Error` / `AppError`, `AppResult`, `AppErrorKind`, `AppCode` | +| Метаданные | `Metadata`, `Field`, `FieldValue`, `FieldRedaction`, хелперы `field::*` | +| Контекст | `Context`, `ResultExt::{ctx, context}` | +| Управление потоком | `ensure!`, `fail!` | +| Derive | `#[derive(Error)]`, `#[derive(Masterror)]` со всеми атрибутами | +| Типы на проводе | `ProblemJson`, `ErrorResponse`, `CODE_MAPPINGS`, `mapping_for_code` | +| Интроспекция | `chain()`, `root_cause()`, `is`/`downcast`/`downcast_ref`/`downcast_mut`, `render_message()` | +| Serde | `serde` с `alloc` (JSON-сериализация типов на проводе) | + +Источники ошибок работают через **`core::error::Error`**: крейт реализует и +использует `core::error::Error` (внутренний псевдоним `CoreError`) вместо +`std::error::Error`, поэтому `with_source(...)`, цепочки источников и +даункастинг полностью функциональны в сборках `no_std`. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, field}; + +let err = AppError::new(AppErrorKind::Timeout, "deadline exceeded") + .with_field(field::u64("attempt", 3)); + +assert_eq!(err.code, AppCode::Timeout); +assert_eq!(err.metadata().len(), 1); +``` + +## Что требует `std` + +Каждая runtime-интеграция явно включает `std` обратно в определении своей +функции. Из `Cargo.toml`: + +- `tracing`, `metrics`, `backtrace`, `colored` +- `axum`, `actix`, `multipart`, `tonic`, `openapi` +- `serde_json`, `redis`, `validator`, `config`, `tokio`, `reqwest`, + `teloxide`, `init-data`, `frontend`, `turnkey` + +`backtrace` нуждается в `std::backtrace::Backtrace` и доступе к окружению; +`colored` — в детекции TTY; веб- и клиентским интеграциям нужны их хост-крейты, +которые сами работают только со `std`. + +## Матрица функций в CI + +CI-задача `no_std` (`.github/workflows/ci.yml`) проверяет эти комбинации на +каждом pull request и push в `main`: + +| Задача | Команда | Что проверяет | +|---|---|---| +| `bare` | `cargo check --no-default-features` | настоящую сборку `no_std` + `alloc` | +| `std-only` | `cargo check --features std` | стандартную поверхность std | +| `tracing` | `cargo check --no-default-features --features tracing` | что одиночная телеметрическая функция собирается автономно | +| `metrics` | `cargo check --no-default-features --features metrics` | то же для metrics | +| `colored` | `cargo check --no-default-features --features colored` | то же для colored | +| `all-features` | `cargo check --all-features` | полное объединение функций | + +Обратите внимание на семантику: только задача `bare` — настоящая компиляция +`no_std`. `tracing = [..., "std"]`, `metrics = [..., "std"]` и +`colored = [..., "std"]` транзитивно включают `std` обратно, поэтому эти +задачи проверяют, что каждая телеметрическая функция самодостаточна при +отключённых значениях по умолчанию — а не то, что телеметрия работает без +стандартной библиотеки. Если нужна телеметрия — нужен `std`. + +## Практическая настройка + +Библиотечные крейты, которые хотят оставаться транспортно-независимыми и +совместимыми с `no_std`: + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } + +[features] +std = ["masterror/std"] +``` + +Бинарный или сервисный крейт затем включает `std` плюс нужные ему интеграции: + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "tracing", "metrics"] } +``` + +Поскольку `AppErrorKind`, `AppCode` и типы на проводе живут в ядре `no_std`, +доменные крейты могут классифицировать ошибки и даже строить полезные нагрузки +`ProblemJson`, тогда как отображение на HTTP происходит только в сервисном +крейте — см. [Лучшие практики](Лучшие-практики). + +## Тулчейн + +Крейт нацелен на edition 2024 с `rust-version = "1.96"` в `Cargo.toml`. +`core::error::Error` (основа цепочек источников в `no_std`) стабилен начиная с +Rust 1.81, поэтому никакие nightly-функции не задействованы. + +См. также: [Флаги возможностей](Флаги-возможностей) · [Начало работы](Начало-работы) · [Лучшие практики](Лучшие-практики) diff --git "a/wiki/\320\222\320\265\320\261-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272\320\270.md" "b/wiki/\320\222\320\265\320\261-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272\320\270.md" new file mode 100644 index 0000000..40339c0 --- /dev/null +++ "b/wiki/\320\222\320\265\320\261-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272\320\270.md" @@ -0,0 +1,211 @@ +# Веб-фреймворки + +`masterror` отображает ошибки на HTTP на транспортной границе. Доменный код +возвращает [`AppResult`](Виды-и-коды-ошибок); адаптер фреймворка +преобразует ошибку в ответ RFC 7807 `application/problem+json`, сбрасывает +телеметрию и применяет редактирование. В крейте существует ровно одна +реализация `IntoResponse` / `ResponseError` для `AppError` — вам никогда не +придётся писать отображение вручную. + +## Флаги функций + +| Флаг | Что включает | +|---|---| +| `axum` | `IntoResponse` для `AppError`, `ProblemJson`, `ErrorResponse`; подтягивает `serde_json` | +| `actix` | `ResponseError` для `AppError`; `Responder` для `ProblemJson`, `ErrorResponse` | +| `multipart` | `From` для `Error` (подразумевает `axum`) | +| `openapi` | Схема `utoipa` для `ErrorResponse` | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum"] } # or ["actix"] +``` + +## Формат на проводе + +Оба адаптера сериализуют [`ProblemJson`](https://docs.rs/masterror/latest/masterror/struct.ProblemJson.html): + +| Поле | Тип | Примечания | +|---|---|---| +| `type` | строка-URI | Канонический класс проблемы, например `https://errors.masterror.rs/not-found` | +| `title` | строка | Краткое описание, выводимое из `AppErrorKind` | +| `status` | число | Код статуса HTTP | +| `detail` | строка? | Публичное сообщение; **опускается, когда ошибка редактируемая** | +| `details` | объект? | Структурированные детали (функция `serde_json`) | +| `code` | строка | Стабильный машиночитаемый `AppCode`, например `NOT_FOUND` | +| `grpc` | объект? | Отображение gRPC `{ name, value }` для мультипротокольных клиентов | +| `metadata` | объект? | Очищенные поля из `Metadata`; опускается при редактировании | + +Транспортные подсказки становятся заголовками, а не полями тела: + +- `AppError::with_retry_after_secs(n)` → `Retry-After: n` +- `AppError::with_www_authenticate(challenge)` → `WWW-Authenticate: challenge` + +Внутренние источники (цепочка `std::error::Error`) только логируются и никогда +не сериализуются клиентам. + +## Axum + +Функция `axum` реализует `IntoResponse` для `AppError`, `ProblemJson` и +`ErrorResponse`, а также собственный метод `AppError::http_status()`, +возвращающий `axum::http::StatusCode`, выводимый из вида ошибки. +Преобразование в ответ сбрасывает телеметрию (событие tracing, счётчик метрик, +ленивый бэктрейс) — см. [Наблюдаемость](Наблюдаемость). + +```rust +use axum::{Router, routing::get}; +use masterror::{AppError, AppResult}; + +async fn handler() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +let app: Router = Router::new().route("/demo", get(handler)); +``` + +`401` с подсказками: + +```rust +use masterror::AppError; + +let err = AppError::unauthorized("missing token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer realm=\"api\""); +``` + +выдаёт статус `401`, заголовки `Retry-After: 7` и +`WWW-Authenticate: Bearer realm="api"`, а также тело: + +```json +{ + "type": "https://errors.masterror.rs/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "missing token", + "code": "UNAUTHORIZED", + "grpc": { "name": "UNAUTHENTICATED", "value": 16 } +} +``` + +### Доменные ошибки в обработчиках + +Паттерн из +[`examples/axum-rest-api`](https://github.com/RAprogramm/masterror/tree/main/examples/axum-rest-api): +выведите доменный enum через derive, один раз преобразуйте его в `AppError`, +а затем переиспользуйте `IntoResponse` из крейта. + +```rust +use axum::response::{IntoResponse, Response}; +use masterror::{AppError, Error}; + +#[derive(Debug, Error, Clone)] +pub enum UserError { + #[error("user not found")] + NotFound, + #[error("email already exists")] + DuplicateEmail, + #[error("invalid email format")] + InvalidEmail +} + +impl From for AppError { + fn from(err: UserError) -> Self { + match err { + UserError::NotFound => AppError::not_found(err.to_string()), + UserError::DuplicateEmail => AppError::conflict(err.to_string()), + UserError::InvalidEmail => AppError::validation(err.to_string()) + } + } +} + +impl IntoResponse for UserError { + fn into_response(self) -> Response { + AppError::from(self).into_response() + } +} +``` + +С `#[app_error(kind = ..., code = ...)]` на derive реализация `From +for AppError` генерируется за вас — см. [Derive-макросы](Derive-макросы). + +## Actix Web + +Функция `actix` реализует `actix_web::ResponseError` для `AppError`, поэтому +обработчики, возвращающие `AppResult`, работают из коробки. +`error_response()` испускает телеметрию и строит ту же полезную нагрузку +problem+json через `ProblemJson::from_ref`. + +```rust,ignore +use actix_web::{App, HttpServer, get}; +use masterror::{AppError, AppResult}; + +#[get("/forbidden")] +async fn forbidden() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| App::new().service(forbidden)) + .bind(("127.0.0.1", 8080))? + .run() + .await +} +``` + +Клиент получает `403` с телом: + +```json +{ + "type": "https://errors.masterror.rs/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "no access", + "code": "FORBIDDEN", + "grpc": { "name": "PERMISSION_DENIED", "value": 7 } +} +``` + +`ProblemJson` и `ErrorResponse` также реализуют `Responder`, поэтому обработчик +может возвращать их напрямую. Отображение статусов использует ту же стабильную +таблицу `AppErrorKind → StatusCode`, что и Axum. + +## Multipart + +`multipart` (подразумевает `axum`) преобразует +`axum::extract::multipart::MultipartError` в `Error` с +`AppErrorKind::BadRequest`, сохраняя сообщение парсера: + +```rust,ignore +use axum::extract::multipart::Multipart; +use masterror::{AppErrorKind, Error}; + +async fn upload(mut multipart: Multipart) -> Result<(), Error> { + while let Some(field) = multipart.next_field().await? { + let _ = field.bytes().await?; + } + Ok(()) +} +``` + +Некорректные клиентские полезные нагрузки проявляются как `400 Bad Request`, +а не как 500. + +## Ручное построение ответов + +Для тестов или собственных транспортов полезную нагрузку можно построить без +фреймворка: + +```rust +use masterror::{AppError, ProblemJson}; + +let problem = ProblemJson::from_app_error(AppError::not_found("resource not found")); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +`ProblemJson::from_ref(&err)` заимствует вместо поглощения, а +`ProblemJson::from_error_response(resp)` повышает устаревший тип на проводе +`ErrorResponse`. + +См. также: [Виды и коды ошибок](Виды-и-коды-ошибок) · [Интеграции](Интеграции) · [Наблюдаемость](Наблюдаемость) · [Флаги возможностей](Флаги-возможностей) diff --git "a/wiki/\320\222\320\270\320\264\321\213-\320\270-\320\272\320\276\320\264\321\213-\320\276\321\210\320\270\320\261\320\276\320\272.md" "b/wiki/\320\222\320\270\320\264\321\213-\320\270-\320\272\320\276\320\264\321\213-\320\276\321\210\320\270\320\261\320\276\320\272.md" new file mode 100644 index 0000000..3cd4342 --- /dev/null +++ "b/wiki/\320\222\320\270\320\264\321\213-\320\270-\320\272\320\276\320\264\321\213-\320\276\321\210\320\270\320\261\320\276\320\272.md" @@ -0,0 +1,172 @@ +# Виды и коды ошибок + +Два типа образуют основу таксономии: + +- **`AppErrorKind`** — *внутренняя* семантическая категория сбоя. Небольшая, стабильная, независимая от фреймворков. Определяет HTTP-статус по умолчанию. +- **`AppCode`** — *публичный* машиночитаемый код, отдаваемый клиентам строкой в SCREAMING_SNAKE_CASE (например, `"NOT_FOUND"`). Часть wire-контракта. + +Каждый `AppError` несёт оба. `AppCode::from(kind)` даёт каноническое отображение 1:1, а `AppError::with_code(...)` переопределяет публичный код, не меняя категорию. + +## Таксономия AppErrorKind + +| Вариант | Значение | HTTP | +|---|---|---| +| `NotFound` | Ресурс не существует или не виден вызывающей стороне | 404 | +| `Validation` | Структурированный ввод не прошёл валидацию | 422 | +| `Conflict` | Конфликт состояния (нарушение уникального ключа, несовпадение версий) | 409 | +| `Unauthorized` | Требуется аутентификация или она не удалась | 401 | +| `Forbidden` | Аутентифицирован, но доступ запрещён | 403 | +| `NotImplemented` | Операция не поддерживается этим развёртыванием | 501 | +| `BadRequest` | Некорректный запрос или отсутствующие параметры | 400 | +| `TelegramAuth` | Сбой процедуры аутентификации Telegram | 401 | +| `InvalidJwt` | JWT просрочен, повреждён или имеет неверную подпись/клеймы | 401 | +| `RateLimited` | Клиент превысил лимиты запросов или квоту | 429 | +| `Timeout` | Операция не завершилась вовремя | 504 | +| `Network` | Ошибка сетевого уровня (DNS, соединение, TLS) | 503 | +| `DependencyUnavailable` | Внешняя зависимость недоступна или деградировала | 503 | +| `Internal` | Неожиданный сбой на стороне сервера | 500 | +| `Database` | Сбой базы данных (запрос, соединение, миграция) | 500 | +| `Service` | Общий сбой сервисного слоя / бизнес-логики | 500 | +| `Config` | Отсутствующая или некорректная конфигурация | 500 | +| `Turnkey` | Сбой подсистемы Turnkey | 500 | +| `Serialization` | Не удалось закодировать данные | 500 | +| `Deserialization` | Не удалось декодировать данные | 500 | +| `ExternalApi` | Вышестоящий API вернул ошибку | 500 | +| `Queue` | Сбой публикации/потребления/подтверждения в очереди | 500 | +| `Cache` | Сбой чтения/записи/кодирования кэша | 500 | + +```rust +use masterror::AppErrorKind; + +let kind = AppErrorKind::NotFound; +assert_eq!(kind.http_status(), 404); // always available, u16 +assert_eq!(kind.label(), "Not found"); // human-readable title +// With the `axum` feature: kind.status_code() -> axum::http::StatusCode +``` + +Правила, заложенные в отображение: проблемы инфраструктуры и ввода/вывода по умолчанию дают 5xx; `Unauthorized` (401) означает, что аутентификация не удалась, `Forbidden` (403) — что аутентификация прошла, но в доступе отказано; используйте `Network` для сбоев соединения/построения запроса и `ExternalApi` для ошибочных HTTP-статусов от вышестоящих сервисов. + +## AppCode + +`AppCode` поставляет константы для каждого вида (`AppCode::NotFound` → `"NOT_FOUND"`, `AppCode::RateLimited` → `"RATE_LIMITED"`, …) плюс `AppCode::UserAlreadyExists` (`"USER_ALREADY_EXISTS"`, отображается как конфликт). Он помечен `#[non_exhaustive]` и поддерживает пользовательские коды: + +```rust +use std::str::FromStr; +use masterror::AppCode; + +// Compile-time literal — panics at compile-time evaluation if not SCREAMING_SNAKE_CASE +const INVALID_JSON: AppCode = AppCode::new("INVALID_JSON"); + +// Runtime value — validated, returns Result +let dynamic = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code"); +assert_eq!(dynamic.as_str(), "THIRD_PARTY_FAILURE"); + +// Parsing round-trips through the same validation +let parsed = AppCode::from_str("NOT_FOUND").expect("known code"); +assert_eq!(parsed, AppCode::NotFound); +``` + +Допустимые коды содержат только `A-Z`, `0-9` и одиночные разделители `_` и сериализуются как обычные JSON-строки. + +## Таблица отображений HTTP / gRPC / problem+json + +`CODE_MAPPINGS` (и функция поиска `mapping_for_code`) задают каноническое транспортное отображение для каждого встроенного кода. Неизвестные пользовательские коды сводятся к `INTERNAL` (500 / gRPC 13): + +| AppCode | HTTP | gRPC | problem `type` | +|---|---|---|---| +| `NOT_FOUND` | 404 | `NOT_FOUND` (5) | `https://errors.masterror.rs/not-found` | +| `VALIDATION` | 422 | `INVALID_ARGUMENT` (3) | `.../validation` | +| `CONFLICT` | 409 | `ALREADY_EXISTS` (6) | `.../conflict` | +| `USER_ALREADY_EXISTS` | 409 | `ALREADY_EXISTS` (6) | `.../user-already-exists` | +| `UNAUTHORIZED` | 401 | `UNAUTHENTICATED` (16) | `.../unauthorized` | +| `FORBIDDEN` | 403 | `PERMISSION_DENIED` (7) | `.../forbidden` | +| `NOT_IMPLEMENTED` | 501 | `UNIMPLEMENTED` (12) | `.../not-implemented` | +| `BAD_REQUEST` | 400 | `INVALID_ARGUMENT` (3) | `.../bad-request` | +| `RATE_LIMITED` | 429 | `RESOURCE_EXHAUSTED` (8) | `.../rate-limited` | +| `TELEGRAM_AUTH` | 401 | `UNAUTHENTICATED` (16) | `.../telegram-auth` | +| `INVALID_JWT` | 401 | `UNAUTHENTICATED` (16) | `.../invalid-jwt` | +| `INTERNAL` | 500 | `INTERNAL` (13) | `.../internal` | +| `DATABASE` | 500 | `INTERNAL` (13) | `.../database` | +| `SERVICE` | 500 | `INTERNAL` (13) | `.../service` | +| `CONFIG` | 500 | `INTERNAL` (13) | `.../config` | +| `TURNKEY` | 500 | `INTERNAL` (13) | `.../turnkey` | +| `TIMEOUT` | 504 | `DEADLINE_EXCEEDED` (4) | `.../timeout` | +| `NETWORK` | 503 | `UNAVAILABLE` (14) | `.../network` | +| `DEPENDENCY_UNAVAILABLE` | 503 | `UNAVAILABLE` (14) | `.../dependency-unavailable` | +| `SERIALIZATION` | 500 | `INTERNAL` (13) | `.../serialization` | +| `DESERIALIZATION` | 500 | `INTERNAL` (13) | `.../deserialization` | +| `EXTERNAL_API` | 500 | `UNAVAILABLE` (14) | `.../external-api` | +| `QUEUE` | 500 | `UNAVAILABLE` (14) | `.../queue` | +| `CACHE` | 500 | `UNAVAILABLE` (14) | `.../cache` | + +Значения gRPC совпадают с дискриминантами `tonic::Code`, поэтому функция `tonic` конвертирует их напрямую. + +```rust +use masterror::{AppCode, mapping_for_code}; + +let mapping = mapping_for_code(&AppCode::Timeout); +assert_eq!(mapping.http_status(), 504); +assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); +assert_eq!(mapping.grpc().value, 4); +assert_eq!(mapping.problem_type(), "https://errors.masterror.rs/timeout"); +``` + +## Подсказки повторных попыток и аутентификации + +Транспортные адаптеры транслируют две опциональные подсказки в HTTP-заголовки: + +```rust +use std::time::Duration; +use masterror::{AppError, AppErrorKind, ProblemJson}; + +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_secs(30) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); + +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); // -> Retry-After header +assert!(problem.www_authenticate.is_some()); // -> WWW-Authenticate header +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); +``` + +У `ErrorResponse` эквивалентные билдеры — `with_retry_after_secs`, `with_retry_after_duration` и `with_www_authenticate`. + +## Семантика редактирования + +Сообщения `AppError` задуманы безопасными для клиентов, но ошибку можно пометить как редактируемую, чтобы граница удалила сообщение: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("host db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); // message stripped +assert!(problem.metadata.is_none()); // metadata stripped too +``` + +Когда `edit_policy` равна `Redact`, `ProblemJson` отбрасывает `detail`, `details` и всю секцию `metadata`. Отдельные поля метаданных дополнительно несут собственную политику `FieldRedaction` (`None`, `Redact`, `Hash`, `Last4`), применяемую при сериализации — см. [Контекст и метаданные](Контекст-и-метаданные). Источники ошибок (`source_ref()`) не сериализуются никогда, независимо от политики. + +## Wire-полезные нагрузки + +**`ProblemJson`** — `application/problem+json` по RFC 7807, создаётся через `ProblemJson::from_app_error` (владеющий вариант) или `ProblemJson::from_ref` (заимствующий). Поля: `type`, `title` (метка вида), `status`, `detail`, опциональный `details`, `code`, `grpc` (`{name, value}`), `metadata`, плюс несериализуемые `retry_after`/`www_authenticate` для заголовков. + +**`ErrorResponse`** — устаревшая плоская JSON-нагрузка: `status`, `code`, `message`, опциональные `details`, `retry`, `www_authenticate`. С функцией `openapi` реализует `utoipa::ToSchema`. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ErrorResponse}; + +let app_err = AppError::new(AppErrorKind::NotFound, "user_not_found"); +let resp: ErrorResponse = (&app_err).into(); +assert_eq!(resp.status, 404); +assert_eq!(resp.code, AppCode::NotFound); +``` + +Для новых API предпочитайте `ProblemJson`; `ErrorResponse` остаётся для сервисов, уже привязанных к плоскому формату. + +--- + +См. также: [Начало работы](Начало-работы) · [Derive-макросы](Derive-макросы) · [Контекст и метаданные](Контекст-и-метаданные) · [Веб-фреймворки](Веб-фреймворки) diff --git "a/wiki/\320\223\320\273\320\260\320\262\320\275\320\260\321\217.md" "b/wiki/\320\223\320\273\320\260\320\262\320\275\320\260\321\217.md" new file mode 100644 index 0000000..6b33599 --- /dev/null +++ "b/wiki/\320\223\320\273\320\260\320\262\320\275\320\260\321\217.md" @@ -0,0 +1,131 @@ +
+ +# masterror + +**Фреймворк-независимые типы ошибок для приложений со стабильными кодами, отображениями HTTP/gRPC и встроенной телеметрией** + +[![Русский](https://img.shields.io/badge/🇷🇺_Русский-blue?style=for-the-badge)](#) +[![English](https://img.shields.io/badge/🇬🇧_English-gray?style=for-the-badge)](Home-en) +[![한국어](https://img.shields.io/badge/🇰🇷_한국어-gray?style=for-the-badge)](홈) + +[![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) +[![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) +![MSRV](https://img.shields.io/badge/MSRV-1.96-blue) +![License](https://img.shields.io/badge/License-MIT-informational) + +
+ +--- + +## Что такое masterror? + +`masterror` — это рабочее пространство для обработки ошибок в сервисах на Rust, которым нужно больше, чем `Display` и `source()`. Там, где `thiserror` ограничивается генерацией реализаций трейтов, а `anyhow` — распространением ошибок со стёртым типом, `masterror` доводит ошибку до самой транспортной границы: + +- **`AppError`** — насыщенное значение ошибки с семантической категорией (`AppErrorKind`), стабильным машиночитаемым кодом (`AppCode`), опциональным безопасным публичным сообщением, структурированными метаданными и транспортными подсказками (`Retry-After`, `WWW-Authenticate`). +- **Консервативные отображения HTTP и gRPC** — каждый вид и код детерминированно отображается в HTTP-статус, дискриминант `tonic::Code` и URI `type` по RFC 7807. +- **Типизированная телеметрия** — метаданные хранятся как типизированные поля (строки, целые числа, числа с плавающей точкой, длительности, IP-адреса, UUID, JSON) с политиками редактирования для каждого поля, а не как самодельные `String`-карты. +- **Нативные derive-макросы** — `#[derive(Error)]` повторяет синтаксис `thiserror`, а `#[app_error(...)]` и `#[derive(Masterror)]` встраивают доменные ошибки в `AppError` с кодами, категориями, редактированием и таблицами отображений. +- **Редактирование по замыслу** — источники никогда не сериализуются для клиентов; сообщения, детали и поля метаданных могут быть удалены, захешированы или замаскированы на границе. + +Без `unsafe`, с зафиксированным MSRV и поддержкой `no_std` при отключённой функции `std`, включённой по умолчанию. + +## Какую проблему он решает + +| Задача | `thiserror` | `anyhow` | `masterror` | +|---|---|---|---| +| Derive для `Display` / `source()` | Да | — | Да (тот же синтаксис) | +| Распространение со стёртым типом и контекстом | — | Да | Да (`.ctx()` / `.context()`) | +| Стабильные машиночитаемые коды ошибок | Вручную | Вручную | `AppCode`, часть wire-контракта | +| Отображение в HTTP-статус | Вручную | Вручную | `AppErrorKind::http_status()`, стабильная таблица | +| Отображение в gRPC-статус | Вручную | Вручную | `CODE_MAPPINGS`, конверсия в `tonic::Status` | +| RFC 7807 `problem+json` | Вручную | Вручную | `ProblemJson::from_app_error` | +| Структурированные типизированные метаданные | — | — | `Metadata` + конструкторы `field::*` | +| Редактирование секретов на границе | — | — | `MessageEditPolicy`, `FieldRedaction` | +| Эмиссия tracing / metrics / backtrace | — | — | За флагами возможностей, автоматически при создании | + +Enum, произведённый `thiserror`, говорит, *что случилось*. `masterror` дополнительно решает, *что увидит клиент* (статус, код, безопасное сообщение, problem+json), *что увидят операторы* (структурированные поля, события tracing, счётчики) и *что никогда не утечёт* (источники, отредактированные поля). + +## Основные возможности + +| Область | Что вы получаете | +|---|---| +| Базовая таксономия | `AppError`, `AppErrorKind` (23 стабильные категории), `AppCode` (коды в SCREAMING_SNAKE_CASE, поддержка собственных кодов), `AppResult` | +| Derive-макросы | `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error(...)]`, `#[masterror(...)]`, провайдеры телеметрии `#[provide(...)]` | +| Управление потоком | `ensure!` / `fail!` — типизированные ранние возвраты без выделения памяти | +| Контекст | `ResultExt::ctx` / `ResultExt::context`, билдер `Context` с отслеживанием вызывающей стороны | +| Wire-полезные нагрузки | `ErrorResponse` (устаревший JSON), `ProblemJson` (RFC 7807) с подсказками повторных попыток и аутентификации | +| Транспорты | Axum `IntoResponse`, Actix `ResponseError`/`Responder`, `tonic::Status`, WASM `JsValue`, схема OpenAPI | +| Интеграции | `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `teloxide`, init data Telegram Mini Apps, Turnkey | +| Наблюдаемость | События `tracing`, счётчики `metrics`, ленивый захват `backtrace`, цветной вывод в терминале, `DisplayMode` (prod/staging/local) | + +## Быстрый пример + +```rust +use masterror::{AppError, AppErrorKind, AppResult, ProblemJson, field}; + +fn find_user(id: u64) -> AppResult<()> { + masterror::ensure!(id != 0, AppError::bad_request("id must be non-zero")); + + Err(AppError::not_found("user not found") + .with_field(field::u64("user_id", id)) + .with_field(field::str("request_id", "abc123"))) +} + +let err = find_user(42).unwrap_err(); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.kind.http_status(), 404); + +let problem = ProblemJson::from_app_error(err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +assert_eq!(problem.grpc.expect("grpc").name, "NOT_FOUND"); +``` + +Или объявите доменную ошибку один раз и позвольте derive-макросу выполнить отображение: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); +``` + +## Крейты рабочего пространства + +| Крейт | Роль | +|---|---| +| [`masterror`](https://crates.io/crates/masterror) | Основные типы ошибок, метаданные, транспорты, интеграции, прелюдия | +| [`masterror-derive`](https://crates.io/crates/masterror-derive) | Процедурные макросы за `#[derive(Error)]` и `#[derive(Masterror)]` (подключается автоматически) | +| [`masterror-template`](https://crates.io/crates/masterror-template) | Общий парсер шаблонов `#[error("...")]` | + +## Документация + +**Начало работы** + +- [Начало работы](Начало-работы) — установка, первые ошибки, макросы, первый derive +- [Флаги возможностей](Флаги-возможностей) — полный справочник флагов с зависимостями + +**Основные концепции** + +- [Виды и коды ошибок](Виды-и-коды-ошибок) — таксономия, таблицы HTTP/gRPC, problem+json +- [Derive-макросы](Derive-макросы) — `#[derive(Error)]`, `#[derive(Masterror)]` и их атрибуты +- [Контекст и метаданные](Контекст-и-метаданные) — `Context`, `ResultExt`, поля, редактирование, цепочки + +**Интеграции** + +- [Веб-фреймворки](Веб-фреймворки) — Axum, Actix, tonic +- [Интеграции](Интеграции) — sqlx, redis, reqwest, validator и другие +- [Наблюдаемость](Наблюдаемость) — tracing, metrics, бэктрейсы, режимы отображения + +**Продвинутое** + +- [Без std](Без-std) — работа без стандартной библиотеки +- [Лучшие практики](Лучшие-практики) — паттерны для сервисов и библиотек +- [Миграция](Миграция) — переход с `thiserror` / `anyhow` diff --git "a/wiki/\320\230\320\275\321\202\320\265\320\263\321\200\320\260\321\206\320\270\320\270.md" "b/wiki/\320\230\320\275\321\202\320\265\320\263\321\200\320\260\321\206\320\270\320\270.md" new file mode 100644 index 0000000..c426b1e --- /dev/null +++ "b/wiki/\320\230\320\275\321\202\320\265\320\263\321\200\320\260\321\206\320\270\320\270.md" @@ -0,0 +1,201 @@ +# Интеграции + +Опциональные флаги функций добавляют конверсии `From<...>` из популярных +сторонних типов ошибок в `masterror::Error`, так что один `?` в месте вызова +даёт классифицированную ошибку со структурированными метаданными. Каждая +конверсия выбирает стабильный [`AppErrorKind`](Виды-и-коды-ошибок) и +присоединяет телеметрические поля (никогда — секреты) для наблюдаемости. + +## Матрица конверсий + +| Флаг | Исходный тип | Результирующий `AppErrorKind` | +|---|---|---| +| `sqlx` | `sqlx_core::error::Error` | `NotFound`, `Conflict`, `Validation`, `Timeout`, `DependencyUnavailable`, `Config`, `BadRequest`, `Serialization`, `Deserialization`, `Network`, `Database`, `Internal` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` | `Database` (с метаданными фазы миграции) | +| `redis` | `redis::RedisError` | `Cache` (по умолчанию), `Timeout`, `DependencyUnavailable` | +| `reqwest` | `reqwest::Error` | `Timeout`, `Network`, `RateLimited`, `DependencyUnavailable`, `ExternalApi` | +| `validator` | `validator::ValidationErrors` | `Validation` | +| `config` | `config::ConfigError` | `Config` (с метаданными `config.phase`) | +| `tokio` | `tokio::time::error::Elapsed` | `Timeout` | +| `serde_json` | `serde_json::Error` | `Serialization` (I/O), `Deserialization` (синтаксис/данные/EOF) | +| `teloxide` | `teloxide_core::RequestError` | `ExternalApi`, `Unauthorized`, `RateLimited`, `Network`, `Deserialization`, `Internal` | +| `init-data` | `init_data_rs::InitDataError` | `TelegramAuth` | +| `tonic` | `masterror::Error` → `tonic::Status` | исходящее отображение, см. ниже | +| `multipart` | `axum::extract::multipart::MultipartError` | `BadRequest` (см. [Веб-фреймворки](Веб-фреймворки)) | + +## sqlx и sqlx-migrate + +`sqlx` зависит только от `sqlx-core` (без драйверов, без TLS). Ключевые +отображения: + +- `Error::RowNotFound` → `NotFound` +- Таймаут пула → `Timeout`; закрытый пул и сбои I/O → + `DependencyUnavailable`; ошибки TLS → `Network` +- Нарушения ограничений классифицируются по виду ошибки `sqlx`: нарушения + уникальности и внешних ключей → `Conflict`, нарушения not-null / check → + `Validation`, всё остальное → `Database` +- Кодирование → `Serialization`, декодирование → `Deserialization` + +Ошибки базы данных сохраняют SQLSTATE и имена ограничений в метаданных. +Известные коды SQLSTATE переопределяют публичный +[`AppCode`](Виды-и-коды-ошибок): `23505` → `USER_ALREADY_EXISTS`, `23503` → +`CONFLICT`, `23502`/`23514` → `VALIDATION`. Транзиентные SQLSTATE (`40001` — +сбой сериализации, `55P03` — блокировка недоступна) присоединяют подсказки +для повторных попыток. + +```rust,ignore +use masterror::{AppErrorKind, Error}; + +async fn load_user(pool: &sqlx::PgPool, id: i64) -> Result { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; // RowNotFound becomes AppErrorKind::NotFound + Ok(user) +} +``` + +`sqlx-migrate` подтягивает полный `sqlx` (всё ещё без функций по умолчанию) +и отображает `sqlx::migrate::MigrateError` в `Database`, записывая фазу и +версию миграции в метаданные. + +## redis + +Все значения `redis::RedisError` по умолчанию отображаются в `Cache`; ошибки +с характером таймаута становятся `Timeout`, а сбои соединения — +`DependencyUnavailable`. Категория и код ошибки сохраняются в метаданных. + +## reqwest + +Рассматривает `reqwest` как клиент внешнего HTTP API: + +- `is_timeout()` → `Timeout` +- `is_connect()` / `is_request()` → `Network` +- Ошибки HTTP-статусов: `429` → `RateLimited`, `408` → `Timeout`, `5xx` → + `DependencyUnavailable`, остальные → `ExternalApi` +- всё остальное → `ExternalApi` + +Метаданные записывают конечную точку, статус и низкоуровневые флаги; URL +помечается для хеширования/редактирования в публичных полезных нагрузках. + +## validator + +`validator::ValidationErrors` → `Validation`, с агрегированным контекстом в +метаданных: имена невалидных полей (`validation.fields`), количество полей и +ошибок, а также первые коды валидации (`validation.codes`): + +```rust,ignore +use masterror::AppResult; +use validator::Validate; + +#[derive(Validate)] +struct Payload { + #[validate(length(min = 5))] + name: String +} + +fn check(p: &Payload) -> AppResult<()> { + p.validate()?; // ValidationErrors -> AppErrorKind::Validation + Ok(()) +} +``` + +## config, tokio, serde_json + +- `config::ConfigError` → `Config`, с полем метаданных `config.phase` + (`not_found`, `file_parse`, `type`, ...), идентифицирующим стадию сбоя. +- `tokio::time::error::Elapsed` → `Timeout` с полем метаданных + `timeout.source = "tokio::time::timeout"`. Ошибка не несёт собственного + сообщения, поэтому клиенты видят фиксированный заголовок вида + `"Operation timed out"`. +- `serde_json::Error` классифицируется через `Error::classify()`: I/O → + `Serialization`; синтаксис, данные и EOF → `Deserialization`. + +## teloxide + +Отображение `teloxide_core::RequestError`: + +| Вариант | `AppErrorKind` | +|---|---| +| `Api` | `ExternalApi` (невалидный токен → `Unauthorized`) | +| `MigrateToChatId` | `ExternalApi` | +| `RetryAfter` | `RateLimited` | +| `Network` | `Network` | +| `InvalidJson` | `Deserialization` | +| `Io` | `Internal` | + +## init-data (Telegram Mini Apps) + +Каждый вариант `init_data_rs::InitDataError` (отсутствующий/невалидный хеш, +истёкшая полезная нагрузка, сбои подписи) отображается в `TelegramAuth`, +отделяя сбои аутентификации Mini App от обычных некорректных запросов. + +## tonic (исходящий gRPC) + +`tonic` конвертирует в обратном направлении: `masterror::Error` → +`tonic::Status` через `From`. [`AppCode`](Виды-и-коды-ошибок) отображается на +канонический `tonic::Code` через ту же таблицу `CODE_MAPPINGS`, что +используется для HTTP. Статус несёт записи метаданных `app-code`, +`app-http-status` и `app-problem-type`, а также подсказки о повторных попытках +и `www-authenticate`, если они заданы. У редактируемых ошибок сообщение +заменяется меткой вида, а метаданные удаляются. + +```rust,ignore +use masterror::AppError; +use tonic::{Code, Status}; + +let status = Status::from(AppError::not_found("missing")); +assert_eq!(status.code(), Code::NotFound); +``` + +## frontend (WASM / браузер) + +Флаг `frontend` добавляет trait `masterror::frontend::BrowserConsoleExt` для +`AppError` и `ErrorResponse` на базе `wasm-bindgen`: + +- `to_js_value()` — сериализовать ошибку в `wasm_bindgen::JsValue` +- `log_to_browser_console()` — вывести её через `console.error` + +Оба метода работают на целевых платформах `wasm32`; на нативных платформах они +возвращают `BrowserConsoleError::UnsupportedTarget`. Режимы сбоя (отсутствие +консоли, невызываемый `console.error`, сбой сериализации) покрыты enum +`BrowserConsoleError`. + +```rust,ignore +use masterror::{AppError, frontend::BrowserConsoleExt}; + +let err = AppError::not_found("user not found"); +err.log_to_browser_console()?; +``` + +## turnkey + +Флаг `turnkey` предоставляет небольшую стабильную доменную таксономию в +`masterror::turnkey`: + +| `TurnkeyErrorKind` | `AppErrorKind` | +|---|---| +| `UniqueLabel` | `Conflict` | +| `RateLimited` | `RateLimited` | +| `Timeout` | `Timeout` | +| `Auth` | `Unauthorized` | +| `Network` | `Network` | +| `Service` | `Turnkey` | + +`TurnkeyError::new(kind, msg)` строит доменную ошибку; `From for +AppError` и `From for AppErrorKind` выполняют отображение. +`classify_turnkey_error(&str)` эвристически классифицирует сырое сообщение +провайдера (без учёта регистра, с учётом границ слов) в `TurnkeyErrorKind`: + +```rust +use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error}; +use masterror::{AppError, AppErrorKind}; + +let kind = classify_turnkey_error("429 rate-limit reached"); +assert_eq!(kind, TurnkeyErrorKind::RateLimited); + +let app: AppError = TurnkeyError::new(kind, "quota exceeded").into(); +assert_eq!(app.kind, AppErrorKind::RateLimited); +``` + +См. также: [Флаги возможностей](Флаги-возможностей) · [Веб-фреймворки](Веб-фреймворки) · [Виды и коды ошибок](Виды-и-коды-ошибок) · [Наблюдаемость](Наблюдаемость) diff --git "a/wiki/\320\232\320\276\320\275\321\202\320\265\320\272\321\201\321\202-\320\270-\320\274\320\265\321\202\320\260\320\264\320\260\320\275\320\275\321\213\320\265.md" "b/wiki/\320\232\320\276\320\275\321\202\320\265\320\272\321\201\321\202-\320\270-\320\274\320\265\321\202\320\260\320\264\320\260\320\275\320\275\321\213\320\265.md" new file mode 100644 index 0000000..0ecd357 --- /dev/null +++ "b/wiki/\320\232\320\276\320\275\321\202\320\265\320\272\321\201\321\202-\320\270-\320\274\320\265\321\202\320\260\320\264\320\260\320\275\320\275\321\213\320\265.md" @@ -0,0 +1,182 @@ +# Контекст и метаданные + +`masterror` заменяет контекст, склеенный из строк (`format!("failed to X: {e}")`), тремя структурированными механизмами: билдером `Context`, типизированными полями `Metadata` и политиками редактирования, применяемыми на транспортной границе. + +## ResultExt: продвижение внешних ошибок + +`ResultExt` реализован для каждого `Result`, где `E: Error + Send + Sync + 'static`, и предлагает два метода: + +### `.context(msg)` — в стиле anyhow + +Оборачивает ошибку сообщением; исходная ошибка становится источником: + +```rust +use masterror::ResultExt; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); +``` + +Если нижележащая ошибка уже является `masterror::Error`, `.context()` сохраняет её классификацию: вид, код, метаданные, политика редактирования, подсказка повторной попытки и детали переносятся, заменяется только сообщение, а исходная ошибка сохраняется как источник. + +### `.ctx(|| Context)` — полный контроль + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn validate() -> Result<(), std::io::Error> { + Err(std::io::Error::other("boom")) +} + +let err = validate() + .ctx(|| { + Context::new(AppErrorKind::Validation) + .with(field::str("phase", "validate")) + .redact(true) + .track_caller() + }) + .unwrap_err(); + +assert_eq!(err.kind, AppErrorKind::Validation); +assert!(err.metadata().get("phase").is_some()); +``` + +Замыкание вычисляется только на пути ошибки. + +## Билдер Context + +| Метод | Эффект | +|---|---| +| `Context::new(kind)` | Целевая категория; `AppCode` по умолчанию берётся из канонического отображения для этого вида | +| `.code(AppCode)` | Переопределить публичный код | +| `.category(kind)` | Сменить категорию; код остаётся синхронизированным, если не был переопределён | +| `.with(field)` | Прикрепить `Field` метаданных | +| `.redact(bool)` | Переключить редактирование сообщения (`MessageEditPolicy::Redact` / `Preserve`) | +| `.redact_field(name, FieldRedaction)` | Переопределить политику редактирования именованного поля | +| `.track_caller()` | Записать место вызова в метаданные `caller.file`, `caller.line`, `caller.column` | + +## Поля метаданных + +`Metadata` — отсортированная карта типизированных полей с inline-размещением (0–4 поля остаются на стеке). Создавайте поля через модуль `masterror::field`: + +| Конструктор | Вариант `FieldValue` | +|---|---| +| `field::str("key", value)` | `Str(Cow<'static, str>)` | +| `field::i64("key", -1)` | `I64` | +| `field::u64("key", 42)` | `U64` | +| `field::f64("key", 0.5)` | `F64` | +| `field::bool("key", true)` | `Bool` | +| `field::uuid("key", uuid)` | `Uuid` | +| `field::duration("key", dur)` | `Duration` | +| `field::ip("key", addr)` | `Ip` (v4 или v6) | +| `field::json("key", json!({...}))` | `Json` (требует функции `serde_json`) | + +Прикрепляйте поля при создании ошибок или через `Context`: + +```rust +use core::time::Duration; +use masterror::{AppError, FieldValue, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::duration("elapsed", Duration::from_millis(1500))) + .with_field(field::u64("attempt", 2)); + +assert_eq!(err.metadata().len(), 3); +assert_eq!(err.metadata().get("attempt"), Some(&FieldValue::U64(2))); + +for (name, value) in err.metadata().iter() { + println!("{name}={value}"); +} +``` + +`with_fields(iter)` расширяет карту из итератора, `with_metadata(meta)` заменяет контейнер, а `Metadata::insert` возвращает прежнее значение при перезаписи ключа. + +## Политики редактирования + +### Политика сообщения: `MessageEditPolicy` + +`Preserve` (по умолчанию) сохраняет публичное сообщение; `Redact` велит транспортам удалить его. Устанавливается через `.redactable()` на ошибке, `.redact(true)` на `Context` или `redact(message)` в `#[masterror(...)]`: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); +``` + +### Политика поля: `FieldRedaction` + +Каждое поле несёт собственную политику, применяемую при сериализации метаданных в `ProblemJson`: + +| Политика | Эффект на публичную нагрузку | +|---|---| +| `None` | Значение сохраняется как есть | +| `Redact` | Поле удаляется целиком | +| `Hash` | Значение заменяется дайджестом SHA-256 | +| `Last4` | Маскируется всё, кроме последних четырёх символов | + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::internal("payment failed") + .with_field(field::str("card_number", "4111111111111111")) + .redact_field("card_number", FieldRedaction::Last4); +``` + +Имена, похожие на секреты, автоматически получают безопасное значение по умолчанию при создании поля: имена, содержащие `password`, `secret`, `authorization`, `cookie`, `session`, `jwt`, `bearer`, `otp`, `pin`, по умолчанию получают `Redact`; имена в духе токенов и ключей (`api_token`, `refresh_token`, `key`, `apikey`) — `Hash`; сегменты карт/счетов в сочетании с числовым сегментом (`card_number`, `iban_no`, `account_id`) — `Last4`. Распознавание регистронезависимо. Явный `redact_field`/`with_redaction` всегда имеет приоритет. + +## Цепочки ошибок + +Ошибки сохраняют полную причинную цепочку. `chain()` итерирует от самой ошибки до первопричины; `root_cause()` сразу переходит к самой глубокой ошибке: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let app_err = AppError::internal("db down").with_context(io_err); + +let chain: Vec<_> = app_err.chain().collect(); +assert_eq!(chain.len(), 2); +assert_eq!(app_err.root_cause().to_string(), "disk offline"); +``` + +`with_context(...)` — предпочтительный способ прикрепить вышестоящую ошибку: он принимает владеемые ошибки или разделяемые значения `Arc` и переиспользует существующие аллокации. `with_source(...)` / `with_source_arc(...)` — их низкоуровневые эквиваленты. + +## Даункастинг + +Проверяйте прикреплённый источник через `is` и `downcast_ref`/`downcast_mut`: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let err = AppError::internal("boom").with_context(io_err); + +assert!(err.is::()); + +if let Some(io) = err.downcast_ref::() { + assert_eq!(io.to_string(), "disk offline"); +} +``` + +- `is::()` — `true`, когда непосредственный источник имеет тип `E` (не обходит всю цепочку). +- `downcast_ref::()` — заимствует источник как `E`. +- `downcast::()` / `downcast_mut::()` — пока заглушки (`downcast` всегда возвращает `Err(self)`, `downcast_mut` всегда возвращает `None`), поэтому предпочитайте `downcast_ref`. + +Для более глубоких проверок обходите `chain()` и вызывайте `source.is::()` / `source.downcast_ref::()` на каждом элементе. + +## Бэктрейсы + +С функцией `backtrace` метод `err.backtrace()` возвращает лениво захваченный `std::backtrace::Backtrace`, учитывающий `RUST_BACKTRACE`, а `with_backtrace(bt)` прикрепляет явно захваченный. Бэктрейсы разделяются через `Arc` при переоборачивании ошибок, поэтому цепочки `.context()` не выполняют повторный захват. + +--- + +См. также: [Начало работы](Начало-работы) · [Виды и коды ошибок](Виды-и-коды-ошибок) · [Derive-макросы](Derive-макросы) · [Наблюдаемость](Наблюдаемость) · [Лучшие практики](Лучшие-практики) diff --git "a/wiki/\320\233\321\203\321\207\321\210\320\270\320\265-\320\277\321\200\320\260\320\272\321\202\320\270\320\272\320\270.md" "b/wiki/\320\233\321\203\321\207\321\210\320\270\320\265-\320\277\321\200\320\260\320\272\321\202\320\270\320\272\320\270.md" new file mode 100644 index 0000000..3362b2d --- /dev/null +++ "b/wiki/\320\233\321\203\321\207\321\210\320\270\320\265-\320\277\321\200\320\260\320\272\321\202\320\270\320\272\320\270.md" @@ -0,0 +1,169 @@ +# Лучшие практики + +Паттерны, которые делают сервисы на `masterror` предсказуемыми: типизированные +доменные ошибки, одна стабильная таксономия кодов, отображение на транспорты +на границе и публичные сообщения, которые никогда не раскрывают внутренности. + +## Выводите доменные ошибки через derive и отображайте их один раз + +Моделируйте каждый ограниченный контекст как enum с `#[derive(Error)]` и +объявляйте отображение на `AppError` прямо на месте через `#[app_error(...)]`. +Derive генерирует `Display`, `From<...>` для обёрнутых источников и конверсию +в `AppError`/`AppCode` — никакого рукописного `match` в каждом месте вызова. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +pub enum UserError { + #[error("user {0} not found")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] + NotFound(u64), + + #[error("email already registered")] + #[app_error(kind = AppErrorKind::Conflict, code = AppCode::Conflict, message)] + DuplicateEmail, + + #[error("storage failure")] + #[app_error(kind = AppErrorKind::Database, code = AppCode::Database)] + Storage(#[from] std::io::Error) +} + +let app: AppError = UserError::DuplicateEmail.into(); +assert_eq!(app.kind, AppErrorKind::Conflict); +``` + +Указывайте `message` только на вариантах, чей вывод `Display` безопасно +показывать клиентам. Опускайте его (как на `Storage`), чтобы текст остался +внутренним — клиент увидит только заголовок вида. Полный справочник +атрибутов: [Derive-макросы](Derive-макросы). + +## Одна таксономия `AppCode` на сервис + +`AppCode` — ваш публичный API-контракт; клиенты ветвятся по нему. Держите +набор небольшим, документированным и отдельным для каждого сервиса: + +- Предпочитайте встроенные коды (`NOT_FOUND`, `CONFLICT`, `VALIDATION`, ...) — + они уже несут канонические отображения HTTP/gRPC/problem-type. +- Заводите собственные коды централизованно, а не в местах вызова: + +```rust +use masterror::AppCode; + +pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED"); +``` + +`AppCode::new` — `const` и паникует на этапе компиляции на всём, что не +SCREAMING_SNAKE_CASE; для строк времени выполнения используйте +`AppCode::try_new`. Переименование кода — ломающее изменение API; относитесь +к добавлениям как к добавлению варианта enum. + +## Отображайте на транспорты только на границе + +Доменный и сервисный слои возвращают `AppResult` и ничего не знают об HTTP. +Единственная реализация `IntoResponse`/`ResponseError`/`Status` в крейте +выполняет отображение в слое обработчиков: + +```rust,ignore +async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult { + repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database +} +``` + +Никогда не конструируйте коды статусов вручную в бизнес-логике и никогда не +реализуйте вторую конверсию в ответ — стабильная таблица +`AppErrorKind → status` в [Веб-фреймворках](Веб-фреймворки) — единственный +источник истины. + +## Редактируйте чувствительные данные, сохраняйте телеметрию + +Два независимых рычага: + +- **Редактирование сообщения** — `err.redactable()` (или `redact(message)` в + `#[masterror(...)]`) скрывает `detail` из полезных нагрузок на проводе, + тогда как логи его сохраняют. +- **Редактирование полей** — политика для каждого поля, применяемая при + сериализации метаданных: + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::bad_request("Invalid credentials") + .with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash)) + .with_field(field::str("card", "4111111111111111").with_redaction(FieldRedaction::Last4)) + .with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact)); +``` + +`Hash` сохраняет корреляционную ценность (одинаковый вход → одинаковый +дайджест), не раскрывая исходную строку; `Last4` подходит для суффиксов +карт/токенов; `Redact` удаляет значение полностью. По умолчанию — `None`. +Редактируйте всё, что идентифицирует пользователя, по умолчанию и осознанно +отказывайтесь от этого, а не наоборот. + +## `Context` против derive + +- **Derive** — когда тип ошибки часть вашего доменного словаря: у него есть + варианты, он появляется в сигнатурах, а его отображение статично. +- **`Context`** (через `ResultExt::ctx`) — когда инфраструктурная ошибка + оборачивается ситуативно в месте вызова и классификация зависит от операции, + а не от типа: + +```rust +# #[cfg(feature = "std")] { +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_state() -> masterror::AppResult> { + std::fs::read("/var/lib/app/state.bin").ctx(|| { + Context::new(AppErrorKind::Internal) + .with(field::str("path", "/var/lib/app/state.bin")) + .track_caller() + }) +} +# } +``` + +`ctx` ленив — замыкание выполняется только на пути ошибки. Используйте простой +`.context("message")`, когда достаточно человекочитаемой заметки. Подробности: +[Контекст и метаданные](Контекст-и-метаданные). + +## Тестируйте виды и коды, а не строки + +Утверждайте стабильную таксономию, никогда — отформатированные сообщения: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ProblemJson}; + +let err = AppError::not_found("user 42 missing"); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.code, AppCode::NotFound); + +let problem = ProblemJson::from_ref(&err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +- `ProblemJson::from_ref` позволяет интеграционным тестам проверять точный + контракт на проводе без поднятия сервера. +- `mapping_for_code(&code)` предоставляет канонический HTTP-статус, gRPC-код и + URI problem-type для табличных тестов. +- Для тестов редактирования утверждайте `problem.detail.is_none()` на ошибке + с `redactable()` и проверяйте политики через + `metadata().iter_with_redaction()`. + +## Публичное сообщение против внутренней телеметрии + +Полезное правило для каждой конструируемой ошибки: + +| Канал | Содержимое | +|---|---| +| `message` / `detail` | Ориентировано на человека, без чувствительных данных, достаточно стабильно для показа пользователю | +| Поля `Metadata` | ID, попытки, конечные точки, длительности — для логов/метрик, с политиками редактирования | +| Цепочка `source` | Сырые нижележащие ошибки — логируются, **никогда** не сериализуются клиентам | + +`masterror` принудительно обеспечивает последнюю строку (источники никогда не +записываются в полезные нагрузки на проводе), но первые две — ваша +ответственность: если строка содержит что-то, что вы не напечатали бы в +браузере, поместите это в метаданные с политикой редактирования или пометьте +ошибку `redactable()`. + +См. также: [Derive-макросы](Derive-макросы) · [Контекст и метаданные](Контекст-и-метаданные) · [Виды и коды ошибок](Виды-и-коды-ошибок) · [Миграция](Миграция) diff --git "a/wiki/\320\234\320\270\320\263\321\200\320\260\321\206\320\270\321\217.md" "b/wiki/\320\234\320\270\320\263\321\200\320\260\321\206\320\270\321\217.md" new file mode 100644 index 0000000..6d93348 --- /dev/null +++ "b/wiki/\320\234\320\270\320\263\321\200\320\260\321\206\320\270\321\217.md" @@ -0,0 +1,142 @@ +# Миграция + +`masterror` спроектирован как прямая замена одновременно `thiserror` +(синтаксис derive) и `anyhow` (эргономика). Большинство миграций — это замена +зависимости плюс постепенное принятие типизированных возможностей. Запускаемые +пошаговые примеры: +[`examples/migrate_from_thiserror.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_thiserror.rs) +и +[`examples/migrate_from_anyhow.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_anyhow.rs). + +## С thiserror + +Шаг 1 механический — поменяйте импорт; синтаксис derive совместим: + +```diff +-use thiserror::Error; ++use masterror::Error; +``` + +### Совместимость атрибутов + +| thiserror | masterror | Примечания | +|---|---|---| +| `#[error("...")]` с плейсхолдерами `{field}` | так же | 1:1, включая позиционные `{0}` | +| Спецификаторы формата `:>8`, `:.3`, `:x`, `:p`, `:e` | так же | `TemplateFormatter` повторяет детекцию форматтеров thiserror | +| `#[error(transparent)]` | так же | требует обёрток с одним полем, пробрасывающих `Display`/`source` | +| `#[from]` | так же | генерирует `From<...>`, проверяет форму обёртки | +| `#[source]` | так же | подключает цепочку `source()` | +| `#[backtrace]` | так же | учитывается на полях | +| — | `#[app_error(kind = ..., code = ..., message)]` | **добавлено**: генерирует `From for AppError` (и `AppCode`); `message` пробрасывает `Display` как публичное сообщение | +| — | `#[provide(ref = T, value = T)]` | **добавлено**: типизированная телеметрия через `std::error::Request`; поля `Option` предоставляют значение только при `Some` | +| — | `#[derive(Masterror)]` + `#[masterror(...)]` | **добавлено**: полное отображение с `category`, `redact(message, fields(...))`, `telemetry(...)`, `map.grpc`, `map.problem` | + +Существующие enum продолжают компилироваться без изменений. Что даёт их +аннотирование: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("user {user_id} not found")] +#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] +struct UserMissing { + user_id: u64 +} + +let app: AppError = UserMissing { user_id: 42 }.into(); +assert_eq!(app.kind, AppErrorKind::NotFound); +``` + +Enum отображаются повариантно — каждый вариант несёт собственный +`#[app_error(...)]`, а derive генерирует единственный `From for +AppError`. + +Рекомендуемый порядок: (1) поменять импорт, (2) добавить `#[app_error]` типам, +пересекающим границу API, (3) заменить рукописные реализации +`From for AppError` сгенерированными, (4) принять +`#[masterror(...)]` там, где нужны редактирование или метаданные. + +## С anyhow + +| anyhow | masterror | Примечания | +|---|---|---| +| `anyhow::Result` | `masterror::AppResult` | псевдоним для `Result` | +| `anyhow::Error` | `masterror::AppError` / `Error` | несёт вид, код и метаданные вместо «блоба» | +| `.context("msg")` | `.context("msg")` | идентично, через `masterror::ResultExt` | +| `.with_context(\|\| ...)` | `.ctx(\|\| Context::new(kind)...)` | ленив, как в anyhow, но строит **типизированный** `Context` (вид, код, поля, редактирование) | +| `bail!(err)` | `fail!(err)` | принимает выражение типизированной ошибки, без машинерии форматирования | +| `ensure!(cond, "msg {x}")` | `ensure!(cond, AppError::...)` | условие + типизированная ошибка; без форматирования на успешном пути | +| `err.chain()` | `err.chain()` | тот же итератор по цепочке источников | +| `err.root_cause()` | `err.root_cause()` | так же | +| `err.is::()` / `downcast` / `downcast_ref` / `downcast_mut` | те же имена на `AppError` | паритет по даункастингу | +| Обёртывание в стиле `#[from]` | Реализации `From<...>` за [флагами функций](Интеграции) | sqlx/redis/reqwest/... приходят уже классифицированными | + +`.context()` работает ровно так, как вы ожидаете: + +```rust +use masterror::{AppResult, ResultExt}; + +fn read_config(path: &str) -> AppResult { + let content = std::fs::read_to_string(path).context("Failed to read config file")?; + Ok(content) +} +``` + +`ensure!`/`fail!` меняют строковое форматирование anyhow на типизированные +ошибки: + +```rust +use masterror::{AppError, AppResult, ensure, fail, field}; + +fn parse(content: &str, path: &str) -> AppResult<()> { + ensure!( + !content.is_empty(), + AppError::bad_request("Config file is empty") + .with_field(field::str("path", path.to_owned())) + ); + if content.starts_with("invalid") { + fail!(AppError::bad_request("Invalid config format")); + } + Ok(()) +} +``` + +Выражение ошибки вычисляется только при срабатывании условия, поэтому +счастливый путь остаётся без выделений памяти — та же гарантия, что даёт +anyhow, плюс машиночитаемый код. + +### Где у anyhow нет эквивалента + +Миграция даёт возможности, у которых нет аналога в anyhow: + +- **Типизированная таксономия** — `AppErrorKind` (внутренний) и `AppCode` + (публичный, SCREAMING_SNAKE_CASE) вместо строкового контекста. См. + [Виды и коды ошибок](Виды-и-коды-ошибок). +- **Транспортные отображения** — RFC 7807 `problem+json` для Axum/Actix и + `tonic::Status` для gRPC, выводимые из одной таблицы кодов. См. + [Веб-фреймворки](Веб-фреймворки). +- **Телеметрия** — автоматические события `tracing`, метрики + `error_total{code,category}` и ленивые бэктрейсы на границе. См. + [Наблюдаемость](Наблюдаемость). +- **Редактирование** — сообщения `redactable()` и политики + `Hash`/`Last4`/`Redact` для каждого поля, соблюдаемые каждым транспортом. См. + [Лучшие практики](Лучшие-практики). +- **Структурированные метаданные** — типизированные + `field::str/u64/duration/ip/...` вместо форматирования значений в сообщение. + +### На что обратить внимание + +- У формы anyhow `ensure!(cond, "format {}", x)` с форматированным сообщением + нет прямого двойника: конструируйте ошибку явно + (`AppError::bad_request(format!(...))`) или, что лучше, используйте + статическое сообщение плюс поля метаданных. +- `anyhow::Error` принимает любой `E: Error + Send + Sync`. В masterror вы + выбираете вид в момент оборачивания (`Context::new(kind)` или конверсия + `From`) — эта точка принятия решения и есть возможность, а не трение: именно + здесь происходит классификация. +- И `ensure!`, и `fail!` разворачиваются в `return Err(...)`, поэтому работают + в любой функции, возвращающей `Result<_, E>`, где ваше выражение уже + является типом ошибки — конверсия `Into` не вставляется. + +См. также: [Начало работы](Начало-работы) · [Derive-макросы](Derive-макросы) · [Контекст и метаданные](Контекст-и-метаданные) · [Лучшие практики](Лучшие-практики) diff --git "a/wiki/\320\235\320\260\320\261\320\273\321\216\320\264\320\260\320\265\320\274\320\276\321\201\321\202\321\214.md" "b/wiki/\320\235\320\260\320\261\320\273\321\216\320\264\320\260\320\265\320\274\320\276\321\201\321\202\321\214.md" new file mode 100644 index 0000000..64a5241 --- /dev/null +++ "b/wiki/\320\235\320\260\320\261\320\273\321\216\320\264\320\260\320\265\320\274\320\276\321\201\321\202\321\214.md" @@ -0,0 +1,163 @@ +# Наблюдаемость + +`masterror` рассматривает телеметрию как часть жизненного цикла ошибки. Каждый +`AppError` отслеживает флаг изменения; телеметрия испускается один раз на +каждое изменение состояния — при конструировании, после мутации или когда +ошибка пересекает транспортную границу (`IntoResponse` в Axum, +`error_response()` в Actix, конверсия в `Status` для tonic). Вручную почти +ничего вызывать не нужно. + +## Флаги функций + +| Флаг | Что добавляет | +|---|---| +| `tracing` | Структурированное событие `tracing` на каждую ошибку, `trace_id` через `log-mdc` | +| `metrics` | Счётчик `error_total{code,category}` через крейт `metrics` | +| `backtrace` | Ленивый захват `std::backtrace::Backtrace`, управляемый `RUST_BACKTRACE` | +| `colored` | ANSI-раскраска терминального вывода с детекцией TTY | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["tracing", "metrics", "backtrace"] } +``` + +## Tracing + +С включённым `tracing` каждая ошибка испускает одно событие уровня ERROR с +target `masterror::error`: + +| Поле | Содержимое | +|---|---| +| `code` | Строка `AppCode`, например `NOT_FOUND` | +| `category` | Метка `AppErrorKind`, например `Database` | +| `message` | Публичное сообщение, если есть | +| `retry_seconds` | Рекомендация о повторе, если задана | +| `redactable` | Редактируется ли сообщение на транспортных границах | +| `metadata_len` | Количество присоединённых полей метаданных | +| `www_authenticate` | Вызов аутентификации, если задан | +| `trace_id` | Берётся из ключа `trace_id` контекста `log-mdc`, если присутствует | + +Испускание учитывает подписчика: если ни один подписчик не заинтересован в +событиях уровня ERROR для этого target, событие остаётся в ожидании и +повторяется при следующем сбросе, так что ничего не теряется при поздней +установке подписчика. + +Чтобы соотносить ошибки с запросами, сохраните trace ID в MDC в middleware +запроса: + +```rust,ignore +log_mdc::insert("trace_id", request_id); +``` + +Каждая ошибка, сконструированная пока ключ установлен, несёт его в событии. + +## Метрики + +С включённым `metrics` каждая свежеизменённая ошибка инкрементирует: + +```text +error_total{code="NOT_FOUND", category="NotFound"} +``` + +Обе метки — стабильные строки (`AppCode::as_str()` и метка `AppErrorKind`), +поэтому дашборды и алерты переживают рефакторинг ваших доменных типов. +Подключайте любой recorder `metrics` (Prometheus, StatsD, ...) как обычно; +`masterror` использует только `metrics::counter!`. + +## Бэктрейсы + +С включённым `backtrace` снимок `Backtrace` захватывается лениво в момент +сброса телеметрии, а не при каждом конструировании. Захват управляется +`RUST_BACKTRACE`: не задано, пустое, `0`, `off` и `false` отключают его; любое +другое значение включает. Настройка читается один раз и кэшируется на процесс. + +```rust +# #[cfg(feature = "backtrace")] { +use masterror::AppError; + +let err = AppError::internal("db down"); +if let Some(bt) = err.backtrace() { + eprintln!("{bt}"); +} +# } +``` + +Можно также присоединить заранее захваченный трейс через +`AppError::with_backtrace(backtrace)` — он имеет приоритет над ленивым +захватом. + +## Ручной сброс через `.log()` + +Конструкторы и конверсии испускают телеметрию автоматически. После мутации +ошибки (добавление полей, изменение рекомендации о повторе) можно принудительно +испустить её заново: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("upstream degraded") + .with_field(field::str("upstream", "billing")); +err.log(); +``` + +`log()` идемпотентен в рамках состояния: если с последнего испускания ничего +не изменилось, он ничего не делает. HTTP/gRPC-адаптеры делают такой же сброс +на границе, поэтому ошибка, которая сконструирована, обогащена и затем +возвращена из обработчика Axum, испускается один раз на состояние — один раз +при конструировании и один раз на границе для обогащённого состояния — и +никогда дважды для одного состояния. + +## Инспекция цепочки + +Независимо от флагов, `AppError` предоставляет инструменты, нужные конвейерам +логирования: + +```rust +# #[cfg(feature = "std")] { +use std::io::Error as IoError; +use masterror::AppError; + +let err = AppError::internal("db down").with_context(IoError::other("disk offline")); + +assert_eq!(err.chain().count(), 2); +let _root = err.root_cause(); +assert!(err.metadata().is_empty()); +# } +``` + +`metadata().iter_with_redaction()` выдаёт тройки `(key, value, policy)`, чтобы +слой логирования мог соблюдать редактирование полей — см. +[Контекст и метаданные](Контекст-и-метаданные). + +## Цветной терминальный вывод + +Флаг `colored` добавляет `masterror::colored::style` для CLI-инструментов. +Цвета применяются только когда stderr — это TTY, `NO_COLOR` не задан, `TERM` +не равен `dumb` и терминал поддерживает ANSI; иначе текст проходит без +изменений. Результат детекции кэшируется на процесс. + +| Функция | Стиль | Для чего | +|---|---|---| +| `error_kind_critical` | красный | критические виды сбоев | +| `error_kind_warning` | жёлтый | восстановимые виды/предупреждения | +| `error_code` | голубой | машинные коды | +| `error_message` | ярко-белый | основное сообщение | +| `source_context` | приглушённый | вторичная информация об источнике | +| `metadata_key` | зелёный | имена структурированных полей | + +```rust +# #[cfg(feature = "colored")] { +use masterror::colored::style; + +eprintln!( + "{}: {}", + style::error_code("ERR_DB_001"), + style::error_message("Database connection failed") +); +# } +``` + +Запускаемое демо: +[`examples/colored_cli.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/colored_cli.rs). + +См. также: [Флаги возможностей](Флаги-возможностей) · [Контекст и метаданные](Контекст-и-метаданные) · [Веб-фреймворки](Веб-фреймворки) · [Лучшие практики](Лучшие-практики) diff --git "a/wiki/\320\235\320\260\321\207\320\260\320\273\320\276-\321\200\320\260\320\261\320\276\321\202\321\213.md" "b/wiki/\320\235\320\260\321\207\320\260\320\273\320\276-\321\200\320\260\320\261\320\276\321\202\321\213.md" new file mode 100644 index 0000000..e4b2ea7 --- /dev/null +++ "b/wiki/\320\235\320\260\321\207\320\260\320\273\320\276-\321\200\320\260\320\261\320\276\321\202\321\213.md" @@ -0,0 +1,193 @@ +# Начало работы + +Эта страница проведёт вас через установку `masterror`, возврат первого `AppError`, ранние возвраты с `ensure!`/`fail!`, использование прелюдии и написание первого derive. + +## Установка + +Сборка по умолчанию включает только функцию `std` — без веб-фреймворков и бэкендов телеметрии: + +```toml +[dependencies] +masterror = "0.28" +``` + +Включайте интеграции по мере необходимости (полный список — в [Флагах возможностей](Флаги-возможностей)): + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] } +``` + +MSRV — **1.96**. Крейт запрещает `unsafe` и поддерживает `no_std` при сборке с `default-features = false`. + +## Ваша первая ошибка + +`AppError` объединяет семантическую категорию (`AppErrorKind`) с опциональным публичным сообщением. `AppResult` — псевдоним для `Result`: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn do_work(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::new(AppErrorKind::BadRequest, "Flag must be set")); + } + Ok(()) +} + +let err = do_work(false).unwrap_err(); +assert!(matches!(err.kind, AppErrorKind::BadRequest)); +assert_eq!(err.kind.http_status(), 400); +``` + +У каждого вида есть именованный конструктор, так что в местах вызова редко приходится писать `AppErrorKind` явно: + +```rust +use masterror::AppError; + +let _ = AppError::not_found("user not found"); // 404 +let _ = AppError::validation("invalid email"); // 422 +let _ = AppError::unauthorized("token expired"); // 401 +let _ = AppError::forbidden("no access"); // 403 +let _ = AppError::conflict("already exists"); // 409 +let _ = AppError::rate_limited("slow down"); // 429 +let _ = AppError::internal("unexpected failure"); // 500 +let _ = AppError::service("orchestration failed"); // 500 +let _ = AppError::timeout("upstream timed out"); // 504 +let _ = AppError::bare(masterror::AppErrorKind::NotFound); // no message +``` + +Прикрепляйте структурированные метаданные и вышестоящий источник, не отказываясь от типизации: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::i64("attempt", 2)) + .with_context(std::io::Error::other("connection reset")); + +assert_eq!(err.metadata().len(), 2); +assert!(err.source_ref().is_some()); +``` + +Источник доступен для логов и обхода через `chain()`, но **никогда не сериализуется для клиентов**. + +## ensure! и fail! + +`ensure!` и `fail!` — типизированные альтернативы `anyhow::ensure!`/`anyhow::bail!`. Выражение ошибки вычисляется лениво, поэтому на успешном пути нет ни форматирования, ни выделения памяти: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("flag must be set")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("token expired")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +``` + +`ensure!` также принимает развёрнутую форму для сложных условий: + +```rust +use masterror::{AppError, AppResult}; + +fn bounded(value: i32, max: i32) -> AppResult<()> { + masterror::ensure!( + cond = value <= max, + else = AppError::service("value too large") + ); + Ok(()) +} +``` + +## Прелюдия + +`masterror::prelude` реэкспортирует только основные типы (`AppError`, `AppErrorKind`, `AppCode`, `AppResult`, `ErrorResponse`, а также хелперы `turnkey`, когда включена соответствующая функция): + +```rust +use masterror::prelude::*; + +fn handler(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::bad_request("Flag must be set")); + } + Ok(()) +} +``` + +Реализации трейтов фреймворков (Axum `IntoResponse`, Actix `Responder`) активируются флагами возможностей и не требуют дополнительных импортов. + +## Добавление контекста к внешним ошибкам + +`ResultExt` превращает любой `Result` в `AppResult`: + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +// Simple, anyhow-style message: +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); + +// Full control over category, code, metadata and redaction: +let err = read_config() + .ctx(|| Context::new(AppErrorKind::Config).with(field::str("path", "app.toml"))) + .unwrap_err(); +assert_eq!(err.kind, AppErrorKind::Config); +``` + +Полный API `Context` описан в разделе [Контекст и метаданные](Контекст-и-метаданные). + +## Ваш первый derive + +`#[derive(Error)]` повторяет синтаксис `thiserror`, а `#[app_error(...)]` добавляет конверсию в `AppError`: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal, message)] +pub struct DomainError { + #[from] + #[source] + source: std::io::Error +} + +fn load() -> Result<(), DomainError> { + Err(std::io::Error::other("disk offline").into()) +} + +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let app: AppError = err.into(); +assert!(matches!(app.kind, AppErrorKind::Internal)); +``` + +- `#[error("...")]` задаёт шаблон `Display` с плейсхолдерами `{field}`. +- `#[from]` генерирует `From` для обёртки. +- `#[source]` пробрасывает внутреннюю ошибку через `source()`. +- `#[app_error(kind = ..., code = ..., message)]` генерирует `From for AppError` (и `for AppCode`); флаг `message` делает вывод `Display` публичным сообщением. + +Enum работают так же — с атрибутами `#[error]` и `#[app_error]` для каждого варианта. Когда вам также нужны метаданные, политика редактирования и таблицы отображений gRPC/problem+json, используйте `#[derive(Masterror)]` — он описан в разделе [Derive-макросы](Derive-макросы). + +## Куда двигаться дальше + +- Отображение ошибок в HTTP-ответы в Axum или Actix — [Веб-фреймворки](Веб-фреймворки) +- Понимание таксономии и wire-контракта — [Виды и коды ошибок](Виды-и-коды-ошибок) +- Включение интеграций для sqlx, redis, reqwest — [Флаги возможностей](Флаги-возможностей) + +--- + +См. также: [Флаги возможностей](Флаги-возможностей) · [Виды и коды ошибок](Виды-и-коды-ошибок) · [Derive-макросы](Derive-макросы) · [Контекст и метаданные](Контекст-и-метаданные) · [Миграция](Миграция) diff --git "a/wiki/\320\244\320\273\320\260\320\263\320\270-\320\262\320\276\320\267\320\274\320\276\320\266\320\275\320\276\321\201\321\202\320\265\320\271.md" "b/wiki/\320\244\320\273\320\260\320\263\320\270-\320\262\320\276\320\267\320\274\320\276\320\266\320\275\320\276\321\201\321\202\320\265\320\271.md" new file mode 100644 index 0000000..5d1b5a1 --- /dev/null +++ "b/wiki/\320\244\320\273\320\260\320\263\320\270-\320\262\320\276\320\267\320\274\320\276\320\266\320\275\320\276\321\201\321\202\320\265\320\271.md" @@ -0,0 +1,131 @@ +# Флаги возможностей + +`masterror` держит сборку по умолчанию компактной: из коробки включена только функция `std`. Всё остальное — веб-транспорты, телеметрия, интеграции с библиотеками — подключается опционально. Эта страница — полный справочник по каждому флагу, объявленному в `Cargo.toml`. + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +# or with features: +# masterror = { version = "0.28", features = [ +# "std", "axum", "actix", "openapi", +# "serde_json", "tracing", "metrics", "backtrace", +# "colored", "sqlx", "sqlx-migrate", "reqwest", +# "redis", "validator", "config", "tokio", +# "multipart", "teloxide", "init-data", "tonic", +# "frontend", "turnkey", "benchmarks" +# ] } +``` + +## Ядро + +| Флаг | Что включает | Доп. зависимости | +|---|---|---| +| `std` *(по умолчанию)* | Поддержка стандартной библиотеки; требуется всем runtime-интеграциям. Отключите для `no_std` (см. [Без std](Без-std)) | — | + +## Веб-транспорты + +| Флаг | Что включает | Доп. зависимости | +|---|---|---| +| `axum` | `IntoResponse` для `AppError` и `ProblemJson` с JSON-телами по RFC 7807; `AppErrorKind::status_code()` | `axum` (json, multipart), `serde_json` | +| `actix` | Actix Web `ResponseError` для `AppError` и `Responder` для `ProblemJson` | `actix-web` | +| `multipart` | Отображает `axum::extract::multipart::MultipartError` → `BadRequest` (подразумевает `axum`) | через `axum` | +| `openapi` | `utoipa::ToSchema` для `ErrorResponse` и `AppCode`, чтобы полезные нагрузки ошибок попадали в спецификации OpenAPI | `utoipa` | +| `serde_json` | Структурированные JSON-`details` в `AppError`/`ErrorResponse`/`ProblemJson`; `FieldValue::Json` и `field::json` | `serde_json` | +| `tonic` | Конверсия ошибок в `tonic::Status` с санитизированными метаданными; экспортирует `StatusConversionError` | `tonic` | + +## Телеметрия и наблюдаемость + +| Флаг | Что включает | Доп. зависимости | +|---|---|---| +| `tracing` | Структурированные события `tracing`, эмитируемые при создании ошибок | `tracing`, `log`, `log-mdc` | +| `metrics` | Инкремент счётчика `error_total{code,category}` для каждого `AppError` | `metrics` | +| `backtrace` | Ленивый захват `std::backtrace::Backtrace` (учитывает `RUST_BACKTRACE`), билдер `with_backtrace()` | — | +| `colored` | Цветной многострочный вывод в терминале с автоматическим определением TTY; более насыщенный `Display` для `AppError` | `owo-colors` | + +## Асинхронные интеграции и ввод/вывод + +Каждый флаг интеграции добавляет конверсию `From<...> for AppError`, классифицирующую ошибку библиотеки в рамках таксономии: + +| Флаг | Конверсия | Доп. зависимости | +|---|---|---| +| `sqlx` | `sqlx_core::Error` → `NotFound` / `Database` (компактный `sqlx-core`, без драйверов и TLS) | `sqlx-core` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` → `Database` (полный `sqlx` только с функцией `migrate`) | `sqlx` | +| `redis` | `redis::RedisError` → `Cache` | `redis` | +| `reqwest` | `reqwest::Error` → `Timeout` / `Network` / `ExternalApi` | `reqwest` | +| `tokio` | `tokio::time::error::Elapsed` → `Timeout` | `tokio` (time) | +| `validator` | `validator::ValidationErrors` → `Validation` | `validator` | +| `config` | `config::ConfigError` → `Config` | `config` | + +`sqlx` и `sqlx-migrate` разделены намеренно: для классификации ошибок достаточно `sqlx-core`, тогда как отображение ошибок миграций тянет полный крейт `sqlx`. + +## Обмен сообщениями и боты + +| Флаг | Конверсия | Доп. зависимости | +|---|---|---| +| `teloxide` | `teloxide_core::RequestError` → `RateLimited` / `Network` / `ExternalApi` / `Deserialization` / `Internal` | `teloxide-core` | +| `init-data` | `init_data_rs::InitDataError` → `TelegramAuth` (валидация init data Telegram Mini Apps) | `init-data-rs` | + +## Фронтенд и домен + +| Флаг | Что включает | Доп. зависимости | +|---|---|---| +| `frontend` | Модуль `frontend`: конверсия ошибок в `wasm_bindgen::JsValue` и логирование через `console.error` в контекстах WASM/браузера | `wasm-bindgen`, `js-sys`, `serde-wasm-bindgen` | +| `turnkey` | Модуль `turnkey`: `TurnkeyErrorKind`, `TurnkeyError`, `classify_turnkey_error` и конверсии в `AppError` | — | +| `benchmarks` | Набор бенчмарков Criterion и инструментарий базовых линий CI (только для локального профилирования) | — | + +## Базовые конверсии (доступны всегда) + +Без каких-либо флагов возможностей `AppError` уже конвертируется из: + +| Источник | Целевой вид | +|---|---| +| `std::io::Error` | `Internal` | +| `String` | `BadRequest` | + +## Рецепты + +REST API на Axum с problem+json, документацией OpenAPI и tracing: + +```toml +masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] } +``` + +Сервис с базой данных на sqlx, миграциями и метриками: + +```toml +masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] } +``` + +gRPC-сервис: + +```toml +masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] } +``` + +Telegram-бот с аутентификацией Mini App: + +```toml +masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] } +``` + +WASM-фронтенд: + +```toml +masterror = { version = "0.28", features = ["frontend", "serde_json"] } +``` + +`no_std` для встраиваемых систем или библиотек: + +```toml +masterror = { version = "0.28", default-features = false } +``` + +## Примечания + +- Все флаги интеграций подразумевают `std`, кроме `sqlx` и `sqlx-migrate`, которые на уровне флага остаются независимыми от `std`. +- `axum` и `actix` подтягивают `serde_json` транзитивно, потому что тела их ответов — JSON. +- Флаги возможностей никогда не меняют wire-контракт уже включённых полей `ErrorResponse`/`ProblemJson` — они лишь добавляют возможности (например, `serde_json` превращает `details` из простого текста в структурированный JSON) или реализации трейтов. + +--- + +См. также: [Начало работы](Начало-работы) · [Веб-фреймворки](Веб-фреймворки) · [Интеграции](Интеграции) · [Наблюдаемость](Наблюдаемость) · [Без std](Без-std) diff --git "a/wiki/\352\264\200\354\270\241\354\204\261.md" "b/wiki/\352\264\200\354\270\241\354\204\261.md" new file mode 100644 index 0000000..8775425 --- /dev/null +++ "b/wiki/\352\264\200\354\270\241\354\204\261.md" @@ -0,0 +1,158 @@ +# 관측성 + +`masterror`는 텔레메트리를 오류 수명 주기의 일부로 취급합니다. 각 `AppError`는 +더티 플래그를 추적하며, 텔레메트리는 상태 변화당 한 번 발행됩니다 — 생성 시, +변경 후, 또는 오류가 전송 경계(Axum `IntoResponse`, Actix +`error_response()`, tonic `Status` 변환)를 넘을 때. 수동으로 호출할 일은 +거의 없습니다. + +## 기능 플래그 + +| 기능 | 추가 내용 | +|---|---| +| `tracing` | 오류당 구조화된 `tracing` 이벤트, `log-mdc`를 통한 `trace_id` | +| `metrics` | `metrics` 크레이트를 통한 `error_total{code,category}` 카운터 | +| `backtrace` | `RUST_BACKTRACE`로 제어되는 지연 `std::backtrace::Backtrace` 캡처 | +| `colored` | TTY 감지가 포함된 ANSI 컬러 터미널 스타일링 | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["tracing", "metrics", "backtrace"] } +``` + +## Tracing + +`tracing`이 활성화되면 각 오류는 타깃 `masterror::error`로 ERROR 레벨 +이벤트를 하나 발행합니다: + +| 필드 | 내용 | +|---|---| +| `code` | `AppCode` 문자열, 예: `NOT_FOUND` | +| `category` | `AppErrorKind` 레이블, 예: `Database` | +| `message` | 공개 메시지 (있는 경우) | +| `retry_seconds` | 재시도 힌트 (설정된 경우) | +| `redactable` | 전송 경계에서 메시지가 리덕션되는지 여부 | +| `metadata_len` | 첨부된 메타데이터 필드 수 | +| `www_authenticate` | 인증 챌린지 (설정된 경우) | +| `trace_id` | `log-mdc` 컨텍스트 키 `trace_id`에서 가져옴 (존재하는 경우) | + +발행은 subscriber를 인식합니다: 해당 타깃의 ERROR 레벨 이벤트에 관심 있는 +subscriber가 없으면 이벤트는 보류 상태로 남아 다음 플러시에서 재시도되므로, +subscriber가 늦게 설치되어도 아무것도 손실되지 않습니다. + +오류를 요청과 연관시키려면 요청 미들웨어에서 MDC에 trace ID를 저장하세요: + +```rust,ignore +log_mdc::insert("trace_id", request_id); +``` + +키가 설정된 동안 생성된 모든 오류는 이벤트에 해당 값을 포함합니다. + +## 메트릭 + +`metrics`가 활성화되면 새로 더티 상태가 된 각 오류는 다음을 증가시킵니다: + +```text +error_total{code="NOT_FOUND", category="NotFound"} +``` + +두 레이블 모두 안정적인 문자열(`AppCode::as_str()`과 `AppErrorKind` +레이블)이므로, 도메인 타입을 리팩터링해도 대시보드와 알림이 유지됩니다. +`metrics` 레코더(Prometheus, StatsD 등)는 평소처럼 연결하면 됩니다. +`masterror`는 `metrics::counter!`만 사용합니다. + +## 백트레이스 + +`backtrace`가 활성화되면 `Backtrace` 스냅샷은 텔레메트리가 플러시될 때 +지연 캡처됩니다 — 생성할 때마다 캡처되지 않습니다. 캡처는 +`RUST_BACKTRACE`로 제어됩니다: 미설정, 빈 값, `0`, `off`, `false`는 +비활성화하고, 그 외 값은 활성화합니다. 이 설정은 한 번 읽혀 프로세스별로 +캐시됩니다. + +```rust +# #[cfg(feature = "backtrace")] { +use masterror::AppError; + +let err = AppError::internal("db down"); +if let Some(bt) = err.backtrace() { + eprintln!("{bt}"); +} +# } +``` + +`AppError::with_backtrace(backtrace)`로 미리 캡처한 트레이스를 첨부할 수도 +있으며, 이는 지연 캡처보다 우선합니다. + +## `.log()`를 통한 수동 플러시 + +생성자와 변환은 텔레메트리를 자동으로 발행합니다. 오류를 변경한 후(필드 추가, +재시도 힌트 변경) 재발행을 강제할 수 있습니다: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("upstream degraded") + .with_field(field::str("upstream", "billing")); +err.log(); +``` + +`log()`는 상태별로 멱등합니다: 마지막 발행 이후 변경된 것이 없으면 아무것도 +하지 않습니다. HTTP/gRPC 어댑터도 경계에서 같은 방식으로 플러시하므로, +생성되고 보강된 뒤 Axum 핸들러에서 반환되는 오류는 상태당 한 번 — +생성 시 한 번, 보강된 상태에 대해 경계에서 한 번 — 발행되며, 같은 상태에 대해 +두 번 발행되는 일은 없습니다. + +## 체인 검사 + +기능과 무관하게 `AppError`는 로그 파이프라인에 필요한 도구를 노출합니다: + +```rust +# #[cfg(feature = "std")] { +use std::io::Error as IoError; +use masterror::AppError; + +let err = AppError::internal("db down").with_context(IoError::other("disk offline")); + +assert_eq!(err.chain().count(), 2); +let _root = err.root_cause(); +assert!(err.metadata().is_empty()); +# } +``` + +`metadata().iter_with_redaction()`은 `(key, value, policy)` 트리플을 +산출하므로 로깅 계층이 필드 리덕션을 존중할 수 있습니다 — +[컨텍스트와 메타데이터](컨텍스트와-메타데이터)를 참조하세요. + +## 컬러 터미널 출력 + +`colored` 기능은 CLI 도구를 위한 `masterror::colored::style`을 추가합니다. +색상은 stderr가 TTY이고, `NO_COLOR`가 설정되지 않았으며, `TERM`이 `dumb`가 +아니고, 터미널이 ANSI를 지원할 때만 적용됩니다. 그렇지 않으면 텍스트가 +그대로 통과합니다. 감지 결과는 프로세스별로 캐시됩니다. + +| 함수 | 스타일 | 용도 | +|---|---|---| +| `error_kind_critical` | 빨간색 | 치명적 실패 종류 | +| `error_kind_warning` | 노란색 | 복구 가능/경고 종류 | +| `error_code` | 청록색 | 기계 코드 | +| `error_message` | 밝은 흰색 | 메인 메시지 | +| `source_context` | 흐리게 | 보조/소스 정보 | +| `metadata_key` | 초록색 | 구조화된 필드 이름 | + +```rust +# #[cfg(feature = "colored")] { +use masterror::colored::style; + +eprintln!( + "{}: {}", + style::error_code("ERR_DB_001"), + style::error_message("Database connection failed") +); +# } +``` + +실행 가능한 데모는 +[`examples/colored_cli.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/colored_cli.rs)를 +참조하세요. + +함께 보기: [기능 플래그](기능-플래그) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [웹 프레임워크](웹-프레임워크) · [모범 사례](모범-사례) diff --git "a/wiki/\352\270\260\353\212\245-\355\224\214\353\236\230\352\267\270.md" "b/wiki/\352\270\260\353\212\245-\355\224\214\353\236\230\352\267\270.md" new file mode 100644 index 0000000..b0c4beb --- /dev/null +++ "b/wiki/\352\270\260\353\212\245-\355\224\214\353\236\230\352\267\270.md" @@ -0,0 +1,131 @@ +# 기능 플래그 + +`masterror`는 기본 빌드를 가볍게 유지합니다. 기본적으로 활성화되는 것은 `std`뿐입니다. 그 외의 모든 것 — 웹 전송, 텔레메트리, 라이브러리 통합 — 은 옵트인입니다. 이 페이지는 `Cargo.toml`에 선언된 모든 플래그에 대한 완전한 레퍼런스입니다. + +```toml +[dependencies] +masterror = { version = "0.28", default-features = false } +# or with features: +# masterror = { version = "0.28", features = [ +# "std", "axum", "actix", "openapi", +# "serde_json", "tracing", "metrics", "backtrace", +# "colored", "sqlx", "sqlx-migrate", "reqwest", +# "redis", "validator", "config", "tokio", +# "multipart", "teloxide", "init-data", "tonic", +# "frontend", "turnkey", "benchmarks" +# ] } +``` + +## 코어 + +| 플래그 | 활성화 내용 | 추가 의존성 | +|---|---|---| +| `std` *(기본값)* | 표준 라이브러리 지원. 모든 런타임 통합에 필요합니다. `no_std`에서는 비활성화하세요 ([no_std 지원](no_std-지원) 참조) | — | + +## 웹 전송 + +| 플래그 | 활성화 내용 | 추가 의존성 | +|---|---|---| +| `axum` | RFC 7807 JSON 본문을 갖춘 `AppError`와 `ProblemJson`의 `IntoResponse`; `AppErrorKind::status_code()` | `axum` (json, multipart), `serde_json` | +| `actix` | `AppError`의 Actix Web `ResponseError`와 `ProblemJson`의 `Responder` | `actix-web` | +| `multipart` | `axum::extract::multipart::MultipartError` → `BadRequest` 매핑 (`axum` 포함) | `axum` 경유 | +| `openapi` | 오류 페이로드가 OpenAPI 스펙에 나타나도록 `ErrorResponse`와 `AppCode`에 `utoipa::ToSchema` 제공 | `utoipa` | +| `serde_json` | `AppError`/`ErrorResponse`/`ProblemJson`의 구조화된 JSON `details`; `FieldValue::Json`과 `field::json` | `serde_json` | +| `tonic` | 정제된 메타데이터와 함께 오류를 `tonic::Status`로 변환; `StatusConversionError` 익스포트 | `tonic` | + +## 텔레메트리와 관측성 + +| 플래그 | 활성화 내용 | 추가 의존성 | +|---|---|---| +| `tracing` | 오류 생성 시 구조화된 `tracing` 이벤트 발행 | `tracing`, `log`, `log-mdc` | +| `metrics` | `AppError`마다 `error_total{code,category}` 카운터 증가 | `metrics` | +| `backtrace` | 지연 `std::backtrace::Backtrace` 캡처 (`RUST_BACKTRACE` 존중), `with_backtrace()` 빌더 | — | +| `colored` | 자동 TTY 감지를 갖춘 컬러 여러 줄 터미널 출력; `AppError`의 풍부한 `Display` | `owo-colors` | + +## 비동기 및 IO 통합 + +각 통합 플래그는 라이브러리 오류를 분류 체계로 분류하는 `From<...> for AppError` 변환을 추가합니다: + +| 플래그 | 변환 | 추가 의존성 | +|---|---|---| +| `sqlx` | `sqlx_core::Error` → `NotFound` / `Database` (드라이버나 TLS가 없는 경량 `sqlx-core`) | `sqlx-core` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` → `Database` (`migrate` 기능만 켠 전체 `sqlx`) | `sqlx` | +| `redis` | `redis::RedisError` → `Cache` | `redis` | +| `reqwest` | `reqwest::Error` → `Timeout` / `Network` / `ExternalApi` | `reqwest` | +| `tokio` | `tokio::time::error::Elapsed` → `Timeout` | `tokio` (time) | +| `validator` | `validator::ValidationErrors` → `Validation` | `validator` | +| `config` | `config::ConfigError` → `Config` | `config` | + +`sqlx`와 `sqlx-migrate`는 의도적으로 분리되어 있습니다. 오류 분류에는 `sqlx-core`만 필요하지만, 마이그레이션 오류 매핑은 전체 `sqlx` 크레이트를 가져옵니다. + +## 메시징과 봇 + +| 플래그 | 변환 | 추가 의존성 | +|---|---|---| +| `teloxide` | `teloxide_core::RequestError` → `RateLimited` / `Network` / `ExternalApi` / `Deserialization` / `Internal` | `teloxide-core` | +| `init-data` | `init_data_rs::InitDataError` → `TelegramAuth` (Telegram Mini Apps init-data 검증) | `init-data-rs` | + +## 프런트엔드와 도메인 + +| 플래그 | 활성화 내용 | 추가 의존성 | +|---|---|---| +| `frontend` | `frontend` 모듈: WASM/브라우저 컨텍스트에서 오류를 `wasm_bindgen::JsValue`로 변환하고 `console.error` 로그 발행 | `wasm-bindgen`, `js-sys`, `serde-wasm-bindgen` | +| `turnkey` | `turnkey` 모듈: `TurnkeyErrorKind`, `TurnkeyError`, `classify_turnkey_error` 및 `AppError`로의 변환 | — | +| `benchmarks` | Criterion 벤치마크 스위트와 CI 베이스라인 도구 (로컬 프로파일링 전용) | — | + +## 기본 변환 (항상 사용 가능) + +기능 플래그가 없어도 `AppError`는 이미 다음에서 변환됩니다: + +| 소스 | 대상 종류 | +|---|---| +| `std::io::Error` | `Internal` | +| `String` | `BadRequest` | + +## 레시피 + +problem+json, OpenAPI 문서, tracing을 갖춘 Axum 기반 REST API: + +```toml +masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] } +``` + +sqlx, 마이그레이션, metrics를 갖춘 데이터베이스 서비스: + +```toml +masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] } +``` + +gRPC 서비스: + +```toml +masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] } +``` + +Mini App 인증을 갖춘 Telegram 봇: + +```toml +masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] } +``` + +WASM 프런트엔드: + +```toml +masterror = { version = "0.28", features = ["frontend", "serde_json"] } +``` + +`no_std` 임베디드 또는 라이브러리 타깃: + +```toml +masterror = { version = "0.28", default-features = false } +``` + +## 참고 사항 + +- `sqlx`와 `sqlx-migrate`를 제외한 모든 통합 플래그는 `std`를 포함합니다. 이 두 플래그는 플래그 수준에서 `std`에 독립적입니다. +- `axum`과 `actix`는 응답 본문이 JSON이므로 `serde_json`을 전이적으로 가져옵니다. +- 기능 플래그는 이미 활성화된 `ErrorResponse`/`ProblemJson` 필드의 와이어 계약을 절대 변경하지 않습니다. 기능(예: `serde_json`은 `details`를 일반 텍스트에서 구조화된 JSON으로 업그레이드)이나 트레이트 구현만 추가합니다. + +--- + +함께 보기: [시작하기](시작하기) · [웹 프레임워크](웹-프레임워크) · [통합](통합) · [관측성](관측성) · [no_std 지원](no_std-지원) diff --git "a/wiki/\353\247\210\354\235\264\352\267\270\353\240\210\354\235\264\354\205\230.md" "b/wiki/\353\247\210\354\235\264\352\267\270\353\240\210\354\235\264\354\205\230.md" new file mode 100644 index 0000000..511d6ae --- /dev/null +++ "b/wiki/\353\247\210\354\235\264\352\267\270\353\240\210\354\235\264\354\205\230.md" @@ -0,0 +1,137 @@ +# 마이그레이션 + +`masterror`는 `thiserror`(파생 구문)와 `anyhow`(사용 편의성) 모두의 대체재로 +설계되었습니다. 대부분의 마이그레이션은 의존성 교체와 타입 기반 기능의 점진적 +도입으로 이루어집니다. 실행 가능한 워크스루: +[`examples/migrate_from_thiserror.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_thiserror.rs) +및 +[`examples/migrate_from_anyhow.rs`](https://github.com/RAprogramm/masterror/blob/main/examples/migrate_from_anyhow.rs). + +## thiserror에서 + +1단계는 기계적입니다 — import만 변경하면 됩니다. 파생 구문은 호환됩니다: + +```diff +-use thiserror::Error; ++use masterror::Error; +``` + +### 속성 호환성 + +| thiserror | masterror | 비고 | +|---|---|---| +| `{field}` 플레이스홀더가 포함된 `#[error("...")]` | 동일 | 위치 기반 `{0}` 포함 1:1 | +| 포맷 스펙 `:>8`, `:.3`, `:x`, `:p`, `:e` | 동일 | `TemplateFormatter`가 thiserror의 포매터 감지를 미러링 | +| `#[error(transparent)]` | 동일 | `Display`/`source`를 전달하는 단일 필드 래퍼 강제 | +| `#[from]` | 동일 | `From<...>` 생성, 래퍼 형태 검증 | +| `#[source]` | 동일 | `source()` 체인 연결 | +| `#[backtrace]` | 동일 | 필드에서 존중됨 | +| — | `#[app_error(kind = ..., code = ..., message)]` | **추가**: `From for AppError`(및 `AppCode`) 생성; `message`는 `Display`를 공개 메시지로 전달 | +| — | `#[provide(ref = T, value = T)]` | **추가**: `std::error::Request`를 통한 타입 기반 텔레메트리; `Option` 필드는 `Some`일 때만 제공 | +| — | `#[derive(Masterror)]` + `#[masterror(...)]` | **추가**: `category`, `redact(message, fields(...))`, `telemetry(...)`, `map.grpc`, `map.problem`을 포함한 전체 매핑 | + +기존 열거형은 변경 없이 계속 컴파일됩니다. 어노테이션을 추가하면 얻는 것: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("user {user_id} not found")] +#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] +struct UserMissing { + user_id: u64 +} + +let app: AppError = UserMissing { user_id: 42 }.into(); +assert_eq!(app.kind, AppErrorKind::NotFound); +``` + +열거형은 변형별로 매핑됩니다 — 각 변형이 자체 `#[app_error(...)]`를 가지며, +파생은 단일 `From for AppError`를 발행합니다. + +권장 순서: (1) import 교체, (2) API 경계를 넘는 타입에 `#[app_error]` 추가, +(3) 손으로 작성한 `From for AppError` 구현을 생성된 것으로 +교체, (4) 리덕션이나 메타데이터가 필요한 곳에 `#[masterror(...)]` 도입. + +## anyhow에서 + +| anyhow | masterror | 비고 | +|---|---|---| +| `anyhow::Result` | `masterror::AppResult` | `Result`의 별칭 | +| `anyhow::Error` | `masterror::AppError` / `Error` | 불투명한 덩어리 대신 종류, 코드, 메타데이터를 전달 | +| `.context("msg")` | `.context("msg")` | `masterror::ResultExt`를 통해 동일 | +| `.with_context(\|\| ...)` | `.ctx(\|\| Context::new(kind)...)` | anyhow처럼 지연 평가되지만 **타입 기반** `Context`(종류, 코드, 필드, 리덕션)를 빌드 | +| `bail!(err)` | `fail!(err)` | 포맷 장치 없이 타입 기반 오류 표현식을 받음 | +| `ensure!(cond, "msg {x}")` | `ensure!(cond, AppError::...)` | 조건 + 타입 기반 오류; 성공 경로에서 포매팅 없음 | +| `err.chain()` | `err.chain()` | 소스 체인에 대한 동일한 이터레이터 | +| `err.root_cause()` | `err.root_cause()` | 동일 | +| `err.is::()` / `downcast` / `downcast_ref` / `downcast_mut` | `AppError`에서 동일한 이름 | 다운캐스팅 동등성 | +| `#[from]` 스타일 래핑 | [기능 플래그](통합) 뒤의 `From<...>` 구현 | sqlx/redis/reqwest/... 가 사전 분류된 상태로 도착 | + +`.context()`는 기대한 그대로 동작합니다: + +```rust +use masterror::{AppResult, ResultExt}; + +fn read_config(path: &str) -> AppResult { + let content = std::fs::read_to_string(path).context("Failed to read config file")?; + Ok(content) +} +``` + +`ensure!`/`fail!`은 anyhow의 문자열 포매팅을 타입 기반 오류와 맞바꿉니다: + +```rust +use masterror::{AppError, AppResult, ensure, fail, field}; + +fn parse(content: &str, path: &str) -> AppResult<()> { + ensure!( + !content.is_empty(), + AppError::bad_request("Config file is empty") + .with_field(field::str("path", path.to_owned())) + ); + if content.starts_with("invalid") { + fail!(AppError::bad_request("Invalid config format")); + } + Ok(()) +} +``` + +오류 표현식은 가드가 걸릴 때만 평가되므로 해피 패스는 할당이 없는 상태로 +유지됩니다 — anyhow와 동일한 보장에 더해 기계 판독 가능한 코드까지 +얻습니다. + +### anyhow에 대응물이 없는 것 + +마이그레이션하면 anyhow에 상응하는 것이 없는 기능들을 얻습니다: + +- **타입 기반 분류 체계** — 문자열 기반 컨텍스트 대신 `AppErrorKind`(내부)와 + `AppCode`(공개, SCREAMING_SNAKE_CASE). + [오류 종류와 코드](오류-종류와-코드)를 참조하세요. +- **전송 매핑** — 동일한 코드 테이블에서 파생되는 Axum/Actix용 RFC 7807 + `problem+json` 및 gRPC용 `tonic::Status`. + [웹 프레임워크](웹-프레임워크)를 참조하세요. +- **텔레메트리** — 경계에서의 자동 `tracing` 이벤트, + `error_total{code,category}` 메트릭 및 지연 백트레이스. + [관측성](관측성)을 참조하세요. +- **리덕션** — 모든 전송에서 존중되는 `redactable()` 메시지 및 필드별 + `Hash`/`Last4`/`Redact` 정책. + [모범 사례](모범-사례)를 참조하세요. +- **구조화된 메타데이터** — 값을 메시지에 포매팅하는 대신 타입 기반 + `field::str/u64/duration/ip/...`. + +### 주의할 점 + +- anyhow의 `ensure!(cond, "format {}", x)` 포맷 메시지 형태에는 직접적인 + 대응물이 없습니다: 오류를 명시적으로 구성하거나 + (`AppError::bad_request(format!(...))`) — 더 좋게는 — 정적 메시지에 + 메타데이터 필드를 더하세요. +- `anyhow::Error`는 임의의 `E: Error + Send + Sync`를 받습니다. + masterror에서는 래핑 시점에 종류를 선택합니다(`Context::new(kind)` 또는 + `From` 변환) — 이 결정 지점은 마찰이 아니라 기능입니다: 바로 여기서 + 분류가 일어납니다. +- `ensure!`와 `fail!`은 모두 `return Err(...)`로 확장되므로, 표현식이 이미 + 오류 타입인 `Result<_, E>`를 반환하는 모든 함수에서 동작합니다 — `Into` + 변환은 삽입되지 않습니다. + +함께 보기: [시작하기](시작하기) · [Derive 매크로](Derive-매크로) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [모범 사례](모범-사례) diff --git "a/wiki/\353\252\250\353\262\224-\354\202\254\353\241\200.md" "b/wiki/\353\252\250\353\262\224-\354\202\254\353\241\200.md" new file mode 100644 index 0000000..6b64541 --- /dev/null +++ "b/wiki/\353\252\250\353\262\224-\354\202\254\353\241\200.md" @@ -0,0 +1,163 @@ +# 모범 사례 + +`masterror` 기반 서비스를 예측 가능하게 유지하는 패턴: 타입 기반 도메인 오류, +하나의 안정적인 코드 분류 체계, 경계에서의 전송 매핑, 그리고 내부 정보를 절대 +누출하지 않는 공개 메시지. + +## 도메인 오류를 파생하고 한 번만 매핑 + +각 바운디드 컨텍스트를 `#[derive(Error)]`가 붙은 열거형으로 모델링하고 +`#[app_error(...)]`로 `AppError` 매핑을 인라인으로 선언하세요. 파생은 +`Display`, 래핑된 소스에 대한 `From<...>`, 그리고 `AppError`/`AppCode`로의 +변환을 생성합니다 — 호출 지점마다 손으로 `match`를 작성할 필요가 없습니다. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +pub enum UserError { + #[error("user {0} not found")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] + NotFound(u64), + + #[error("email already registered")] + #[app_error(kind = AppErrorKind::Conflict, code = AppCode::Conflict, message)] + DuplicateEmail, + + #[error("storage failure")] + #[app_error(kind = AppErrorKind::Database, code = AppCode::Database)] + Storage(#[from] std::io::Error) +} + +let app: AppError = UserError::DuplicateEmail.into(); +assert_eq!(app.kind, AppErrorKind::Conflict); +``` + +`message`는 `Display` 출력이 클라이언트에게 보여도 안전한 변형에만 +포함하세요. (`Storage`처럼) 생략하면 텍스트는 내부용으로 유지되고 — +클라이언트는 해당 종류의 제목만 보게 됩니다. 전체 속성 레퍼런스: +[Derive 매크로](Derive-매크로). + +## 서비스당 하나의 `AppCode` 분류 체계 + +`AppCode`는 공개 API 계약이며, 클라이언트는 이를 기준으로 분기합니다. +집합을 작게, 문서화된 상태로, 서비스별로 유지하세요: + +- 내장 코드(`NOT_FOUND`, `CONFLICT`, `VALIDATION` 등)를 우선 사용하세요 — + 이미 정규 HTTP/gRPC/problem-type 매핑을 갖고 있습니다. +- 커스텀 코드는 호출 지점에서 인라인으로 만들지 말고 중앙에서 발행하세요: + +```rust +use masterror::AppCode; + +pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED"); +``` + +`AppCode::new`는 `const`이며 SCREAMING_SNAKE_CASE가 아닌 것은 컴파일 타임에 +패닉합니다. 런타임 문자열에는 `AppCode::try_new`를 사용하세요. 코드 이름 +변경은 파괴적 API 변경입니다 — 추가는 열거형 변형 추가처럼 취급하세요. + +## 전송 매핑은 경계에서만 + +도메인 및 서비스 계층은 `AppResult`를 반환하며 HTTP에 대해 아무것도 +모릅니다. 크레이트의 단일 `IntoResponse`/`ResponseError`/`Status` 구현이 +핸들러 계층에서 매핑을 수행합니다: + +```rust,ignore +async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult { + repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database +} +``` + +비즈니스 로직에서 상태 코드를 직접 만들지 말고, 두 번째 응답 변환을 +구현하지도 마세요 — [웹 프레임워크](웹-프레임워크)의 안정적인 +`AppErrorKind → status` 테이블이 유일한 진실의 원천입니다. + +## 민감 데이터는 리덕션하고 텔레메트리는 유지 + +독립적인 두 가지 조절 수단이 있습니다: + +- **메시지 리덕션** — `err.redactable()`(또는 `#[masterror(...)]`의 + `redact(message)`)은 로그에는 남기면서 와이어 페이로드에서 `detail`을 + 숨깁니다. +- **필드 리덕션** — 메타데이터 직렬화 시 적용되는 필드별 정책: + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::bad_request("Invalid credentials") + .with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash)) + .with_field(field::str("card", "4111111111111111").with_redaction(FieldRedaction::Last4)) + .with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact)); +``` + +`Hash`는 원시 문자열을 노출하지 않으면서 상관 분석 가치(같은 입력 → 같은 +다이제스트)를 유지하고, `Last4`는 카드/토큰 접미사에 적합하며, `Redact`는 +값을 완전히 제거합니다. 기본값은 `None`입니다. 사용자를 식별할 수 있는 것은 +기본적으로 리덕션하고, 의식적으로만 예외를 두세요 — 그 반대가 아니라요. + +## `Context` vs 파생 + +- **파생**은 오류 타입이 도메인 어휘의 일부일 때 사용하세요: 변형이 있고, + 시그니처에 나타나며, 매핑이 정적인 경우입니다. +- **`Context`**(`ResultExt::ctx` 사용)는 호출 지점에서 인프라 오류를 + 즉석에서 래핑하고 분류가 타입이 아니라 작업에 따라 달라질 때 사용하세요: + +```rust +# #[cfg(feature = "std")] { +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_state() -> masterror::AppResult> { + std::fs::read("/var/lib/app/state.bin").ctx(|| { + Context::new(AppErrorKind::Internal) + .with(field::str("path", "/var/lib/app/state.bin")) + .track_caller() + }) +} +# } +``` + +`ctx`는 지연 평가됩니다 — 클로저는 오류 경로에서만 실행됩니다. 사람이 읽을 수 +있는 메모만 필요하면 단순한 `.context("message")`를 사용하세요. 자세한 내용: +[컨텍스트와 메타데이터](컨텍스트와-메타데이터). + +## 문자열이 아니라 종류와 코드를 테스트 + +포맷된 메시지가 아니라 안정적인 분류 체계에 대해 단언하세요: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ProblemJson}; + +let err = AppError::not_found("user 42 missing"); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.code, AppCode::NotFound); + +let problem = ProblemJson::from_ref(&err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +- `ProblemJson::from_ref`를 사용하면 통합 테스트가 서버를 띄우지 않고도 + 정확한 와이어 계약을 단언할 수 있습니다. +- `mapping_for_code(&code)`는 테이블 기반 테스트를 위한 정규 HTTP 상태, + gRPC 코드 및 problem-type URI를 노출합니다. +- 리덕션 테스트에서는 `redactable()` 오류에 대해 + `problem.detail.is_none()`을 단언하고 + `metadata().iter_with_redaction()` 정책을 확인하세요. + +## 공개 메시지 vs 내부 텔레메트리 + +생성하는 모든 오류에 유용한 규칙: + +| 채널 | 내용 | +|---|---| +| `message` / `detail` | 사람 지향적이고, 민감하지 않으며, 사용자에게 보여도 될 만큼 안정적 | +| `Metadata` 필드 | ID, 시도 횟수, 엔드포인트, 소요 시간 — 리덕션 정책과 함께 로그/메트릭용 | +| `source` 체인 | 원시 하위 오류 — 로그에만 기록되며, 클라이언트에게 **절대** 직렬화되지 않음 | + +`masterror`는 마지막 행을 강제합니다(소스는 와이어 페이로드에 절대 기록되지 +않음). 하지만 첫 두 행은 여러분의 책임입니다: 문자열에 브라우저에 출력하지 +않을 내용이 포함되어 있다면, 리덕션 정책과 함께 메타데이터에 넣거나 오류를 +`redactable()`로 표시하세요. + +함께 보기: [Derive 매크로](Derive-매크로) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [오류 종류와 코드](오류-종류와-코드) · [마이그레이션](마이그레이션) diff --git "a/wiki/\354\213\234\354\236\221\355\225\230\352\270\260.md" "b/wiki/\354\213\234\354\236\221\355\225\230\352\270\260.md" new file mode 100644 index 0000000..145ecd7 --- /dev/null +++ "b/wiki/\354\213\234\354\236\221\355\225\230\352\270\260.md" @@ -0,0 +1,193 @@ +# 시작하기 + +이 페이지에서는 `masterror` 설치, 첫 `AppError` 반환, `ensure!`/`fail!`을 통한 단락 처리, 프렐루드 사용, 첫 파생 작성을 차례로 살펴봅니다. + +## 설치 + +기본 빌드는 `std` 기능만 활성화합니다 — 웹 프레임워크도, 텔레메트리 백엔드도 포함되지 않습니다: + +```toml +[dependencies] +masterror = "0.28" +``` + +필요한 통합을 그때그때 활성화하세요 (전체 목록은 [기능 플래그](기능-플래그) 참조): + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] } +``` + +MSRV는 **1.96**입니다. 이 크레이트는 `unsafe`를 금지하며 `default-features = false`로 빌드하면 `no_std`를 지원합니다. + +## 첫 오류 + +`AppError`는 의미론적 범주(`AppErrorKind`)와 선택적인 공개 메시지를 결합합니다. `AppResult`는 `Result`의 별칭입니다: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn do_work(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::new(AppErrorKind::BadRequest, "Flag must be set")); + } + Ok(()) +} + +let err = do_work(false).unwrap_err(); +assert!(matches!(err.kind, AppErrorKind::BadRequest)); +assert_eq!(err.kind.http_status(), 400); +``` + +모든 종류에는 이름 있는 생성자가 있으므로 호출 지점에서 `AppErrorKind`를 직접 쓸 일은 거의 없습니다: + +```rust +use masterror::AppError; + +let _ = AppError::not_found("user not found"); // 404 +let _ = AppError::validation("invalid email"); // 422 +let _ = AppError::unauthorized("token expired"); // 401 +let _ = AppError::forbidden("no access"); // 403 +let _ = AppError::conflict("already exists"); // 409 +let _ = AppError::rate_limited("slow down"); // 429 +let _ = AppError::internal("unexpected failure"); // 500 +let _ = AppError::service("orchestration failed"); // 500 +let _ = AppError::timeout("upstream timed out"); // 504 +let _ = AppError::bare(masterror::AppErrorKind::NotFound); // no message +``` + +타입을 포기하지 않고도 구조화된 메타데이터와 업스트림 소스를 첨부할 수 있습니다: + +```rust +use masterror::{AppError, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::i64("attempt", 2)) + .with_context(std::io::Error::other("connection reset")); + +assert_eq!(err.metadata().len(), 2); +assert!(err.source_ref().is_some()); +``` + +소스는 로그와 `chain()` 순회에서 사용할 수 있지만 **절대 클라이언트에 직렬화되지 않습니다**. + +## ensure!와 fail! + +`ensure!`와 `fail!`은 `anyhow::ensure!`/`anyhow::bail!`의 타입 기반 대안입니다. 오류 표현식은 지연 평가되므로 성공 경로에서는 포매팅도 할당도 수행하지 않습니다: + +```rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("flag must be set")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("token expired")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +``` + +`ensure!`는 복잡한 조건을 위한 상세 형식도 지원합니다: + +```rust +use masterror::{AppError, AppResult}; + +fn bounded(value: i32, max: i32) -> AppResult<()> { + masterror::ensure!( + cond = value <= max, + else = AppError::service("value too large") + ); + Ok(()) +} +``` + +## 프렐루드 + +`masterror::prelude`는 코어 타입만 재수출합니다 (`AppError`, `AppErrorKind`, `AppCode`, `AppResult`, `ErrorResponse`, 그리고 해당 기능이 켜져 있을 때의 `turnkey` 헬퍼): + +```rust +use masterror::prelude::*; + +fn handler(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::bad_request("Flag must be set")); + } + Ok(()) +} +``` + +프레임워크 트레이트 구현(Axum `IntoResponse`, Actix `Responder`)은 기능 플래그로 활성화되며 별도의 임포트가 필요하지 않습니다. + +## 외부 오류에 컨텍스트 추가하기 + +`ResultExt`는 임의의 `Result`를 `AppResult`로 승격합니다: + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +// Simple, anyhow-style message: +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); + +// Full control over category, code, metadata and redaction: +let err = read_config() + .ctx(|| Context::new(AppErrorKind::Config).with(field::str("path", "app.toml"))) + .unwrap_err(); +assert_eq!(err.kind, AppErrorKind::Config); +``` + +전체 `Context` API는 [컨텍스트와 메타데이터](컨텍스트와-메타데이터)를 참조하세요. + +## 첫 파생 + +`#[derive(Error)]`는 `thiserror` 구문을 미러링하고, `#[app_error(...)]`는 `AppError`로의 변환을 추가합니다: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal, message)] +pub struct DomainError { + #[from] + #[source] + source: std::io::Error +} + +fn load() -> Result<(), DomainError> { + Err(std::io::Error::other("disk offline").into()) +} + +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let app: AppError = err.into(); +assert!(matches!(app.kind, AppErrorKind::Internal)); +``` + +- `#[error("...")]`는 `{field}` 플레이스홀더를 사용하는 `Display` 템플릿을 정의합니다. +- `#[from]`은 래퍼에 대한 `From`를 생성합니다. +- `#[source]`는 내부 오류를 `source()`를 통해 전달합니다. +- `#[app_error(kind = ..., code = ..., message)]`는 `From for AppError`(그리고 `for AppCode`)를 생성합니다. `message` 플래그는 `Display` 출력을 공개 메시지로 노출합니다. + +열거형도 변형별 `#[error]`와 `#[app_error]` 속성으로 동일하게 동작합니다. 메타데이터, 리덕션 정책, gRPC/problem+json 매핑 테이블까지 필요하다면 `#[derive(Masterror)]`를 사용하세요 — [Derive 매크로](Derive-매크로)에서 다룹니다. + +## 다음 단계 + +- Axum 또는 Actix에서 오류를 HTTP 응답으로 매핑하기 — [웹 프레임워크](웹-프레임워크) +- 분류 체계와 와이어 계약 이해하기 — [오류 종류와 코드](오류-종류와-코드) +- sqlx, redis, reqwest 통합 활성화하기 — [기능 플래그](기능-플래그) + +--- + +함께 보기: [기능 플래그](기능-플래그) · [오류 종류와 코드](오류-종류와-코드) · [Derive 매크로](Derive-매크로) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [마이그레이션](마이그레이션) diff --git "a/wiki/\354\230\244\353\245\230-\354\242\205\353\245\230\354\231\200-\354\275\224\353\223\234.md" "b/wiki/\354\230\244\353\245\230-\354\242\205\353\245\230\354\231\200-\354\275\224\353\223\234.md" new file mode 100644 index 0000000..1d53eac --- /dev/null +++ "b/wiki/\354\230\244\353\245\230-\354\242\205\353\245\230\354\231\200-\354\275\224\353\223\234.md" @@ -0,0 +1,172 @@ +# 오류 종류와 코드 + +두 타입이 분류 체계의 근간을 이룹니다: + +- **`AppErrorKind`** — 실패의 *내부적인* 의미론적 범주. 작고 안정적이며 프레임워크에 독립적입니다. 기본 HTTP 상태를 결정합니다. +- **`AppCode`** — 클라이언트에 SCREAMING_SNAKE_CASE 문자열(예: `"NOT_FOUND"`)로 노출되는 *공개적인* 기계 판독 가능 코드. 와이어 계약의 일부입니다. + +모든 `AppError`는 둘 다 지닙니다. `AppCode::from(kind)`는 표준 1:1 매핑을 제공하며, `AppError::with_code(...)`는 범주를 바꾸지 않고 공개 코드를 재정의합니다. + +## AppErrorKind 분류 체계 + +| 변형 | 의미 | HTTP | +|---|---|---| +| `NotFound` | 리소스가 존재하지 않거나 호출자에게 보이지 않음 | 404 | +| `Validation` | 구조화된 입력이 검증에 실패함 | 422 | +| `Conflict` | 상태 충돌 (유니크 키 위반, 버전 불일치) | 409 | +| `Unauthorized` | 인증이 필요하거나 인증에 실패함 | 401 | +| `Forbidden` | 인증되었으나 허용되지 않음 | 403 | +| `NotImplemented` | 이 배포에서 지원하지 않는 연산 | 501 | +| `BadRequest` | 잘못된 형식의 요청 또는 누락된 매개변수 | 400 | +| `TelegramAuth` | Telegram 인증 플로우 실패 | 401 | +| `InvalidJwt` | JWT 만료, 형식 오류 또는 잘못된 서명/클레임 | 401 | +| `RateLimited` | 클라이언트가 속도 제한 또는 할당량을 초과함 | 429 | +| `Timeout` | 연산이 제시간에 완료되지 않음 | 504 | +| `Network` | 네트워크 수준 오류 (DNS, 연결, TLS) | 503 | +| `DependencyUnavailable` | 외부 의존성이 다운되었거나 성능 저하됨 | 503 | +| `Internal` | 예기치 않은 서버 측 실패 | 500 | +| `Database` | 데이터베이스 실패 (쿼리, 연결, 마이그레이션) | 500 | +| `Service` | 일반적인 서비스 계층/비즈니스 로직 실패 | 500 | +| `Config` | 누락되었거나 유효하지 않은 구성 | 500 | +| `Turnkey` | Turnkey 서브시스템 실패 | 500 | +| `Serialization` | 데이터 인코딩 실패 | 500 | +| `Deserialization` | 데이터 디코딩 실패 | 500 | +| `ExternalApi` | 업스트림 API가 오류를 반환함 | 500 | +| `Queue` | 큐 발행/소비/확인 실패 | 500 | +| `Cache` | 캐시 읽기/쓰기/인코딩 실패 | 500 | + +```rust +use masterror::AppErrorKind; + +let kind = AppErrorKind::NotFound; +assert_eq!(kind.http_status(), 404); // always available, u16 +assert_eq!(kind.label(), "Not found"); // human-readable title +// With the `axum` feature: kind.status_code() -> axum::http::StatusCode +``` + +매핑에 내재된 설계 규칙: 인프라 및 I/O 문제는 기본적으로 5xx로 처리합니다. `Unauthorized`(401)는 인증 실패를 의미하고, `Forbidden`(403)은 인증에는 성공했으나 접근이 거부되었음을 의미합니다. 연결/구성 실패에는 `Network`를, 업스트림 HTTP 상태 오류에는 `ExternalApi`를 사용하세요. + +## AppCode + +`AppCode`는 모든 종류에 대응하는 상수(`AppCode::NotFound` → `"NOT_FOUND"`, `AppCode::RateLimited` → `"RATE_LIMITED"`, …)와 함께 `AppCode::UserAlreadyExists`(`"USER_ALREADY_EXISTS"`, 충돌로 매핑됨)를 제공합니다. `#[non_exhaustive]`이며 호출자 정의 코드를 지원합니다: + +```rust +use std::str::FromStr; +use masterror::AppCode; + +// Compile-time literal — panics at compile-time evaluation if not SCREAMING_SNAKE_CASE +const INVALID_JSON: AppCode = AppCode::new("INVALID_JSON"); + +// Runtime value — validated, returns Result +let dynamic = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code"); +assert_eq!(dynamic.as_str(), "THIRD_PARTY_FAILURE"); + +// Parsing round-trips through the same validation +let parsed = AppCode::from_str("NOT_FOUND").expect("known code"); +assert_eq!(parsed, AppCode::NotFound); +``` + +유효한 코드는 `A-Z`, `0-9`와 단일 `_` 구분자만 포함하며 일반 JSON 문자열로 직렬화됩니다. + +## HTTP / gRPC / problem+json 매핑 테이블 + +`CODE_MAPPINGS`(및 `mapping_for_code` 조회)는 모든 내장 코드에 대한 표준 전송 매핑을 정의합니다. 알 수 없는 커스텀 코드는 `INTERNAL`(500 / gRPC 13)로 폴백합니다: + +| AppCode | HTTP | gRPC | problem `type` | +|---|---|---|---| +| `NOT_FOUND` | 404 | `NOT_FOUND` (5) | `https://errors.masterror.rs/not-found` | +| `VALIDATION` | 422 | `INVALID_ARGUMENT` (3) | `.../validation` | +| `CONFLICT` | 409 | `ALREADY_EXISTS` (6) | `.../conflict` | +| `USER_ALREADY_EXISTS` | 409 | `ALREADY_EXISTS` (6) | `.../user-already-exists` | +| `UNAUTHORIZED` | 401 | `UNAUTHENTICATED` (16) | `.../unauthorized` | +| `FORBIDDEN` | 403 | `PERMISSION_DENIED` (7) | `.../forbidden` | +| `NOT_IMPLEMENTED` | 501 | `UNIMPLEMENTED` (12) | `.../not-implemented` | +| `BAD_REQUEST` | 400 | `INVALID_ARGUMENT` (3) | `.../bad-request` | +| `RATE_LIMITED` | 429 | `RESOURCE_EXHAUSTED` (8) | `.../rate-limited` | +| `TELEGRAM_AUTH` | 401 | `UNAUTHENTICATED` (16) | `.../telegram-auth` | +| `INVALID_JWT` | 401 | `UNAUTHENTICATED` (16) | `.../invalid-jwt` | +| `INTERNAL` | 500 | `INTERNAL` (13) | `.../internal` | +| `DATABASE` | 500 | `INTERNAL` (13) | `.../database` | +| `SERVICE` | 500 | `INTERNAL` (13) | `.../service` | +| `CONFIG` | 500 | `INTERNAL` (13) | `.../config` | +| `TURNKEY` | 500 | `INTERNAL` (13) | `.../turnkey` | +| `TIMEOUT` | 504 | `DEADLINE_EXCEEDED` (4) | `.../timeout` | +| `NETWORK` | 503 | `UNAVAILABLE` (14) | `.../network` | +| `DEPENDENCY_UNAVAILABLE` | 503 | `UNAVAILABLE` (14) | `.../dependency-unavailable` | +| `SERIALIZATION` | 500 | `INTERNAL` (13) | `.../serialization` | +| `DESERIALIZATION` | 500 | `INTERNAL` (13) | `.../deserialization` | +| `EXTERNAL_API` | 500 | `UNAVAILABLE` (14) | `.../external-api` | +| `QUEUE` | 500 | `UNAVAILABLE` (14) | `.../queue` | +| `CACHE` | 500 | `UNAVAILABLE` (14) | `.../cache` | + +gRPC 값은 `tonic::Code` 판별값과 일치하므로 `tonic` 기능이 직접 변환합니다. + +```rust +use masterror::{AppCode, mapping_for_code}; + +let mapping = mapping_for_code(&AppCode::Timeout); +assert_eq!(mapping.http_status(), 504); +assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); +assert_eq!(mapping.grpc().value, 4); +assert_eq!(mapping.problem_type(), "https://errors.masterror.rs/timeout"); +``` + +## 재시도 및 인증 힌트 + +전송 어댑터는 두 가지 선택적 힌트를 HTTP 헤더로 변환합니다: + +```rust +use std::time::Duration; +use masterror::{AppError, AppErrorKind, ProblemJson}; + +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_secs(30) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); + +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); // -> Retry-After header +assert!(problem.www_authenticate.is_some()); // -> WWW-Authenticate header +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); +``` + +`ErrorResponse`에서 이에 대응하는 빌더는 `with_retry_after_secs`, `with_retry_after_duration`, `with_www_authenticate`입니다. + +## 리덕션 의미론 + +`AppError` 메시지는 클라이언트에게 안전하도록 만들어졌지만, 오류를 리덕션 가능으로 표시하여 경계에서 메시지를 제거하도록 할 수 있습니다: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("host db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); // message stripped +assert!(problem.metadata.is_none()); // metadata stripped too +``` + +`edit_policy`가 `Redact`이면 `ProblemJson`은 `detail`, `details` 그리고 `metadata` 섹션 전체를 제거합니다. 개별 메타데이터 필드는 여기에 더해 직렬화 시 적용되는 자체 `FieldRedaction` 정책(`None`, `Redact`, `Hash`, `Last4`)을 지닙니다 — [컨텍스트와 메타데이터](컨텍스트와-메타데이터)를 참조하세요. 오류 소스(`source_ref()`)는 정책과 무관하게 절대 직렬화되지 않습니다. + +## 와이어 페이로드 + +**`ProblemJson`** — RFC 7807 `application/problem+json`. `ProblemJson::from_app_error`(소유) 또는 `ProblemJson::from_ref`(대여)로 생성합니다. 필드: `type`, `title`(종류 레이블), `status`, `detail`, 선택적 `details`, `code`, `grpc`(`{name, value}`), `metadata`, 그리고 헤더용으로 직렬화되지 않는 `retry_after`/`www_authenticate`. + +**`ErrorResponse`** — 레거시 플랫 JSON 페이로드: `status`, `code`, `message`, 선택적 `details`, `retry`, `www_authenticate`. `openapi` 기능 사용 시 `utoipa::ToSchema`를 파생합니다. + +```rust +use masterror::{AppCode, AppError, AppErrorKind, ErrorResponse}; + +let app_err = AppError::new(AppErrorKind::NotFound, "user_not_found"); +let resp: ErrorResponse = (&app_err).into(); +assert_eq!(resp.status, 404); +assert_eq!(resp.code, AppCode::NotFound); +``` + +새로운 API에는 `ProblemJson`을 권장합니다. `ErrorResponse`는 이미 플랫 형태를 사용 중인 서비스를 위해 유지됩니다. + +--- + +함께 보기: [시작하기](시작하기) · [Derive 매크로](Derive-매크로) · [컨텍스트와 메타데이터](컨텍스트와-메타데이터) · [웹 프레임워크](웹-프레임워크) diff --git "a/wiki/\354\233\271-\355\224\204\353\240\210\354\236\204\354\233\214\355\201\254.md" "b/wiki/\354\233\271-\355\224\204\353\240\210\354\236\204\354\233\214\355\201\254.md" new file mode 100644 index 0000000..f4ef3e1 --- /dev/null +++ "b/wiki/\354\233\271-\355\224\204\353\240\210\354\236\204\354\233\214\355\201\254.md" @@ -0,0 +1,209 @@ +# 웹 프레임워크 + +`masterror`는 전송 경계에서 오류를 HTTP로 매핑합니다. 도메인 코드는 +[`AppResult`](오류-종류와-코드)를 반환하고, 프레임워크 어댑터가 오류를 +RFC 7807 `application/problem+json` 응답으로 변환하며, 텔레메트리를 플러시하고 +리덕션을 적용합니다. 크레이트에는 `AppError`에 대한 `IntoResponse` / +`ResponseError` 구현이 정확히 하나만 존재하므로, 매핑을 직접 작성할 일이 +없습니다. + +## 기능 플래그 + +| 기능 | 활성화 내용 | +|---|---| +| `axum` | `AppError`, `ProblemJson`, `ErrorResponse`에 대한 `IntoResponse`; `serde_json`을 함께 가져옴 | +| `actix` | `AppError`에 대한 `ResponseError`; `ProblemJson`, `ErrorResponse`에 대한 `Responder` | +| `multipart` | `Error`에 대한 `From` (`axum`을 함께 활성화) | +| `openapi` | `ErrorResponse`에 대한 `utoipa` 스키마 | + +```toml +[dependencies] +masterror = { version = "0.28", features = ["axum"] } # or ["actix"] +``` + +## 와이어 형식 + +두 어댑터 모두 [`ProblemJson`](https://docs.rs/masterror/latest/masterror/struct.ProblemJson.html)을 직렬화합니다: + +| 필드 | 타입 | 비고 | +|---|---|---| +| `type` | string URI | 정규 문제 클래스, 예: `https://errors.masterror.rs/not-found` | +| `title` | string | `AppErrorKind`에서 파생된 짧은 요약 | +| `status` | number | HTTP 상태 코드 | +| `detail` | string? | 공개 메시지; **오류가 리덕션 가능하면 생략됨** | +| `details` | object? | 구조화된 세부 정보 (`serde_json` 기능) | +| `code` | string | 안정적인 기계 판독 가능 `AppCode`, 예: `NOT_FOUND` | +| `grpc` | object? | 멀티 프로토콜 클라이언트를 위한 `{ name, value }` gRPC 매핑 | +| `metadata` | object? | `Metadata`에서 정제된 필드; 리덕션 시 생략됨 | + +전송 힌트는 본문 필드가 아니라 헤더가 됩니다: + +- `AppError::with_retry_after_secs(n)` → `Retry-After: n` +- `AppError::with_www_authenticate(challenge)` → `WWW-Authenticate: challenge` + +내부 소스(`std::error::Error` 체인)는 로그에만 기록되며 클라이언트에게 +직렬화되지 않습니다. + +## Axum + +`axum` 기능은 `AppError`, `ProblemJson` 및 `ErrorResponse`에 대해 +`IntoResponse`를 구현하고, 오류 종류에서 파생된 `axum::http::StatusCode`를 +반환하는 고유 메서드 `AppError::http_status()`를 추가합니다. 응답으로 +변환하면 텔레메트리(tracing 이벤트, 메트릭 카운터, 지연 백트레이스)가 +플러시됩니다 — [관측성](관측성)을 참조하세요. + +```rust +use axum::{Router, routing::get}; +use masterror::{AppError, AppResult}; + +async fn handler() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +let app: Router = Router::new().route("/demo", get(handler)); +``` + +힌트가 포함된 `401`: + +```rust +use masterror::AppError; + +let err = AppError::unauthorized("missing token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer realm=\"api\""); +``` + +위 코드는 상태 `401`, 헤더 `Retry-After: 7` 및 +`WWW-Authenticate: Bearer realm="api"`, 그리고 다음 본문을 생성합니다: + +```json +{ + "type": "https://errors.masterror.rs/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "missing token", + "code": "UNAUTHORIZED", + "grpc": { "name": "UNAUTHENTICATED", "value": 16 } +} +``` + +### 핸들러의 도메인 오류 + +[`examples/axum-rest-api`](https://github.com/RAprogramm/masterror/tree/main/examples/axum-rest-api)의 +패턴: 도메인 열거형을 파생하고, 한 번만 `AppError`로 변환한 뒤, 크레이트의 +`IntoResponse`를 재사용합니다. + +```rust +use axum::response::{IntoResponse, Response}; +use masterror::{AppError, Error}; + +#[derive(Debug, Error, Clone)] +pub enum UserError { + #[error("user not found")] + NotFound, + #[error("email already exists")] + DuplicateEmail, + #[error("invalid email format")] + InvalidEmail +} + +impl From for AppError { + fn from(err: UserError) -> Self { + match err { + UserError::NotFound => AppError::not_found(err.to_string()), + UserError::DuplicateEmail => AppError::conflict(err.to_string()), + UserError::InvalidEmail => AppError::validation(err.to_string()) + } + } +} + +impl IntoResponse for UserError { + fn into_response(self) -> Response { + AppError::from(self).into_response() + } +} +``` + +파생에 `#[app_error(kind = ..., code = ...)]`를 붙이면 `From +for AppError` 구현이 자동으로 생성됩니다 — [Derive 매크로](Derive-매크로)를 +참조하세요. + +## Actix Web + +`actix` 기능은 `AppError`에 대해 `actix_web::ResponseError`를 구현하므로, +`AppResult`를 반환하는 핸들러가 즉시 동작합니다. `error_response()`는 +텔레메트리를 발행하고 `ProblemJson::from_ref`를 통해 동일한 problem+json +페이로드를 빌드합니다. + +```rust,ignore +use actix_web::{App, HttpServer, get}; +use masterror::{AppError, AppResult}; + +#[get("/forbidden")] +async fn forbidden() -> AppResult<&'static str> { + Err(AppError::forbidden("no access")) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| App::new().service(forbidden)) + .bind(("127.0.0.1", 8080))? + .run() + .await +} +``` + +클라이언트는 다음과 함께 `403`을 받습니다: + +```json +{ + "type": "https://errors.masterror.rs/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "no access", + "code": "FORBIDDEN", + "grpc": { "name": "PERMISSION_DENIED", "value": 7 } +} +``` + +`ProblemJson`과 `ErrorResponse`도 `Responder`를 구현하므로 핸들러가 이를 +직접 반환할 수 있습니다. 상태 매핑은 Axum과 동일한 안정적인 +`AppErrorKind → StatusCode` 테이블을 사용합니다. + +## Multipart + +`multipart`(`axum`을 함께 활성화)는 +`axum::extract::multipart::MultipartError`를 파서 메시지를 보존하면서 +`AppErrorKind::BadRequest`의 `Error`로 변환합니다: + +```rust,ignore +use axum::extract::multipart::Multipart; +use masterror::{AppErrorKind, Error}; + +async fn upload(mut multipart: Multipart) -> Result<(), Error> { + while let Some(field) = multipart.next_field().await? { + let _ = field.bytes().await?; + } + Ok(()) +} +``` + +잘못된 형식의 클라이언트 페이로드는 500 대신 `400 Bad Request`로 표면화됩니다. + +## 응답 수동 빌드 + +테스트나 커스텀 전송을 위해 프레임워크 없이 페이로드를 구성할 수 있습니다: + +```rust +use masterror::{AppError, ProblemJson}; + +let problem = ProblemJson::from_app_error(AppError::not_found("resource not found")); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +``` + +`ProblemJson::from_ref(&err)`는 소비하는 대신 빌려오고, +`ProblemJson::from_error_response(resp)`는 레거시 `ErrorResponse` 와이어 +타입을 업그레이드합니다. + +함께 보기: [오류 종류와 코드](오류-종류와-코드) · [통합](통합) · [관측성](관측성) · [기능 플래그](기능-플래그) diff --git "a/wiki/\354\273\250\355\205\215\354\212\244\355\212\270\354\231\200-\353\251\224\355\203\200\353\215\260\354\235\264\355\204\260.md" "b/wiki/\354\273\250\355\205\215\354\212\244\355\212\270\354\231\200-\353\251\224\355\203\200\353\215\260\354\235\264\355\204\260.md" new file mode 100644 index 0000000..36fe8b7 --- /dev/null +++ "b/wiki/\354\273\250\355\205\215\354\212\244\355\212\270\354\231\200-\353\251\224\355\203\200\353\215\260\354\235\264\355\204\260.md" @@ -0,0 +1,182 @@ +# 컨텍스트와 메타데이터 + +`masterror`는 문자열로 이어 붙인 컨텍스트(`format!("failed to X: {e}")`)를 세 가지 구조화된 메커니즘으로 대체합니다: `Context` 빌더, 타입 기반 `Metadata` 필드, 그리고 전송 경계에서 적용되는 리덕션 정책입니다. + +## ResultExt: 외부 오류 승격하기 + +`ResultExt`는 `E: Error + Send + Sync + 'static`인 모든 `Result`에 구현되어 있으며 두 가지 메서드를 제공합니다: + +### `.context(msg)` — anyhow 스타일 + +오류를 메시지로 감싸고, 원본 오류는 소스가 됩니다: + +```rust +use masterror::ResultExt; + +fn read_config() -> Result { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) +} + +let err = read_config().context("Failed to read config file").unwrap_err(); +assert!(err.source_ref().is_some()); +``` + +기저 오류가 이미 `masterror::Error`인 경우 `.context()`는 그 분류를 보존합니다. 종류, 코드, 메타데이터, 편집 정책, 재시도 힌트, 세부 정보가 이어지고, 메시지만 교체되며 원본 오류는 소스로 유지됩니다. + +### `.ctx(|| Context)` — 완전한 제어 + +```rust +use masterror::{AppErrorKind, Context, ResultExt, field}; + +fn validate() -> Result<(), std::io::Error> { + Err(std::io::Error::other("boom")) +} + +let err = validate() + .ctx(|| { + Context::new(AppErrorKind::Validation) + .with(field::str("phase", "validate")) + .redact(true) + .track_caller() + }) + .unwrap_err(); + +assert_eq!(err.kind, AppErrorKind::Validation); +assert!(err.metadata().get("phase").is_some()); +``` + +클로저는 오류 경로에서만 평가됩니다. + +## Context 빌더 + +| 메서드 | 효과 | +|---|---| +| `Context::new(kind)` | 대상 범주. `AppCode`는 해당 종류의 표준 매핑으로 기본 설정됩니다 | +| `.code(AppCode)` | 공개 코드 재정의 | +| `.category(kind)` | 범주 변경. 코드가 재정의되지 않은 한 코드를 동기화 상태로 유지합니다 | +| `.with(field)` | 메타데이터 `Field` 첨부 | +| `.redact(bool)` | 메시지 리덕션 토글 (`MessageEditPolicy::Redact` / `Preserve`) | +| `.redact_field(name, FieldRedaction)` | 이름 있는 필드의 리덕션 정책 재정의 | +| `.track_caller()` | 호출 지점을 `caller.file`, `caller.line`, `caller.column` 메타데이터로 기록 | + +## 메타데이터 필드 + +`Metadata`는 정렬된 인라인 할당 방식의 타입 기반 필드 맵입니다(필드 0–4개는 스택에 유지됨). `masterror::field` 모듈로 필드를 만듭니다: + +| 빌더 | `FieldValue` 변형 | +|---|---| +| `field::str("key", value)` | `Str(Cow<'static, str>)` | +| `field::i64("key", -1)` | `I64` | +| `field::u64("key", 42)` | `U64` | +| `field::f64("key", 0.5)` | `F64` | +| `field::bool("key", true)` | `Bool` | +| `field::uuid("key", uuid)` | `Uuid` | +| `field::duration("key", dur)` | `Duration` | +| `field::ip("key", addr)` | `Ip` (v4 또는 v6) | +| `field::json("key", json!({...}))` | `Json` (`serde_json` 기능 필요) | + +오류를 생성할 때나 `Context`를 통해 필드를 첨부하세요: + +```rust +use core::time::Duration; +use masterror::{AppError, FieldValue, field}; + +let err = AppError::service("downstream degraded") + .with_field(field::str("request_id", "abc123")) + .with_field(field::duration("elapsed", Duration::from_millis(1500))) + .with_field(field::u64("attempt", 2)); + +assert_eq!(err.metadata().len(), 3); +assert_eq!(err.metadata().get("attempt"), Some(&FieldValue::U64(2))); + +for (name, value) in err.metadata().iter() { + println!("{name}={value}"); +} +``` + +`with_fields(iter)`는 이터레이터로부터 확장하고, `with_metadata(meta)`는 컨테이너를 교체하며, `Metadata::insert`는 키를 덮어쓸 때 이전 값을 반환합니다. + +## 리덕션 정책 + +### 메시지 정책: `MessageEditPolicy` + +`Preserve`(기본값)는 공개 메시지를 유지하고, `Redact`는 전송에서 메시지를 제거하도록 지시합니다. 오류에 `.redactable()`, `Context`에 `.redact(true)`, 또는 `#[masterror(...)]`에 `redact(message)`로 설정합니다: + +```rust +use masterror::{AppError, MessageEditPolicy, ProblemJson}; + +let err = AppError::internal("db-3 credentials rejected").redactable(); +assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + +let problem = ProblemJson::from_app_error(err); +assert!(problem.detail.is_none()); +``` + +### 필드 정책: `FieldRedaction` + +각 필드는 메타데이터가 `ProblemJson`으로 직렬화될 때 적용되는 자체 정책을 지닙니다: + +| 정책 | 공개 페이로드에 미치는 효과 | +|---|---| +| `None` | 값이 그대로 유지됨 | +| `Redact` | 필드가 완전히 제거됨 | +| `Hash` | 값이 SHA-256 다이제스트로 대체됨 | +| `Last4` | 마지막 네 글자를 제외한 전부가 마스킹됨 | + +```rust +use masterror::{AppError, FieldRedaction, field}; + +let err = AppError::internal("payment failed") + .with_field(field::str("card_number", "4111111111111111")) + .redact_field("card_number", FieldRedaction::Last4); +``` + +비밀 정보로 보이는 흔한 이름에는 필드 생성 시 안전한 기본값이 자동으로 적용됩니다. `password`, `secret`, `authorization`, `cookie`, `session`, `jwt`, `bearer`, `otp`, `pin`을 포함하는 이름은 기본적으로 `Redact`가 되고, 토큰/키 계열 이름(`api_token`, `refresh_token`, `key`, `apikey`)은 기본적으로 `Hash`가 되며, 카드/계좌 세그먼트와 숫자 계열 세그먼트가 결합된 이름(`card_number`, `iban_no`, `account_id`)은 기본적으로 `Last4`가 됩니다. 감지는 대소문자를 구분하지 않습니다. 명시적인 `redact_field`/`with_redaction`이 항상 우선합니다. + +## 오류 체인 + +오류는 전체 인과 체인을 유지합니다. `chain()`은 오류 자신부터 근본 원인까지 반복하고, `root_cause()`는 가장 깊은 오류로 바로 건너뜁니다: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let app_err = AppError::internal("db down").with_context(io_err); + +let chain: Vec<_> = app_err.chain().collect(); +assert_eq!(chain.len(), 2); +assert_eq!(app_err.root_cause().to_string(), "disk offline"); +``` + +업스트림 오류를 첨부할 때는 `with_context(...)`를 권장합니다. 소유된 오류나 공유 `Arc` 값을 받아 기존 할당을 재사용합니다. `with_source(...)` / `with_source_arc(...)`는 더 낮은 수준의 대응 메서드입니다. + +## 다운캐스팅 + +첨부된 소스는 `is`와 `downcast_ref`/`downcast_mut`로 검사합니다: + +```rust +use masterror::AppError; + +let io_err = std::io::Error::other("disk offline"); +let err = AppError::internal("boom").with_context(io_err); + +assert!(err.is::()); + +if let Some(io) = err.downcast_ref::() { + assert_eq!(io.to_string(), "disk offline"); +} +``` + +- `is::()` — 직접 소스가 타입 `E`일 때 `true` (전체 체인을 순회하지 않음). +- `downcast_ref::()` — 소스를 `E`로 대여. +- `downcast::()` / `downcast_mut::()` — 현재는 스텁입니다 (`downcast`는 항상 `Err(self)`를, `downcast_mut`는 항상 `None`을 반환). `downcast_ref`를 권장합니다. + +더 깊은 매칭이 필요하면 `chain()`을 순회하며 각 요소에 `source.is::()` / `source.downcast_ref::()`를 사용하세요. + +## 백트레이스 + +`backtrace` 기능을 사용하면 `err.backtrace()`가 `RUST_BACKTRACE`를 존중하는 지연 캡처된 `std::backtrace::Backtrace`를 반환하고, `with_backtrace(bt)`는 명시적 캡처를 첨부합니다. 오류가 다시 감싸질 때 백트레이스는 `Arc`로 공유되므로 `.context()` 체인은 다시 캡처하지 않습니다. + +--- + +함께 보기: [시작하기](시작하기) · [오류 종류와 코드](오류-종류와-코드) · [Derive 매크로](Derive-매크로) · [관측성](관측성) · [모범 사례](모범-사례) diff --git "a/wiki/\355\206\265\355\225\251.md" "b/wiki/\355\206\265\355\225\251.md" new file mode 100644 index 0000000..7550643 --- /dev/null +++ "b/wiki/\355\206\265\355\225\251.md" @@ -0,0 +1,200 @@ +# 통합 + +선택적 기능 플래그는 널리 쓰이는 서드파티 오류 타입에서 `masterror::Error`로의 +`From<...>` 변환을 추가하므로, 호출 지점의 `?` 하나로 구조화된 메타데이터를 +갖춘 분류된 오류가 생성됩니다. 모든 변환은 안정적인 +[`AppErrorKind`](오류-종류와-코드)를 선택하고 관측성을 위한 텔레메트리 +필드(비밀 정보는 절대 포함하지 않음)를 첨부합니다. + +## 변환 매트릭스 + +| 기능 | 소스 타입 | 결과 `AppErrorKind` | +|---|---|---| +| `sqlx` | `sqlx_core::error::Error` | `NotFound`, `Conflict`, `Validation`, `Timeout`, `DependencyUnavailable`, `Config`, `BadRequest`, `Serialization`, `Deserialization`, `Network`, `Database`, `Internal` | +| `sqlx-migrate` | `sqlx::migrate::MigrateError` | `Database` (마이그레이션 단계 메타데이터 포함) | +| `redis` | `redis::RedisError` | `Cache` (기본값), `Timeout`, `DependencyUnavailable` | +| `reqwest` | `reqwest::Error` | `Timeout`, `Network`, `RateLimited`, `DependencyUnavailable`, `ExternalApi` | +| `validator` | `validator::ValidationErrors` | `Validation` | +| `config` | `config::ConfigError` | `Config` (`config.phase` 메타데이터 포함) | +| `tokio` | `tokio::time::error::Elapsed` | `Timeout` | +| `serde_json` | `serde_json::Error` | `Serialization` (I/O), `Deserialization` (구문/데이터/EOF) | +| `teloxide` | `teloxide_core::RequestError` | `ExternalApi`, `Unauthorized`, `RateLimited`, `Network`, `Deserialization`, `Internal` | +| `init-data` | `init_data_rs::InitDataError` | `TelegramAuth` | +| `tonic` | `masterror::Error` → `tonic::Status` | 아웃바운드 매핑, 아래 참조 | +| `multipart` | `axum::extract::multipart::MultipartError` | `BadRequest` ([웹 프레임워크](웹-프레임워크) 참조) | + +## sqlx 및 sqlx-migrate + +`sqlx`는 `sqlx-core`에만 의존합니다(드라이버 없음, TLS 없음). 주요 매핑: + +- `Error::RowNotFound` → `NotFound` +- 풀 타임아웃 → `Timeout`; 풀 종료 및 I/O 실패 → + `DependencyUnavailable`; TLS 오류 → `Network` +- 제약 조건 위반은 `sqlx` 오류 종류로 분류됩니다: 유니크 및 + 외래 키 위반 → `Conflict`, not-null / check 위반 → + `Validation`, 그 외 → `Database` +- 인코딩 → `Serialization`, 디코딩 → `Deserialization` + +데이터베이스 오류는 SQLSTATE와 제약 조건 이름을 메타데이터로 캡처합니다. 알려진 +SQLSTATE 코드는 공개 [`AppCode`](오류-종류와-코드)를 재정의합니다: +`23505` → `USER_ALREADY_EXISTS`, `23503` → `CONFLICT`, `23502`/`23514` → +`VALIDATION`. 일시적인 SQLSTATE(`40001` 직렬화 실패, `55P03` 잠금 +획득 불가)는 재시도 힌트를 첨부합니다. + +```rust,ignore +use masterror::{AppErrorKind, Error}; + +async fn load_user(pool: &sqlx::PgPool, id: i64) -> Result { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; // RowNotFound becomes AppErrorKind::NotFound + Ok(user) +} +``` + +`sqlx-migrate`는 전체 `sqlx`(여전히 기본 기능 없이)를 가져오며 +`sqlx::migrate::MigrateError`를 `Database`로 매핑하고, 마이그레이션 단계와 +버전을 메타데이터에 기록합니다. + +## redis + +모든 `redis::RedisError` 값은 기본적으로 `Cache`로 매핑됩니다. 타임아웃 계열 +오류는 `Timeout`이 되고 연결 실패는 `DependencyUnavailable`이 됩니다. +오류 카테고리와 코드는 메타데이터로 보존됩니다. + +## reqwest + +`reqwest`를 외부 HTTP API의 클라이언트로 취급합니다: + +- `is_timeout()` → `Timeout` +- `is_connect()` / `is_request()` → `Network` +- HTTP 상태 오류: `429` → `RateLimited`, `408` → `Timeout`, `5xx` → + `DependencyUnavailable`, 그 외 → `ExternalApi` +- 나머지 모든 경우 → `ExternalApi` + +메타데이터는 엔드포인트, 상태 및 저수준 플래그를 기록합니다. URL은 공개 +페이로드에서 해싱/리덕션 대상으로 표시됩니다. + +## validator + +`validator::ValidationErrors` → `Validation`이며, 메타데이터에 집계 +컨텍스트를 포함합니다: 실패한 필드 이름(`validation.fields`), 필드 및 오류 +개수, 그리고 첫 번째 검증 코드(`validation.codes`): + +```rust,ignore +use masterror::AppResult; +use validator::Validate; + +#[derive(Validate)] +struct Payload { + #[validate(length(min = 5))] + name: String +} + +fn check(p: &Payload) -> AppResult<()> { + p.validate()?; // ValidationErrors -> AppErrorKind::Validation + Ok(()) +} +``` + +## config, tokio, serde_json + +- `config::ConfigError` → `Config`이며, 실패 단계를 식별하는 + `config.phase` 메타데이터 필드(`not_found`, `file_parse`, `type` 등)를 + 포함합니다. +- `tokio::time::error::Elapsed` → `Timeout`이며 + `timeout.source = "tokio::time::timeout"` 메타데이터 필드를 포함합니다. 이 + 오류는 커스텀 메시지를 갖지 않으므로 클라이언트는 해당 종류의 고정 제목 + `"Operation timed out"`을 보게 됩니다. +- `serde_json::Error`는 `Error::classify()`를 통해 분류됩니다: I/O → + `Serialization`; 구문, 데이터 및 EOF → `Deserialization`. + +## teloxide + +`teloxide_core::RequestError` 매핑: + +| 변형 | `AppErrorKind` | +|---|---| +| `Api` | `ExternalApi` (유효하지 않은 토큰 → `Unauthorized`) | +| `MigrateToChatId` | `ExternalApi` | +| `RetryAfter` | `RateLimited` | +| `Network` | `Network` | +| `InvalidJson` | `Deserialization` | +| `Io` | `Internal` | + +## init-data (Telegram Mini Apps) + +모든 `init_data_rs::InitDataError` 변형(누락/유효하지 않은 해시, 만료된 +페이로드, 서명 실패)은 `TelegramAuth`로 매핑되어 Mini App 인증 실패를 +일반적인 잘못된 요청과 구분합니다. + +## tonic (아웃바운드 gRPC) + +`tonic`은 반대 방향으로 변환합니다: `From`을 통해 `masterror::Error` → +`tonic::Status`. [`AppCode`](오류-종류와-코드)는 HTTP에 사용되는 것과 동일한 +`CODE_MAPPINGS` 테이블을 통해 정규 `tonic::Code`로 매핑됩니다. +상태에는 메타데이터 항목 `app-code`, `app-http-status` 및 +`app-problem-type`이 포함되며, 존재할 경우 재시도 및 `www-authenticate` +힌트도 함께 전달됩니다. 리덕션 가능한 오류는 메시지가 종류 레이블로 대체되고 +메타데이터가 제거됩니다. + +```rust,ignore +use masterror::AppError; +use tonic::{Code, Status}; + +let status = Status::from(AppError::not_found("missing")); +assert_eq!(status.code(), Code::NotFound); +``` + +## frontend (WASM / 브라우저) + +`frontend` 기능은 `wasm-bindgen`을 기반으로 `AppError` 및 `ErrorResponse`에 +대한 `masterror::frontend::BrowserConsoleExt` 트레이트를 추가합니다: + +- `to_js_value()` — 오류를 `wasm_bindgen::JsValue`로 직렬화 +- `log_to_browser_console()` — `console.error`를 통해 발행 + +둘 다 `wasm32` 타깃에서 동작합니다. 네이티브 타깃에서는 +`BrowserConsoleError::UnsupportedTarget`을 반환합니다. 실패 모드(콘솔 없음, +`console.error` 호출 불가, 직렬화 실패)는 `BrowserConsoleError` 열거형으로 +처리됩니다. + +```rust,ignore +use masterror::{AppError, frontend::BrowserConsoleExt}; + +let err = AppError::not_found("user not found"); +err.log_to_browser_console()?; +``` + +## turnkey + +`turnkey` 기능은 `masterror::turnkey`에 작고 안정적인 도메인 분류 체계를 +노출합니다: + +| `TurnkeyErrorKind` | `AppErrorKind` | +|---|---| +| `UniqueLabel` | `Conflict` | +| `RateLimited` | `RateLimited` | +| `Timeout` | `Timeout` | +| `Auth` | `Unauthorized` | +| `Network` | `Network` | +| `Service` | `Turnkey` | + +`TurnkeyError::new(kind, msg)`는 도메인 오류를 빌드하고, `From for +AppError` 및 `From for AppErrorKind`가 매핑을 수행합니다. +`classify_turnkey_error(&str)`는 원시 프로바이더 메시지를 휴리스틱하게 +(대소문자 무시, 단어 경계 인식) `TurnkeyErrorKind`로 분류합니다: + +```rust +use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error}; +use masterror::{AppError, AppErrorKind}; + +let kind = classify_turnkey_error("429 rate-limit reached"); +assert_eq!(kind, TurnkeyErrorKind::RateLimited); + +let app: AppError = TurnkeyError::new(kind, "quota exceeded").into(); +assert_eq!(app.kind, AppErrorKind::RateLimited); +``` + +함께 보기: [기능 플래그](기능-플래그) · [웹 프레임워크](웹-프레임워크) · [오류 종류와 코드](오류-종류와-코드) · [관측성](관측성) diff --git "a/wiki/\355\231\210.md" "b/wiki/\355\231\210.md" new file mode 100644 index 0000000..f368e64 --- /dev/null +++ "b/wiki/\355\231\210.md" @@ -0,0 +1,131 @@ +
+ +# masterror + +**안정적인 코드, HTTP/gRPC 매핑, 내장 텔레메트리를 갖춘 프레임워크 독립적인 애플리케이션 오류 타입** + +[![한국어](https://img.shields.io/badge/🇰🇷_한국어-blue?style=for-the-badge)](#) +[![English](https://img.shields.io/badge/🇬🇧_English-gray?style=for-the-badge)](Home-en) +[![Русский](https://img.shields.io/badge/🇷🇺_Русский-gray?style=for-the-badge)](Главная) + +[![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) +[![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) +![MSRV](https://img.shields.io/badge/MSRV-1.96-blue) +![License](https://img.shields.io/badge/License-MIT-informational) + +
+ +--- + +## masterror란 무엇인가요? + +`masterror`는 `Display`와 `source()`만으로는 부족한 Rust 서비스를 위한 오류 처리 워크스페이스입니다. `thiserror`가 트레이트 구현 파생에서 멈추고 `anyhow`가 타입이 소거된 전파에서 멈추는 반면, `masterror`는 오류를 전송 경계까지 끝까지 운반합니다: + +- **`AppError`** — 의미론적 범주(`AppErrorKind`), 안정적인 기계 판독 가능 코드(`AppCode`), 선택적인 안전한 공개 메시지, 구조화된 메타데이터, 전송 힌트(`Retry-After`, `WWW-Authenticate`)를 갖춘 풍부한 오류 값입니다. +- **보수적인 HTTP 및 gRPC 매핑** — 모든 종류와 코드는 HTTP 상태, `tonic::Code` 판별값, RFC 7807 `type` URI에 결정론적으로 매핑됩니다. +- **타입 기반 텔레메트리** — 메타데이터는 임시방편의 `String` 맵이 아니라 필드별 리덕션 정책을 갖춘 타입 기반 필드(문자열, 정수, 부동 소수점, 기간, IP, UUID, JSON)로 저장됩니다. +- **네이티브 파생** — `#[derive(Error)]`는 `thiserror` 구문을 미러링하며, `#[app_error(...)]`와 `#[derive(Masterror)]`는 코드, 범주, 리덕션 및 매핑 테이블과 함께 도메인 오류를 `AppError`에 연결합니다. +- **설계 단계부터의 리덕션** — 소스는 절대 클라이언트에 직렬화되지 않으며, 메시지, 세부 정보 및 메타데이터 필드는 경계에서 리덕션, 해시 또는 마스킹될 수 있습니다. + +`unsafe` 코드가 없고, MSRV가 고정되어 있으며, 기본 `std` 기능을 비활성화하면 `no_std`를 지원합니다. + +## 해결하는 문제 + +| 관심사 | `thiserror` | `anyhow` | `masterror` | +|---|---|---|---| +| `Display` / `source()` 파생 | 지원 | — | 지원 (동일한 구문) | +| 컨텍스트를 포함한 타입 소거 전파 | — | 지원 | 지원 (`.ctx()` / `.context()`) | +| 안정적인 기계 판독 가능 오류 코드 | 수동 | 수동 | `AppCode`, 와이어 계약의 일부 | +| HTTP 상태 매핑 | 수동 | 수동 | `AppErrorKind::http_status()`, 안정적인 테이블 | +| gRPC 상태 매핑 | 수동 | 수동 | `CODE_MAPPINGS`, `tonic::Status` 변환 | +| RFC 7807 `problem+json` | 수동 | 수동 | `ProblemJson::from_app_error` | +| 구조화된 타입 기반 메타데이터 | — | — | `Metadata` + `field::*` 빌더 | +| 경계에서의 비밀 정보 리덕션 | — | — | `MessageEditPolicy`, `FieldRedaction` | +| tracing / metrics / backtrace 발행 | — | — | 기능 게이트, 생성 시 자동 | + +`thiserror`로 파생한 열거형은 *무슨 일이 일어났는지* 알려줍니다. `masterror`는 여기에 더해 *클라이언트가 무엇을 보는지*(상태, 코드, 안전한 메시지, problem+json), *운영자가 무엇을 보는지*(구조화된 필드, tracing 이벤트, 카운터), *무엇이 절대 유출되지 않는지*(소스, 리덕션된 필드)까지 결정합니다. + +## 주요 기능 + +| 영역 | 제공 내용 | +|---|---| +| 코어 분류 체계 | `AppError`, `AppErrorKind` (23개의 안정적인 범주), `AppCode` (SCREAMING_SNAKE_CASE 코드, 커스텀 코드 지원), `AppResult` | +| 파생 | `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error(...)]`, `#[masterror(...)]`, `#[provide(...)]` 텔레메트리 프로바이더 | +| 제어 흐름 | `ensure!` / `fail!` — 타입 기반의 할당 없는 조기 반환 | +| 컨텍스트 | `ResultExt::ctx` / `ResultExt::context`, 호출자 추적을 갖춘 `Context` 빌더 | +| 와이어 페이로드 | `ErrorResponse` (레거시 JSON), 재시도 및 인증 힌트를 갖춘 `ProblemJson` (RFC 7807) | +| 전송 | Axum `IntoResponse`, Actix `ResponseError`/`Responder`, `tonic::Status`, WASM `JsValue`, OpenAPI 스키마 | +| 통합 | `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `teloxide`, Telegram Mini Apps init data, Turnkey | +| 관측성 | `tracing` 이벤트, `metrics` 카운터, 지연 `backtrace` 캡처, 컬러 터미널 출력, `DisplayMode` (prod/staging/local) | + +## 빠른 예제 + +```rust +use masterror::{AppError, AppErrorKind, AppResult, ProblemJson, field}; + +fn find_user(id: u64) -> AppResult<()> { + masterror::ensure!(id != 0, AppError::bad_request("id must be non-zero")); + + Err(AppError::not_found("user not found") + .with_field(field::u64("user_id", id)) + .with_field(field::str("request_id", "abc123"))) +} + +let err = find_user(42).unwrap_err(); +assert_eq!(err.kind, AppErrorKind::NotFound); +assert_eq!(err.kind.http_status(), 404); + +let problem = ProblemJson::from_app_error(err); +assert_eq!(problem.status, 404); +assert_eq!(problem.code.as_str(), "NOT_FOUND"); +assert_eq!(problem.grpc.expect("grpc").name, "NOT_FOUND"); +``` + +또는 도메인 오류를 한 번만 선언하고 매핑은 파생 매크로에 맡길 수 있습니다: + +```rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); +``` + +## 워크스페이스 크레이트 + +| 크레이트 | 역할 | +|---|---| +| [`masterror`](https://crates.io/crates/masterror) | 코어 오류 타입, 메타데이터, 전송, 통합, 프렐루드 | +| [`masterror-derive`](https://crates.io/crates/masterror-derive) | `#[derive(Error)]`와 `#[derive(Masterror)]`를 지원하는 프로시저 매크로 (자동으로 가져옴) | +| [`masterror-template`](https://crates.io/crates/masterror-template) | 공유 `#[error("...")]` 템플릿 파서 | + +## 문서 + +**시작하기** + +- [시작하기](시작하기) — 설치, 첫 오류, 매크로, 첫 파생 +- [기능 플래그](기능-플래그) — 의존성을 포함한 전체 플래그 레퍼런스 + +**핵심 개념** + +- [오류 종류와 코드](오류-종류와-코드) — 분류 체계, HTTP/gRPC 테이블, problem+json +- [Derive 매크로](Derive-매크로) — `#[derive(Error)]`, `#[derive(Masterror)]`와 해당 속성 +- [컨텍스트와 메타데이터](컨텍스트와-메타데이터) — `Context`, `ResultExt`, 필드, 리덕션, 체인 + +**통합** + +- [웹 프레임워크](웹-프레임워크) — Axum, Actix, tonic +- [통합](통합) — sqlx, redis, reqwest, validator 등 +- [관측성](관측성) — tracing, metrics, 백트레이스, 디스플레이 모드 + +**고급** + +- [no_std 지원](no_std-지원) — 표준 라이브러리 없이 실행하기 +- [모범 사례](모범-사례) — 서비스와 라이브러리를 위한 패턴 +- [마이그레이션](마이그레이션) — `thiserror` / `anyhow`에서 이전하기