Skip to content

Error Kinds and Codes en

github-actions[bot] edited this page Jul 5, 2026 · 1 revision

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
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:

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);

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.

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:

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:

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. 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.

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 · Derive Macros · Context and Metadata · Web Frameworks

Clone this wiki locally