Skip to content

Commit 2c25477

Browse files
committed
feat!: introduce AppCode and new ErrorResponse fields
1 parent d45222e commit 2c25477

File tree

7 files changed

+636
-168
lines changed

7 files changed

+636
-168
lines changed

CHANGELOG.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
# Changelog
22
All notable changes to this project will be documented in this file.
33

4-
## [Unreleased]
4+
## [0.3.0] - 2025-08-24
5+
### Added
6+
- `AppCode` — stable machine-readable error code (part of the wire contract).
7+
- `ErrorResponse.code`, `ErrorResponse.retry`, `ErrorResponse.www_authenticate` fields.
8+
- Axum/Actix integrations now set `Retry-After` and `WWW-Authenticate` headers when applicable.
9+
10+
### Changed (breaking)
11+
- `ErrorResponse::new` now requires `(status: u16, code: AppCode, message: impl Into<String>)`.
12+
13+
### Migration
14+
- Replace `ErrorResponse::new(status, "msg")` with
15+
`ErrorResponse::new(status, AppCode::<Variant>, "msg")`.
16+
- Optionally use `.with_retry_after_secs(...)` and/or `.with_www_authenticate(...)`
17+
to populate the new fields.
518

619
## [0.2.1] - 2025-08-20
720
### Changed
8-
- Cleanup of feature flags: clarified `openapi` vs `openapi-*`.
21+
- Cleaned up feature flags: clarified `openapi` vs `openapi-*`.
922
- Simplified error DTOs (`ErrorResponse`) with proper `ToSchema` support.
1023
- Minor code cleanup in Actix and SQLx integration.
1124

1225
### Notes
13-
- MSRV: 1.89
14-
- No unsafe
26+
- **MSRV:** 1.89
27+
- **No unsafe**
1528

1629
## [0.2.0] - 2025-08-20
1730
### Added
@@ -29,6 +42,7 @@ All notable changes to this project will be documented in this file.
2942
- **MSRV:** 1.89
3043
- **No unsafe:** the crate forbids `unsafe`.
3144

