# orion-error
Structured error handling for Rust services with:
- layered universal error categories via `UvsReason`
- domain-specific error enums with stable `ErrorCode`
- contextual propagation via `OperationContext` and `ErrorWith`
- first-entry conversion via `IntoAs`
- structured cross-layer propagation via `ErrorConv` and `ErrorWrapAs`
- optional source-chain preservation for real underlying errors
[](https://github.com/galaxio-labs/orion-error/actions)
[](https://codecov.io/gh/galaxio-labs/orion-error)
[](https://crates.io/crates/orion-error)
## Installation
```toml
[dependencies]
orion-error = "0.6.1"
```
Optional features:
```toml
[dependencies]
orion-error = { version = "0.6.1", features = ["serde"] }
# or
orion-error = { version = "0.6.1", features = ["tracing"] }
```
Default features include `log`.
V1 fix and review baseline:
- [docs/v1-fix-and-review-plan.md](./docs/v1-fix-and-review-plan.md)
Import guidance:
- `orion_error::prelude::*` is the V1 primary-path wildcard import
- legacy `owe_*()` / `err_wrap(...)` helper imports should be taken explicitly from `orion_error::compat_prelude::*` or `orion_error::compat_traits::*`
## Quick Start
```rust
use derive_more::From;
use orion_error::{
ContextRecord, ErrorCode, ErrorWith, IntoAs, OperationContext, StructError, UvsReason,
};
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, From)]
enum AppError {
#[error("invalid request")]
InvalidRequest,
#[error("{0}")]
Uvs(UvsReason),
}
impl ErrorCode for AppError {
fn error_code(&self) -> i32 {
match self {
Self::InvalidRequest => 1000,
Self::Uvs(reason) => reason.error_code(),
}
}
}
fn load_config() -> Result<String, StructError<AppError>> {
let mut ctx = OperationContext::doing("load_config");
ctx.record("path", "config.toml");
ctx.record_meta("config.kind", "app_config");
ctx.record_meta("config.format", "toml");
std::fs::read_to_string("config.toml")
.into_as(AppError::from(UvsReason::system_error()), "read config file failed")
.doing("read config file")
.with(&ctx)
}
```
Notes:
- `DomainReason` is usually implemented automatically when your enum satisfies `From<UvsReason> + Display + PartialEq`.
- Use `record(...)` on `OperationContext`; `with(...)` on the context itself is deprecated.
- Default to `into_as(...)` for plain `Result<T, E: Error>` entering the structured system the first time.
- Use `wrap_as(...)` when the upstream value is already `StructError<_>` and the upper layer wants a new reason boundary.
- Use legacy `owe_*()` / `owe_*_source()` and `err_wrap(...)` only as compatibility paths.
## Core Concepts
### 1. `UvsReason`
`UvsReason` is the built-in cross-project error taxonomy:
- Business layer: `ValidationError` `100`, `BusinessError` `101`, `NotFoundError` `102`, `PermissionError` `103`, `LogicError` `104`, `RunRuleError` `105`
- Infrastructure layer: `DataError` `200`, `SystemError` `201`, `NetworkError` `202`, `ResourceError` `203`, `TimeoutError` `204`
- Config/external layer: `ConfigError` `300`, `ExternalError` `301`
Useful helpers:
- `error_code()`
- `is_retryable()`
- `is_high_severity()`
- `category_name()`
### 2. `StructError<R>`
`StructError<R>` is the main structured wrapper around a domain reason `R`.
It carries:
- `reason`
- `detail`
- `position`
- context stack
- optional underlying `source`
Construction styles:
```rust
let err = StructError::from(UvsReason::validation_error())
.with_detail("missing field: user_id");
```
```rust
let err = StructError::builder(UvsReason::validation_error())
.detail("missing field: user_id")
.position(location!())
.finish();
```
With preserved source:
```rust
let err = StructError::builder(UvsReason::system_error())
.detail("failed to read config")
.source(std::io::Error::other("disk offline"))
.finish();
```
For non-structured sources on an existing `StructError`, prefer:
```rust
let err = StructError::from(UvsReason::system_error())
.with_detail("failed to read config")
.with_std_source(std::io::Error::other("disk offline"));
```
### 3. Context Propagation
```rust
use orion_error::{ContextRecord, ErrorWith, OperationContext};
let mut ctx = OperationContext::doing("process_order");
ctx.record("order_id", "123");
ctx.record("user_id", "42");
let result = do_work()
.doing("validate order")
.with(&ctx);
```
Rules of thumb:
- `OperationContext::doing("process_order")` is the V1 primary naming path for the outermost goal.
- Chained `.doing("validate order")` on an error appends an inner path segment instead of replacing the outer goal.
- In V1, `doing(...)` is only naming sugar over `want(...)`; it does not change the underlying `OperationContext` model.
- Display and `serde` now expose both `Want` and `Path`, for example: `Want=process_order`, `Path=process_order / validate order`.
- Use `target_main()` to read the outermost goal and `target_path()` to read the full path.
### 3.1 Typed Metadata
`OperationContext` can also carry machine-readable metadata for diagnostics and classification:
```rust
use orion_error::{ErrorMetadata, OperationContext, StructError, UvsReason};
let ctx = OperationContext::doing("load sink defaults")
.with_meta("config.kind", "sink_defaults")
.with_meta("config.scope", "sink")
.with_meta("parse.line", 1u32);
let err = StructError::from(UvsReason::config_error()).with(ctx);
assert_eq!(err.context_metadata().get_str("config.kind"), Some("sink_defaults"));
```
Recommended usage:
- Put stable classification hints such as `config.kind`, `config.scope`, `component.name`, `parse.line` into metadata.
- Keep metadata short and machine-readable.
- Keep long human-facing explanations in `detail`.
- Metadata is not rendered by default in `Display`.
### 4. Conversion Helpers
Default recommendation for plain `Result<T, E: Error>` entering the structured system:
```rust
use orion_error::IntoAs;
read_file().into_as(UvsReason::system_error(), "read file failed")?;
http_call().into_as(UvsReason::network_error(), "http call failed")?;
```
Use `raw_source(...)` only when you must explicitly mark a downstream opt-in raw `StdError` type as unstructured:
```rust
use std::fmt;
use orion_error::{raw_source, IntoAs, RawStdError, UvsReason};
#[derive(Debug)]
struct ThirdPartyError;
impl fmt::Display for ThirdPartyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "third-party failure")
}
}
impl std::error::Error for ThirdPartyError {}
impl RawStdError for ThirdPartyError {}
third_party_call()
.map_err(raw_source)
.into_as(UvsReason::system_error(), "third-party call failed")?;
```
`raw_source(...)` is intentionally conservative in V1. It only accepts types that explicitly implement `RawStdError`; it is not a blanket `E: StdError` path, and it must not be used for `StructError<_>`.
This is the intended V1 design:
- `IntoAs` stays behind a sealed `UnstructuredSource` entry
- built-in allowlisted raw errors implement `UnstructuredSource` directly
- unknown downstream raw `StdError` types may opt in explicitly through `RawStdError`
- `StructError<_>` cannot enter `raw_source(...)`, because downstream crates cannot implement `RawStdError` for external types
In other words, V1 keeps the explicit escape hatch without reopening a blanket `E: StdError` path.
Use legacy `owe_*()` only for values that are not real error types and only implement `Display`:
```rust
parse_input().owe_validation()?;
message_only_result.owe_biz()?;
```
For converting one `StructError<R1>` into another `StructError<R2>`, prefer `err_conv()`:
```rust
repo_call().err_conv()?;
```
`err_conv()` preserves context, detail, position, and source.
If the upper layer wants to redefine the reason instead of converting it, use `wrap_as(...)` to keep the lower `StructError` as `source`:
```rust
use orion_error::ErrorWrapAs;
repo_call().wrap_as(UvsReason::system_error(), "service call failed")?;
```
In other words:
- `into_as(...)` is for `Result<T, E>` where `E` is a real non-structured error type
- `err_conv()` is for `Result<T, StructError<R1>>` to `Result<T, StructError<R2>>`
- `wrap_as(...)` is for `Result<T, StructError<R1>>` when the upper layer wants a new reason boundary
- `err_wrap(...)` remains for compatibility and should not be treated as the V1 primary API
If you want to attach a lower `StructError` directly and preserve its structured source frames, use `with_struct_source(...)`:
```rust
use orion_error::{ErrorWith, OperationContext, StructError, UvsReason};
let source = StructError::from(UvsReason::config_error()).with(
OperationContext::doing("load sink defaults")
.with_meta("config.kind", "sink_defaults")
);
let err = StructError::from(UvsReason::system_error())
.with(OperationContext::doing("start engine").with_meta("component.name", "engine"))
.with_struct_source(source);
assert_eq!(err.context_metadata().get_str("component.name"), Some("engine"));
assert_eq!(
err.source_frames()[0].metadata.get_str("config.kind"),
Some("sink_defaults")
);
```
The same rule applies to the builder API: use `.source_struct(lower_err)` for `StructError<_>` sources, and keep `.source(err)` for ordinary non-structured errors.
## Reports and Redaction
Default `Display` should stay concise. For diagnostics, logs, or structured export, use `ErrorReport` and the explicit render APIs:
```rust
use orion_error::{RedactPolicy, RenderMode, StructError, UvsReason};
struct SimplePolicy;
impl RedactPolicy for SimplePolicy {
fn redact_key(&self, key: &str) -> bool {
matches!(key, "password" | "config.secret")
}
fn redact_value(&self, _key: Option<&str>, _value: &str) -> Option<String> {
Some("<redacted>".to_string())
}
}
let err = StructError::from(UvsReason::config_error())
.with_detail("load config failed")
.with(
orion_error::OperationContext::doing("load config")
.with_meta("config.kind", "sink_defaults")
.with_meta("config.secret", "/prod/secrets/api-key"),
);
let report = err.report();
assert_eq!(report.root_metadata.get_str("config.kind"), Some("sink_defaults"));
let verbose = err.render(RenderMode::Verbose);
let redacted = err.render_redacted(RenderMode::Verbose, &SimplePolicy);
assert!(verbose.contains("config.secret"));
assert!(redacted.contains("<redacted>"));
```
Recommended usage:
- `report()` for structured inspection or custom serialization pipelines.
- `render(RenderMode::Compact)` for short summaries.
- `render(RenderMode::Verbose)` for local diagnostics and debug output.
- `render_redacted(...)` before writing potentially sensitive diagnostics to logs or external systems.
## Logging
`OperationContext` supports optional logging integration.
```rust
use orion_error::{op_context, ContextRecord};
let mut ctx = op_context!("sync-user").with_auto_log();
ctx.record("user_id", "42");
ctx.info("starting sync");
do_sync()?;
ctx.mark_suc();
```
Use `scoped_success()` if you want RAII-style success marking.
## Source Chain
If you use `with_std_source(...)`, `raw_source(...)`, or compatibility APIs such as `owe_*_source()`, the original error remains available:
```rust
use orion_error::IntoAs;
let err: StructError<UvsReason> = std::fs::read_to_string("config.toml")
.into_as(UvsReason::system_error(), "read config failed")
.unwrap_err();
assert!(std::error::Error::source(&err).is_some());
assert!(err.root_cause().is_some());
```
You can also inspect the entire chain:
```rust
let chain = err.source_chain();
let frames = err.source_frames();
let pretty = err.display_chain();
```
With the `serde` feature, serialized output also includes:
- `want`
- `path`
- `source_frames`
- `source_message`
- `source_chain`
`source_frames` is the structured form of the chain. Each frame contains:
- `index`
- `message`
- optional `display`
- optional `type_name`
- optional `error_code`
- optional `reason`
- optional `want`
- optional `path`
- optional `detail`
- optional `metadata`
- `is_root_cause`
For `StructError` sources, `message` is the stable reason text and `display` carries the full formatted error. `debug` remains available on `SourceFrame` at runtime, but it is not serialized by default because `Debug` output may contain sensitive internal fields. `source_chain` is kept as a compatibility summary; new observability pipelines should prefer `source_frames`. `type_name` is best-effort and should not be treated as a complete or stable classification key.
The underlying trait object itself is still not serialized.
If you use legacy `owe_*()` helpers, only the display string is copied into `detail`, so they are not the preferred path for normal Rust errors.
## `thiserror` Integration
Recommended pattern:
- use `thiserror` for domain enum definition
- include `Uvs(UvsReason)` as the bridge variant
- implement `ErrorCode`
- use `orion-error` for conversion, context, and classification
See [docs/thiserror-comparison.md](docs/thiserror-comparison.md).
## Migration Notes
Prefer these current names:
- `CwdGuard`-style example does not apply here; ignore older cross-project docs
- `OperationContext::record(...)` instead of deprecated `with(...)`
- `with_auto_log()` instead of deprecated `with_exit_log()`
- prefer `owe_*_source()` by default; keep `owe_*()` for `Display`-only cases
## Validation
From crate root:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features -- --test-threads=1
cargo run --example order_case
cargo run --example logging_example --features log
```
## Chinese Notes
当前版本文档以源码为准,推荐优先参考:
- [docs/tutorial.md](docs/tutorial.md)
- [docs/LOGGING.md](docs/LOGGING.md)
- [docs/thiserror-comparison.md](docs/thiserror-comparison.md)
如果 README 与源码冲突,请以 `src/` 和测试为准。