masterror란 무엇인가요?
masterror는 Display와 source()만으로는 부족한 Rust 서비스를 위한 오류 처리 워크스페이스입니다. thiserror가 트레이트 구현 파생에서 멈추고 anyhow가 타입이 소거된 전파에서 멈추는 반면, masterror는 오류를 전송 경계까지 끝까지 운반합니다:
AppError— 의미론적 범주(AppErrorKind), 안정적인 기계 판독 가능 코드(AppCode), 선택적인 안전한 공개 메시지, 구조화된 메타데이터, 전송 힌트(Retry-After,WWW-Authenticate)를 갖춘 풍부한 오류 값입니다.- 보수적인 HTTP 및 gRPC 매핑 — 모든 종류와 코드는 HTTP 상태,
tonic::Code판별값, RFC 7807typeURI에 결정론적으로 매핑됩니다. - 타입 기반 텔레메트리 — 메타데이터는 임시방편의
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<T> |
| 파생 | #[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) |
빠른 예제
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");
또는 도메인 오류를 한 번만 선언하고 매핑은 파생 매크로에 맡길 수 있습니다:
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 | 코어 오류 타입, 메타데이터, 전송, 통합, 프렐루드 |
masterror-derive | #[derive(Error)]와 #[derive(Masterror)]를 지원하는 프로시저 매크로 (자동으로 가져옴) |
masterror-template | 공유 #[error("...")] 템플릿 파서 |
문서
시작하기
핵심 개념
- 오류 종류와 코드 — 분류 체계, HTTP/gRPC 테이블, problem+json
- Derive 매크로 —
#[derive(Error)],#[derive(Masterror)]와 해당 속성 - 컨텍스트와 메타데이터 —
Context,ResultExt, 필드, 리덕션, 체인
통합
- 웹 프레임워크 — Axum, Actix, tonic
- 통합 — sqlx, redis, reqwest, validator 등
- 관측성 — tracing, metrics, 백트레이스, 디스플레이 모드
고급
시작하기
이 페이지에서는 masterror 설치, 첫 AppError 반환, ensure!/fail!을 통한 단락 처리, 프렐루드 사용, 첫 파생 작성을 차례로 살펴봅니다.
설치
기본 빌드는 std 기능만 활성화합니다 — 웹 프레임워크도, 텔레메트리 백엔드도 포함되지 않습니다:
[dependencies]
masterror = "0.28"
필요한 통합을 그때그때 활성화하세요 (전체 목록은 기능 플래그 참조):
[dependencies]
masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] }
MSRV는 1.96입니다. 이 크레이트는 unsafe를 금지하며 default-features = false로 빌드하면 no_std를 지원합니다.
첫 오류
AppError는 의미론적 범주(AppErrorKind)와 선택적인 공개 메시지를 결합합니다. AppResult<T>는 Result<T, AppError>의 별칭입니다:
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를 직접 쓸 일은 거의 없습니다:
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
타입을 포기하지 않고도 구조화된 메타데이터와 업스트림 소스를 첨부할 수 있습니다:
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!의 타입 기반 대안입니다. 오류 표현식은 지연 평가되므로 성공 경로에서는 포매팅도 할당도 수행하지 않습니다:
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!는 복잡한 조건을 위한 상세 형식도 지원합니다:
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 헬퍼):
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<T, E: Error>를 AppResult<T>로 승격합니다:
use masterror::{AppErrorKind, Context, ResultExt, field};
fn read_config() -> Result<String, std::io::Error> {
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로의 변환을 추가합니다:
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<std::io::Error>를 생성합니다.#[source]는 내부 오류를source()를 통해 전달합니다.#[app_error(kind = ..., code = ..., message)]는From<DomainError> for AppError(그리고for AppCode)를 생성합니다.message플래그는Display출력을 공개 메시지로 노출합니다.
열거형도 변형별 #[error]와 #[app_error] 속성으로 동일하게 동작합니다. 메타데이터, 리덕션 정책, gRPC/problem+json 매핑 테이블까지 필요하다면 #[derive(Masterror)]를 사용하세요 — Derive 매크로에서 다룹니다.
다음 단계
- Axum 또는 Actix에서 오류를 HTTP 응답으로 매핑하기 — 웹 프레임워크
- 분류 체계와 와이어 계약 이해하기 — 오류 종류와 코드
- sqlx, redis, reqwest 통합 활성화하기 — 기능 플래그
함께 보기: 기능 플래그 · 오류 종류와 코드 · Derive 매크로 · 컨텍스트와 메타데이터 · 마이그레이션
기능 플래그
masterror는 기본 빌드를 가볍게 유지합니다. 기본적으로 활성화되는 것은 std뿐입니다. 그 외의 모든 것 — 웹 전송, 텔레메트리, 라이브러리 통합 — 은 옵트인입니다. 이 페이지는 Cargo.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 지원 참조) | — |
웹 전송
| 플래그 | 활성화 내용 | 추가 의존성 |
|---|---|---|
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:
masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] }
sqlx, 마이그레이션, metrics를 갖춘 데이터베이스 서비스:
masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] }
gRPC 서비스:
masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] }
Mini App 인증을 갖춘 Telegram 봇:
masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] }
WASM 프런트엔드:
masterror = { version = "0.28", features = ["frontend", "serde_json"] }
no_std 임베디드 또는 라이브러리 타깃:
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 지원
오류 종류와 코드
두 타입이 분류 체계의 근간을 이룹니다:
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 |
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]이며 호출자 정의 코드를 지원합니다:
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<AppCode, ParseAppCodeError>
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 기능이 직접 변환합니다.
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 헤더로 변환합니다:
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 메시지는 클라이언트에게 안전하도록 만들어졌지만, 오류를 리덕션 가능으로 표시하여 경계에서 메시지를 제거하도록 할 수 있습니다:
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를 파생합니다.
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 매크로
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 의미론을 미러링합니다.
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 단축 표기를 통한 필드 프로젝션을 포함하여 이름 있는 인수와 위치 인수를 지원합니다:
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 = ...
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<E>가 지원됩니다. |
#[from] | 래퍼에 대한 From<FieldType>을 생성합니다. 같은 필드에 #[source]를 함축합니다. |
#[backtrace] | 필드가 오류 인트로스펙션을 통해 노출되는 std::backtrace::Backtrace(또는 Option<Backtrace>)를 보유하거나, #[source]와 결합되면 소스의 백트레이스에 위임합니다. |
추론: 문자 그대로 source라는 이름의 필드는 자동으로 소스로 취급되며, std::backtrace::Backtrace(또는 Option<Backtrace>) 타입의 필드는 속성 없이도 백트레이스로 인식됩니다.
열거형은 변형별 #[error]와 변형별 #[from]/#[source]/#[backtrace]를 지원합니다:
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(플래그), no_source(플래그).
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<T> for AppError를 생성합니다.code = ...는 추가로From<T> for AppCode를 생성합니다.message는Display출력을 공개 메시지로 전달합니다. 메시지를 내부용으로 유지하려면 생략하세요.no_source는 소스 첨부를 생략하고 변환 시 도메인 오류를 폐기합니다(Send + Sync + 'static이 아닌 타입용).
생성된 From<T> for AppError는 원본 도메인 오류를 소스로 첨부합니다: downcast_ref로 다운캐스트할 수 있고 chain()/root_cause()에 나타나며, error_generic_member_access를 지원하는 툴체인에서는 #[provide] 데이터가 AppError를 통해 전달됩니다. 소스 첨부에는 T: Send + Sync + 'static이 필요합니다.
열거형은 변형별로 매핑을 선택하며, 파생은 여전히 단일 From<Enum> for AppError를 발행합니다.
#[provide(...)] — 타입 기반 텔레메트리
std::error::Request(nightly error_generic_member_access; 사용 가능할 때 자동으로 컴파일에 포함됨)를 통해 타입 기반 컨텍스트를 노출합니다. Option 필드는 값이 채워졌을 때만 프로바이더를 등록합니다:
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
}
소비자는 도메인 오류 또는 변환된 AppError에 대해 std::error::request_ref::<TelemetrySnapshot>(&err)로 스냅샷을 추출합니다 — 변환은 첨부된 소스를 통해 프로바이더를 전달합니다.
#[derive(Masterror)] — 엔드투엔드 도메인 오류
#[derive(Masterror)]는 Display, std::error::Error, From<T> for masterror::Error 그리고 컴파일 타임 전송 매핑 테이블까지 생성하며, 모두 하나의 #[masterror(...)] 속성으로 구성합니다:
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<u64>,
#[source]
source: Option<std::io::Error>
}
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<masterror::Field>로 평가되는 표현식. 값이 있는 필드는 Metadata에 삽입됩니다. 없을 때는 telemetry() 사용 |
map.grpc = <i32> | gRPC 상태 코드 (tonic::Code 판별값과 일치) |
map.problem = "<uri>" | RFC 7807 type URI |
생성되는 매핑 테이블
구조체의 경우 파생은 연관 상수를 발행하고, 열거형의 경우 변형별 매핑을 집계하는 배열과 슬라이스를 발행합니다:
| 형태 | 상수 |
|---|---|
| 구조체 | T::HTTP_MAPPING: HttpMapping, T::GRPC_MAPPING: Option<GrpcMapping>, T::PROBLEM_MAPPING: Option<ProblemMapping> |
| 열거형 | 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(...)] |
함께 보기: 시작하기 · 오류 종류와 코드 · 컨텍스트와 메타데이터 · 마이그레이션
컨텍스트와 메타데이터
masterror는 문자열로 이어 붙인 컨텍스트(format!("failed to X: {e}"))를 세 가지 구조화된 메커니즘으로 대체합니다: Context 빌더, 타입 기반 Metadata 필드, 그리고 전송 경계에서 적용되는 리덕션 정책입니다.
ResultExt: 외부 오류 승격하기
ResultExt는 E: Error + Send + Sync + 'static인 모든 Result<T, E>에 구현되어 있으며 두 가지 메서드를 제공합니다:
.context(msg) — anyhow 스타일
오류를 메시지로 감싸고, 원본 오류는 소스가 됩니다:
use masterror::ResultExt;
fn read_config() -> Result<String, std::io::Error> {
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) — 완전한 제어
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를 통해 필드를 첨부하세요:
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)로 설정합니다:
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 | 마지막 네 글자를 제외한 전부가 마스킹됨 |
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()는 가장 깊은 오류로 바로 건너뜁니다:
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<dyn Error + Send + Sync> 값을 받아 기존 할당을 재사용합니다. with_source(...) / with_source_arc(...)는 더 낮은 수준의 대응 메서드입니다.
다운캐스팅
첨부된 소스는 is와 downcast_ref/downcast_mut로 검사합니다:
use masterror::AppError;
let io_err = std::io::Error::other("disk offline");
let err = AppError::internal("boom").with_context(io_err);
assert!(err.is::<std::io::Error>());
if let Some(io) = err.downcast_ref::<std::io::Error>() {
assert_eq!(io.to_string(), "disk offline");
}
is::<E>()— 직접 소스가 타입E일 때true(전체 체인을 순회하지 않음).downcast_ref::<E>()— 소스를E로 대여.downcast::<E>()/downcast_mut::<E>()— 현재는 스텁입니다 (downcast는 항상Err(self)를,downcast_mut는 항상None을 반환).downcast_ref를 권장합니다.
더 깊은 매칭이 필요하면 chain()을 순회하며 각 요소에 source.is::<E>() / source.downcast_ref::<E>()를 사용하세요.
백트레이스
backtrace 기능을 사용하면 err.backtrace()가 RUST_BACKTRACE를 존중하는 지연 캡처된 std::backtrace::Backtrace를 반환하고, with_backtrace(bt)는 명시적 캡처를 첨부합니다. 오류가 다시 감싸질 때 백트레이스는 Arc로 공유되므로 .context() 체인은 다시 캡처하지 않습니다.
함께 보기: 시작하기 · 오류 종류와 코드 · Derive 매크로 · 관측성 · 모범 사례
웹 프레임워크
masterror는 전송 경계에서 오류를 HTTP로 매핑합니다. 도메인 코드는
AppResult<T>를 반환하고, 프레임워크 어댑터가 오류를
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::extract::multipart::MultipartError> (axum을 함께 활성화) |
openapi | ErrorResponse에 대한 utoipa 스키마 |
[dependencies]
masterror = { version = "0.28", features = ["axum"] } # or ["actix"]
와이어 형식
두 어댑터 모두 ProblemJson을 직렬화합니다:
| 필드 | 타입 | 비고 |
|---|---|---|
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: nAppError::with_www_authenticate(challenge)→WWW-Authenticate: challenge
내부 소스(std::error::Error 체인)는 로그에만 기록되며 클라이언트에게
직렬화되지 않습니다.
Axum
axum 기능은 AppError, ProblemJson 및 ErrorResponse에 대해
IntoResponse를 구현하고, 오류 종류에서 파생된 axum::http::StatusCode를
반환하는 고유 메서드 AppError::http_status()를 추가합니다. 응답으로
변환하면 텔레메트리(tracing 이벤트, 메트릭 카운터, 지연 백트레이스)가
플러시됩니다 — 관측성을 참조하세요.
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:
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", 그리고 다음 본문을 생성합니다:
{
"type": "https://errors.masterror.rs/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "missing token",
"code": "UNAUTHORIZED",
"grpc": { "name": "UNAUTHENTICATED", "value": 16 }
}
핸들러의 도메인 오류
examples/axum-rest-api의
패턴: 도메인 열거형을 파생하고, 한 번만 AppError로 변환한 뒤, 크레이트의
IntoResponse를 재사용합니다.
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<UserError> 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<UserError> for AppError 구현이 자동으로 생성됩니다 — Derive 매크로를
참조하세요.
Actix Web
actix 기능은 AppError에 대해 actix_web::ResponseError를 구현하므로,
AppResult<T>를 반환하는 핸들러가 즉시 동작합니다. error_response()는
텔레메트리를 발행하고 ProblemJson::from_ref를 통해 동일한 problem+json
페이로드를 빌드합니다.
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을 받습니다:
{
"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로 변환합니다:
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로 표면화됩니다.
응답 수동 빌드
테스트나 커스텀 전송을 위해 프레임워크 없이 페이로드를 구성할 수 있습니다:
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 와이어
타입을 업그레이드합니다.
함께 보기: 오류 종류와 코드 · 통합 · 관측성 · 기능 플래그
통합
선택적 기능 플래그는 널리 쓰이는 서드파티 오류 타입에서 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 잠금
획득 불가)는 재시도 힌트를 첨부합니다.
use masterror::{AppErrorKind, Error};
async fn load_user(pool: &sqlx::PgPool, id: i64) -> Result<User, Error> {
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()→Timeoutis_connect()/is_request()→Network- HTTP 상태 오류:
429→RateLimited,408→Timeout,5xx→DependencyUnavailable, 그 외 →ExternalApi - 나머지 모든 경우 →
ExternalApi
메타데이터는 엔드포인트, 상태 및 저수준 플래그를 기록합니다. URL은 공개 페이로드에서 해싱/리덕션 대상으로 표시됩니다.
validator
validator::ValidationErrors → Validation이며, 메타데이터에 집계
컨텍스트를 포함합니다: 실패한 필드 이름(validation.fields), 필드 및 오류
개수, 그리고 첫 번째 검증 코드(validation.codes):
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
힌트도 함께 전달됩니다. 리덕션 가능한 오류는 메시지가 종류 레이블로 대체되고
메타데이터가 제거됩니다.
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 열거형으로
처리됩니다.
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<TurnkeyError> for AppError 및 From<TurnkeyErrorKind> for AppErrorKind가 매핑을 수행합니다.
classify_turnkey_error(&str)는 원시 프로바이더 메시지를 휴리스틱하게
(대소문자 무시, 단어 경계 인식) TurnkeyErrorKind로 분류합니다:
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);
함께 보기: 기능 플래그 · 웹 프레임워크 · 오류 종류와 코드 · 관측성
관측성
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 컬러 터미널 스타일링 |
[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를 저장하세요:
log_mdc::insert("trace_id", request_id);
키가 설정된 동안 생성된 모든 오류는 이벤트에 해당 값을 포함합니다.
메트릭
metrics가 활성화되면 새로 더티 상태가 된 각 오류는 다음을 증가시킵니다:
error_total{code="NOT_FOUND", category="NotFound"}
두 레이블 모두 안정적인 문자열(AppCode::as_str()과 AppErrorKind
레이블)이므로, 도메인 타입을 리팩터링해도 대시보드와 알림이 유지됩니다.
metrics 레코더(Prometheus, StatsD 등)는 평소처럼 연결하면 됩니다.
masterror는 metrics::counter!만 사용합니다.
백트레이스
backtrace가 활성화되면 Backtrace 스냅샷은 텔레메트리가 플러시될 때
지연 캡처됩니다 — 생성할 때마다 캡처되지 않습니다. 캡처는
RUST_BACKTRACE로 제어됩니다: 미설정, 빈 값, 0, off, false는
비활성화하고, 그 외 값은 활성화합니다. 이 설정은 한 번 읽혀 프로세스별로
캐시됩니다.
#[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()를 통한 수동 플러시
생성자와 변환은 텔레메트리를 자동으로 발행합니다. 오류를 변경한 후(필드 추가, 재시도 힌트 변경) 재발행을 강제할 수 있습니다:
use masterror::{AppError, field};
let err = AppError::service("upstream degraded")
.with_field(field::str("upstream", "billing"));
err.log();
log()는 상태별로 멱등합니다: 마지막 발행 이후 변경된 것이 없으면 아무것도
하지 않습니다. HTTP/gRPC 어댑터도 경계에서 같은 방식으로 플러시하므로,
생성되고 보강된 뒤 Axum 핸들러에서 반환되는 오류는 상태당 한 번 —
생성 시 한 번, 보강된 상태에 대해 경계에서 한 번 — 발행되며, 같은 상태에 대해
두 번 발행되는 일은 없습니다.
체인 검사
기능과 무관하게 AppError는 로그 파이프라인에 필요한 도구를 노출합니다:
#[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 | 초록색 | 구조화된 필드 이름 |
#[cfg(feature = "colored")] {
use masterror::colored::style;
eprintln!(
"{}: {}",
style::error_code("ERR_DB_001"),
style::error_message("Database connection failed")
);
}
실행 가능한 데모는
examples/colored_cli.rs를
참조하세요.
함께 보기: 기능 플래그 · 컨텍스트와 메타데이터 · 웹 프레임워크 · 모범 사례
no_std 지원
masterror는 Rust 표준 라이브러리 없이 빌드됩니다. 크레이트 루트는
#![cfg_attr(not(feature = "std"), no_std)]를 선언하며, 기본 std 기능이
임베디드/WASM 친화적 빌드와 여러분 사이에 있는 유일한 장벽입니다:
[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 빌드에서 완전히 동작합니다.
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,coloredaxum,actix,multipart,tonic,openapiserde_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 호환을 유지하려는 라이브러리 크레이트:
[dependencies]
masterror = { version = "0.28", default-features = false }
[features]
std = ["masterror/std"]
바이너리 또는 서비스 크레이트는 std와 필요한 통합을 함께 켭니다:
[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 기능은 전혀 사용되지 않습니다.
모범 사례
masterror 기반 서비스를 예측 가능하게 유지하는 패턴: 타입 기반 도메인 오류,
하나의 안정적인 코드 분류 체계, 경계에서의 전송 매핑, 그리고 내부 정보를 절대
누출하지 않는 공개 메시지.
도메인 오류를 파생하고 한 번만 매핑
각 바운디드 컨텍스트를 #[derive(Error)]가 붙은 열거형으로 모델링하고
#[app_error(...)]로 AppError 매핑을 인라인으로 선언하세요. 파생은
Display, 래핑된 소스에 대한 From<...>, 그리고 AppError/AppCode로의
변환을 생성합니다 — 호출 지점마다 손으로 match를 작성할 필요가 없습니다.
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 매크로.
서비스당 하나의 AppCode 분류 체계
AppCode는 공개 API 계약이며, 클라이언트는 이를 기준으로 분기합니다.
집합을 작게, 문서화된 상태로, 서비스별로 유지하세요:
- 내장 코드(
NOT_FOUND,CONFLICT,VALIDATION등)를 우선 사용하세요 — 이미 정규 HTTP/gRPC/problem-type 매핑을 갖고 있습니다. - 커스텀 코드는 호출 지점에서 인라인으로 만들지 말고 중앙에서 발행하세요:
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<T>를 반환하며 HTTP에 대해 아무것도
모릅니다. 크레이트의 단일 IntoResponse/ResponseError/Status 구현이
핸들러 계층에서 매핑을 수행합니다:
async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult<User> {
repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database
}
비즈니스 로직에서 상태 코드를 직접 만들지 말고, 두 번째 응답 변환을
구현하지도 마세요 — 웹 프레임워크의 안정적인
AppErrorKind → status 테이블이 유일한 진실의 원천입니다.
민감 데이터는 리덕션하고 텔레메트리는 유지
독립적인 두 가지 조절 수단이 있습니다:
- 메시지 리덕션 —
err.redactable()(또는#[masterror(...)]의redact(message))은 로그에는 남기면서 와이어 페이로드에서detail을 숨깁니다. - 필드 리덕션 — 메타데이터 직렬화 시 적용되는 필드별 정책:
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사용)는 호출 지점에서 인프라 오류를 즉석에서 래핑하고 분류가 타입이 아니라 작업에 따라 달라질 때 사용하세요:
#[cfg(feature = "std")] {
use masterror::{AppErrorKind, Context, ResultExt, field};
fn read_state() -> masterror::AppResult<Vec<u8>> {
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")를 사용하세요. 자세한 내용:
컨텍스트와 메타데이터.
문자열이 아니라 종류와 코드를 테스트
포맷된 메시지가 아니라 안정적인 분류 체계에 대해 단언하세요:
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 매크로 · 컨텍스트와 메타데이터 · 오류 종류와 코드 · 마이그레이션
마이그레이션
masterror는 thiserror(파생 구문)와 anyhow(사용 편의성) 모두의 대체재로
설계되었습니다. 대부분의 마이그레이션은 의존성 교체와 타입 기반 기능의 점진적
도입으로 이루어집니다. 실행 가능한 워크스루:
examples/migrate_from_thiserror.rs
및
examples/migrate_from_anyhow.rs.
thiserror에서
1단계는 기계적입니다 — import만 변경하면 됩니다. 파생 구문은 호환됩니다:
-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<YourError> for AppError(및 AppCode) 생성; message는 Display를 공개 메시지로 전달 |
| — | #[provide(ref = T, value = T)] | 추가: std::error::Request를 통한 타입 기반 텔레메트리; Option<T> 필드는 Some일 때만 제공 |
| — | #[derive(Masterror)] + #[masterror(...)] | 추가: category, redact(message, fields(...)), telemetry(...), map.grpc, map.problem을 포함한 전체 매핑 |
기존 열거형은 변경 없이 계속 컴파일됩니다. 어노테이션을 추가하면 얻는 것:
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<Enum> for AppError를 발행합니다.
권장 순서: (1) import 교체, (2) API 경계를 넘는 타입에 #[app_error] 추가,
(3) 손으로 작성한 From<DomainError> for AppError 구현을 생성된 것으로
교체, (4) 리덕션이나 메타데이터가 필요한 곳에 #[masterror(...)] 도입.
anyhow에서
| anyhow | masterror | 비고 |
|---|---|---|
anyhow::Result<T> | masterror::AppResult<T> | Result<T, AppError>의 별칭 |
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::<E>() / downcast / downcast_ref / downcast_mut | AppError에서 동일한 이름 | 다운캐스팅 동등성 |
#[from] 스타일 래핑 | 기능 플래그 뒤의 From<...> 구현 | sqlx/redis/reqwest/… 가 사전 분류된 상태로 도착 |
.context()는 기대한 그대로 동작합니다:
use masterror::{AppResult, ResultExt};
fn read_config(path: &str) -> AppResult<String> {
let content = std::fs::read_to_string(path).context("Failed to read config file")?;
Ok(content)
}
ensure!/fail!은 anyhow의 문자열 포매팅을 타입 기반 오류와 맞바꿉니다:
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 매크로 · 컨텍스트와 메타데이터 · 모범 사례