45+
[0.3.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.0
3246
[0.2.1]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.1
3347
[0.2.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.0
3448

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "masterror"
3-
version = "0.2.1"
3+
version = "0.3.0"
44
rust-version = "1.89"
55
edition = "2024"
66
description = "Application error types and response mapping"

src/code.rs

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
//! Public machine-readable error codes (`AppCode`).
2+
//!
3+
//! ## What is `AppCode`?
4+
//! A **stable error code** you can return to clients (mobile apps, frontends,
5+
//! other services). It is part of the public wire contract and is intended
6+
//! to stay stable across versions. Humans see `message`; machines key off
7+
//! `code`.
8+
//!
9+
//! `AppCode` complements (but does not replace) HTTP status codes:
10+
//! - `status` (e.g., 404, 422, 500) tells **transport-level** outcome.
11+
//! - `code` (e.g., `NOT_FOUND`, `VALIDATION`) tells **semantic category**,
12+
//! which remains stable even if your transport mapping changes.
13+
//!
14+
//! ## Stability and SemVer
15+
//! - New variants **may be added in minor releases** (non-breaking).
16+
//! - The enum is marked `#[non_exhaustive]` so downstream users must include a
17+
//! wildcard arm (`_`) when matching, which keeps them forward-compatible.
18+
//!
19+
//! ## Typical usage
20+
//! Construct an `ErrorResponse` with a code and return it to clients:
21+
//!
22+
//! ```rust
23+
//! use masterror::{AppCode, ErrorResponse};
24+
//!
25+
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found");
26+
//! ```
27+
//!
28+
//! Convert from internal taxonomy (`AppErrorKind`) to a public code:
29+
//!
30+
//! ```rust
31+
//! use masterror::{AppCode, AppErrorKind};
32+
//!
33+
//! let code = AppCode::from(AppErrorKind::Validation);
34+
//! assert_eq!(code.as_str(), "VALIDATION");
35+
//! ```
36+
//!
37+
//! Serialize to JSON (uses SCREAMING_SNAKE_CASE):
38+
//!
39+
//! ```rust
40+
//! # #[cfg(feature = "serde_json")]
41+
//! # {
42+
//! use masterror::AppCode;
43+
//! let json = serde_json::to_string(&AppCode::RateLimited).unwrap();
44+
//! assert_eq!(json, r#""RATE_LIMITED""#);
45+
//! # }
46+
//! ```
47+
//!
48+
//! Match codes safely (note the wildcard arm due to `#[non_exhaustive]`):
49+
//!
50+
//! ```rust
51+
//! use masterror::AppCode;
52+
//!
53+
//! fn is_client_error(code: AppCode) -> bool {
54+
//! match code {
55+
//! AppCode::NotFound
56+
//! | AppCode::Validation
57+
//! | AppCode::Conflict
58+
//! | AppCode::Unauthorized
59+
//! | AppCode::Forbidden
60+
//! | AppCode::NotImplemented
61+
//! | AppCode::BadRequest
62+
//! | AppCode::RateLimited
63+
//! | AppCode::TelegramAuth
64+
//! | AppCode::InvalidJwt => true,
65+
//! _ => false // future-proof: treat unknown as not client error
66+
//! }
67+
//! }
68+
//! ```
69+
70+
use std::fmt::{self, Display};
71+
72+
use serde::{Deserialize, Serialize};
73+
#[cfg(feature = "openapi")]
74+
use utoipa::ToSchema;
75+
76+
use crate::kind::AppErrorKind;
77+
78+
/// Stable machine-readable error code exposed to clients.
79+
///
80+
/// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g.,
81+
/// `"NOT_FOUND"`). This type is part of the public wire contract.
82+
///
83+
/// Design rules:
84+
/// - Keep the set small and meaningful.
85+
/// - Prefer adding new variants over overloading existing ones.
86+
/// - Do not encode private/internal details in codes.
87+
#[cfg_attr(feature = "openapi", derive(ToSchema))]
88+
#[non_exhaustive]
89+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
91+
pub enum AppCode {
92+
// ───────────── 4xx family (client-visible categories) ─────────────
93+
/// Resource does not exist or is not visible to the caller.
94+
///
95+
/// Typically mapped to HTTP **404 Not Found**.
96+
NotFound,
97+
98+
/// Input failed validation (shape, constraints, business rules).
99+
///
100+
/// Typically mapped to HTTP **422 Unprocessable Entity**.
101+
Validation,
102+
103+
/// State conflict with an existing resource or concurrent update.
104+
///
105+
/// Typically mapped to HTTP **409 Conflict**.
106+
Conflict,
107+
108+
/// Authentication required or failed (missing/invalid credentials).
109+
///
110+
/// Typically mapped to HTTP **401 Unauthorized**.
111+
Unauthorized,
112+
113+
/// Authenticated but not allowed to perform the operation.
114+
///
115+
/// Typically mapped to HTTP **403 Forbidden**.
116+
Forbidden,
117+
118+
/// Operation is not implemented or not supported by this deployment.
119+
///
120+
/// Typically mapped to HTTP **501 Not Implemented**.
121+
NotImplemented,
122+
123+
/// Malformed request or missing required parameters.
124+
///
125+
/// Typically mapped to HTTP **400 Bad Request**.
126+
BadRequest,
127+
128+
/// Client exceeded rate limits or quota.
129+
///
130+
/// Typically mapped to HTTP **429 Too Many Requests**.
131+
RateLimited,
132+
133+
/// Telegram authentication flow failed (signature, timestamp, or payload).
134+
///
135+
/// Typically mapped to HTTP **401 Unauthorized**.
136+
TelegramAuth,
137+
138+
/// Provided JWT is invalid (expired, malformed, wrong signature/claims).
139+
///
140+
/// Typically mapped to HTTP **401 Unauthorized**.
141+
InvalidJwt,
142+
143+
// ───────────── 5xx family (server/infra categories) ─────────────
144+
/// Unexpected server-side failure not captured by more specific kinds.
145+
///
146+
/// Typically mapped to HTTP **500 Internal Server Error**.
147+
Internal,
148+
149+
/// Database-related failure (query, connection, migration, etc.).
150+
///
151+
/// Typically mapped to HTTP **500 Internal Server Error**.
152+
Database,
153+
154+
/// Generic service-layer failure (business logic or orchestration).
155+
///
156+
/// Typically mapped to HTTP **500 Internal Server Error**.
157+
Service,
158+
159+
/// Configuration error (missing/invalid environment or runtime config).
160+
///
161+
/// Typically mapped to HTTP **500 Internal Server Error**.
162+
Config,
163+
164+
/// Failure in the Turnkey subsystem/integration.
165+
///
166+
/// Typically mapped to HTTP **500 Internal Server Error**.
167+
Turnkey,
168+
169+
/// Operation did not complete within the allotted time.
170+
///
171+
/// Typically mapped to HTTP **504 Gateway Timeout**.
172+
Timeout,
173+
174+
/// Network-level error (DNS, connect, TLS, request build).
175+
///
176+
/// Typically mapped to HTTP **503 Service Unavailable**.
177+
Network,
178+
179+
/// External dependency is unavailable or degraded (cache, broker,
180+
/// third-party).
181+
///
182+
/// Typically mapped to HTTP **503 Service Unavailable**.
183+
DependencyUnavailable,
184+
185+
/// Failed to serialize data (encode).
186+
///
187+
/// Typically mapped to HTTP **500 Internal Server Error**.
188+
Serialization,
189+
190+
/// Failed to deserialize data (decode).
191+
///
192+
/// Typically mapped to HTTP **500 Internal Server Error**.
193+
Deserialization,
194+
195+
/// Upstream API returned an error or protocol-level failure.
196+
///
197+
/// Typically mapped to HTTP **500 Internal Server Error**.
198+
ExternalApi,
199+
200+
/// Queue processing failure (publish/consume/ack).
201+
///
202+
/// Typically mapped to HTTP **500 Internal Server Error**.
203+
Queue,
204+
205+
/// Cache subsystem failure (read/write/encoding).
206+
///
207+
/// Typically mapped to HTTP **500 Internal Server Error**.
208+
Cache
209+
}
210+
211+
impl AppCode {
212+
/// Get the canonical string form of this code (SCREAMING_SNAKE_CASE).
213+
///
214+
/// This is equivalent to how the code is serialized to JSON.
215+
pub const fn as_str(&self) -> &'static str {
216+
match self {
217+
// 4xx
218+
AppCode::NotFound => "NOT_FOUND",
219+
AppCode::Validation => "VALIDATION",
220+
AppCode::Conflict => "CONFLICT",
221+
AppCode::Unauthorized => "UNAUTHORIZED",
222+
AppCode::Forbidden => "FORBIDDEN",
223+
AppCode::NotImplemented => "NOT_IMPLEMENTED",
224+
AppCode::BadRequest => "BAD_REQUEST",
225+
AppCode::RateLimited => "RATE_LIMITED",
226+
AppCode::TelegramAuth => "TELEGRAM_AUTH",
227+
AppCode::InvalidJwt => "INVALID_JWT",
228+
229+
// 5xx
230+
AppCode::Internal => "INTERNAL",
231+
AppCode::Database => "DATABASE",
232+
AppCode::Service => "SERVICE",
233+
AppCode::Config => "CONFIG",
234+
AppCode::Turnkey => "TURNKEY",
235+
AppCode::Timeout => "TIMEOUT",
236+
AppCode::Network => "NETWORK",
237+
AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE",
238+
AppCode::Serialization => "SERIALIZATION",
239+
AppCode::Deserialization => "DESERIALIZATION",
240+
AppCode::ExternalApi => "EXTERNAL_API",
241+
AppCode::Queue => "QUEUE",
242+
AppCode::Cache => "CACHE"
243+
}
244+
}
245+
}
246+
247+
impl Display for AppCode {
248+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249+
// Stable human/machine readable form matching JSON representation.
250+
f.write_str(self.as_str())
251+
}
252+
}
253+
254+
impl From<AppErrorKind> for AppCode {
255+
/// Map internal taxonomy (`AppErrorKind`) to public machine code
256+
/// (`AppCode`).
257+
///
258+
/// The mapping is 1:1 today and intentionally conservative.
259+
fn from(kind: AppErrorKind) -> Self {
260+
match kind {
261+
// 4xx
262+
AppErrorKind::NotFound => Self::NotFound,
263+
AppErrorKind::Validation => Self::Validation,
264+
AppErrorKind::Conflict => Self::Conflict,
265+
AppErrorKind::Unauthorized => Self::Unauthorized,
266+
AppErrorKind::Forbidden => Self::Forbidden,
267+
AppErrorKind::NotImplemented => Self::NotImplemented,
268+
AppErrorKind::BadRequest => Self::BadRequest,
269+
AppErrorKind::RateLimited => Self::RateLimited,
270+
AppErrorKind::TelegramAuth => Self::TelegramAuth,
271+
AppErrorKind::InvalidJwt => Self::InvalidJwt,
272+
273+
// 5xx
274+
AppErrorKind::Internal => Self::Internal,
275+
AppErrorKind::Database => Self::Database,
276+
AppErrorKind::Service => Self::Service,
277+
AppErrorKind::Config => Self::Config,
278+
AppErrorKind::Turnkey => Self::Turnkey,
279+
AppErrorKind::Timeout => Self::Timeout,
280+
AppErrorKind::Network => Self::Network,
281+
AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable,
282+
AppErrorKind::Serialization => Self::Serialization,
283+
AppErrorKind::Deserialization => Self::Deserialization,
284+
AppErrorKind::ExternalApi => Self::ExternalApi,
285+
AppErrorKind::Queue => Self::Queue,
286+
AppErrorKind::Cache => Self::Cache
287+
}
288+
}
289+
}
290+
291+
#[cfg(test)]
292+
mod tests {
293+
use super::{AppCode, AppErrorKind};
294+
295+
#[test]
296+
fn as_str_matches_json_serde_names() {
297+
assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND");
298+
assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED");
299+
assert_eq!(
300+
AppCode::DependencyUnavailable.as_str(),
301+
"DEPENDENCY_UNAVAILABLE"
302+
);
303+
}
304+
305+
#[test]
306+
fn mapping_from_kind_is_stable() {
307+
// Spot checks to guard against accidental remaps.
308+
assert!(matches!(
309+
AppCode::from(AppErrorKind::NotFound),
310+
AppCode::NotFound
311+
));
312+
assert!(matches!(
313+
AppCode::from(AppErrorKind::Validation),
314+
AppCode::Validation
315+
));
316+
assert!(matches!(
317+
AppCode::from(AppErrorKind::Internal),
318+
AppCode::Internal
319+
));
320+
assert!(matches!(
321+
AppCode::from(AppErrorKind::Timeout),
322+
AppCode::Timeout
323+
));
324+
}
325+
326+
#[test]
327+
fn display_uses_screaming_snake_case() {
328+
assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST");
329+
}
330+
}

0 commit comments

Comments
 (0)