Skip to main content

rskit_errors/
error.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::code::ErrorCode;
6
7/// Application-level structured error.
8///
9/// Carries a machine-readable [`ErrorCode`], a human-readable message,
10/// optional key→value details for rich error responses, and an optional
11/// cause chain compatible with `std::error::Error`.
12///
13/// Fields are private to preserve the error's invariants. `http_status` is
14/// fully determined by `code` and can never drift from it. `retryable` is
15/// seeded from `code`'s default but may be intentionally overridden via the
16/// [`AppError::retryable`] builder; read access is via the getter methods and
17/// mutation via the builder methods (`with_*`, `retryable`, `context`).
18///
19/// `details` deliberately uses [`serde_json::Value`]: it models RFC 9457
20/// problem-detail *extension members*, which are by definition arbitrary JSON
21/// and cannot be given a closed type without losing that openness.
22#[derive(Debug)]
23pub struct AppError {
24    /// Machine-readable error classification.
25    code: ErrorCode,
26    /// Human-readable description of the error.
27    message: String,
28    /// Whether the operation that produced this error is safe to retry.
29    retryable: bool,
30    /// Canonical HTTP status code for this error.
31    http_status: http::StatusCode,
32    /// Arbitrary key-value pairs for rich error responses.
33    details: HashMap<String, Value>,
34    /// Optional underlying error that caused this one.
35    cause: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
36}
37
38impl std::fmt::Display for AppError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}: {}", self.code, self.message)
41    }
42}
43
44impl std::error::Error for AppError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        self.cause.as_deref().map(|e| e as _)
47    }
48}
49
50impl AppError {
51    // ── Core constructor ────────────────────────────────────────────────
52
53    /// Create a new [`AppError`] from `code` and a human-readable `message`.
54    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
55        let retryable = code.is_retryable();
56        let http_status = code.http_status();
57        Self {
58            code,
59            message: message.into(),
60            retryable,
61            http_status,
62            details: HashMap::new(),
63            cause: None,
64        }
65    }
66
67    // ── Fluent builder methods ──────────────────────────────────────────
68
69    /// Attach an underlying cause to this error.
70    #[must_use]
71    pub fn with_cause(mut self, cause: impl std::error::Error + Send + Sync + 'static) -> Self {
72        self.cause = Some(Box::new(cause));
73        self
74    }
75
76    /// Add a single key-value detail entry.
77    #[must_use]
78    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
79        self.details.insert(key.into(), value.into());
80        self
81    }
82
83    /// Merge a map of detail entries into this error.
84    #[must_use]
85    pub fn with_details(mut self, details: HashMap<String, Value>) -> Self {
86        self.details.extend(details);
87        self
88    }
89
90    /// Override whether this error is considered retryable.
91    #[must_use]
92    pub fn retryable(mut self, r: bool) -> Self {
93        self.retryable = r;
94        self
95    }
96
97    // ── Convenience constructors (mirror gokit's API surface) ───────────
98
99    /// Create a `ServiceUnavailable` error for the named service.
100    pub fn service_unavailable(service: impl Into<String>) -> Self {
101        Self::new(
102            ErrorCode::ServiceUnavailable,
103            format!("service unavailable: {}", service.into()),
104        )
105    }
106
107    /// Create a `ConnectionFailed` error for the named service.
108    pub fn connection_failed(service: impl Into<String>) -> Self {
109        Self::new(
110            ErrorCode::ConnectionFailed,
111            format!("connection failed: {}", service.into()),
112        )
113    }
114
115    /// Create a `Timeout` error for the named operation.
116    pub fn timeout(operation: impl Into<String>) -> Self {
117        Self::new(
118            ErrorCode::Timeout,
119            format!("operation timed out: {}", operation.into()),
120        )
121    }
122
123    /// Create a `RateLimited` error.
124    pub fn rate_limited() -> Self {
125        Self::new(ErrorCode::RateLimited, "rate limit exceeded")
126    }
127
128    /// Create a `NotFound` error for `resource`, optionally including an `id`.
129    pub fn not_found(resource: impl Into<String>, id: Option<&str>) -> Self {
130        let msg = match id {
131            Some(id) => format!("{} '{}' not found", resource.into(), id),
132            None => format!("{} not found", resource.into()),
133        };
134        Self::new(ErrorCode::NotFound, msg)
135    }
136
137    /// Create an `AlreadyExists` error for the named resource.
138    pub fn already_exists(resource: impl Into<String>) -> Self {
139        Self::new(
140            ErrorCode::AlreadyExists,
141            format!("{} already exists", resource.into()),
142        )
143    }
144
145    /// Create a `Conflict` error with the given reason.
146    pub fn conflict(reason: impl Into<String>) -> Self {
147        Self::new(ErrorCode::Conflict, reason)
148    }
149
150    /// Create an `InvalidInput` error for the named field.
151    pub fn invalid_input(field: impl Into<String>, reason: impl Into<String>) -> Self {
152        Self::new(
153            ErrorCode::InvalidInput,
154            format!("invalid {}: {}", field.into(), reason.into()),
155        )
156    }
157
158    /// Create a `MissingField` error for the named required field.
159    pub fn missing_field(field: impl Into<String>) -> Self {
160        Self::new(
161            ErrorCode::MissingField,
162            format!("missing required field: {}", field.into()),
163        )
164    }
165
166    /// Create an `InvalidFormat` error for the named field.
167    pub fn invalid_format(field: impl Into<String>, expected: impl Into<String>) -> Self {
168        Self::new(
169            ErrorCode::InvalidFormat,
170            format!(
171                "invalid format for {}: expected {}",
172                field.into(),
173                expected.into()
174            ),
175        )
176    }
177
178    /// Create an `Unauthorized` error with the given reason.
179    pub fn unauthorized(reason: impl Into<String>) -> Self {
180        Self::new(ErrorCode::Unauthorized, reason)
181    }
182
183    /// Create a `Forbidden` error with the given reason.
184    pub fn forbidden(reason: impl Into<String>) -> Self {
185        Self::new(ErrorCode::Forbidden, reason)
186    }
187
188    /// Create a `TokenExpired` error.
189    pub fn token_expired() -> Self {
190        Self::new(ErrorCode::TokenExpired, "authentication token has expired")
191    }
192
193    /// Create an `InvalidToken` error.
194    pub fn invalid_token() -> Self {
195        Self::new(ErrorCode::InvalidToken, "authentication token is invalid")
196    }
197
198    /// Wrap an arbitrary error as an `Internal` application error.
199    ///
200    /// The cause is stored internally for logging but is NOT included in the serialized
201    /// response — callers receive a generic "internal server error" message.
202    pub fn internal(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
203        Self::new(ErrorCode::Internal, "internal server error").with_cause(cause)
204    }
205
206    /// Wrap a database error.
207    ///
208    /// The cause is stored internally for logging but is NOT included in the serialized
209    /// response — callers receive a generic "database error" message.
210    pub fn database_error(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
211        Self::new(ErrorCode::DatabaseError, "database error").with_cause(cause)
212    }
213
214    /// Wrap an external service error, naming the dependency.
215    ///
216    /// The cause is stored internally for logging but is NOT included in the serialized
217    /// response — callers receive a generic message naming only the service.
218    pub fn external_service(
219        service: impl Into<String>,
220        cause: impl std::error::Error + Send + Sync + 'static,
221    ) -> Self {
222        let svc = service.into();
223        let msg = format!("external service error ({svc})");
224        Self::new(ErrorCode::ExternalService, msg)
225            .with_cause(cause)
226            .with_detail("service", svc)
227    }
228
229    /// Create a `Cancelled` error for the named operation.
230    pub fn cancelled(operation: impl Into<String>) -> Self {
231        let op = operation.into();
232        Self::new(
233            ErrorCode::Cancelled,
234            format!("operation '{}' was cancelled", op),
235        )
236        .with_detail("operation", op)
237    }
238
239    /// Add human-readable context to this error.
240    ///
241    /// Prepends `msg` to the existing error message so call-site context is
242    /// preserved in logs and error responses without losing the original cause.
243    ///
244    /// # Examples
245    ///
246    /// ```rust
247    /// # use rskit_errors::{AppError, ErrorCode};
248    /// let err = AppError::new(ErrorCode::NotFound, "user not found")
249    ///     .context("load profile");
250    /// assert_eq!(err.message(), "load profile: user not found");
251    /// ```
252    #[must_use]
253    pub fn context(mut self, msg: impl Into<String>) -> Self {
254        let new_msg = format!("{}: {}", msg.into(), self.message);
255        self.message = new_msg;
256        self
257    }
258
259    // ── Query helpers ───────────────────────────────────────────────────
260
261    /// Returns `true` if the operation that produced this error is safe to retry.
262    pub fn is_retryable(&self) -> bool {
263        self.retryable
264    }
265
266    /// Returns `true` if this error indicates a missing resource.
267    pub fn is_not_found(&self) -> bool {
268        self.code == ErrorCode::NotFound
269    }
270
271    /// Returns `true` if this error is an authentication/authorisation failure.
272    pub fn is_unauthorized(&self) -> bool {
273        matches!(
274            self.code,
275            ErrorCode::Unauthorized | ErrorCode::TokenExpired | ErrorCode::InvalidToken
276        )
277    }
278
279    /// Machine-readable error classification.
280    pub const fn code(&self) -> ErrorCode {
281        self.code
282    }
283
284    /// Human-readable error message.
285    pub fn message(&self) -> &str {
286        &self.message
287    }
288
289    /// Canonical HTTP status code for this error.
290    pub const fn http_status(&self) -> http::StatusCode {
291        self.http_status
292    }
293
294    /// Additional structured error details.
295    pub fn details(&self) -> &HashMap<String, Value> {
296        &self.details
297    }
298
299    /// Underlying source error, if one was attached.
300    pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
301        self.cause.as_deref()
302    }
303}
304
305impl serde::Serialize for AppError {
306    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
307        use serde::ser::SerializeStruct;
308        let mut s = ser.serialize_struct("AppError", 4)?;
309        s.serialize_field("code", &self.code)?;
310        s.serialize_field("message", &self.message)?;
311        s.serialize_field("retryable", &self.retryable)?;
312        if !self.details.is_empty() {
313            s.serialize_field("details", &self.details)?;
314        }
315        s.end()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    // ── AppError::new ─────────────────────────────────────────────────────────
324
325    #[test]
326    fn new_sets_code_and_message() {
327        let err = AppError::new(ErrorCode::NotFound, "item not found");
328        assert_eq!(err.code, ErrorCode::NotFound);
329        assert_eq!(err.message, "item not found");
330    }
331
332    #[test]
333    fn new_sets_retryable_true_for_retryable_code() {
334        let err = AppError::new(ErrorCode::ConnectionFailed, "conn error");
335        assert!(err.retryable);
336    }
337
338    #[test]
339    fn new_sets_retryable_false_for_non_retryable_code() {
340        let err = AppError::new(ErrorCode::Unauthorized, "no access");
341        assert!(!err.retryable);
342    }
343
344    #[test]
345    fn new_sets_http_status_from_code() {
346        let err = AppError::new(ErrorCode::NotFound, "missing");
347        assert_eq!(err.http_status, http::StatusCode::NOT_FOUND);
348    }
349
350    #[test]
351    fn new_starts_with_empty_details() {
352        let err = AppError::new(ErrorCode::Internal, "oops");
353        assert!(err.details.is_empty());
354    }
355
356    #[test]
357    fn new_starts_with_no_cause() {
358        let err = AppError::new(ErrorCode::Internal, "oops");
359        assert!(err.cause.is_none());
360    }
361
362    // ── with_detail ───────────────────────────────────────────────────────────
363
364    #[test]
365    fn with_detail_stores_single_kv() {
366        let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
367        assert_eq!(
368            err.details.get("field").and_then(|v| v.as_str()),
369            Some("email")
370        );
371    }
372
373    #[test]
374    fn with_detail_stores_multiple_kv() {
375        let err = AppError::new(ErrorCode::InvalidInput, "bad")
376            .with_detail("field", "email")
377            .with_detail("reason", "invalid format");
378        assert_eq!(err.details.len(), 2);
379        assert!(err.details.contains_key("field"));
380        assert!(err.details.contains_key("reason"));
381    }
382
383    // ── with_cause ────────────────────────────────────────────────────────────
384
385    #[test]
386    fn with_cause_stores_cause() {
387        use std::io;
388        let io_err = io::Error::new(io::ErrorKind::TimedOut, "timed out");
389        let err = AppError::new(ErrorCode::Timeout, "timed out").with_cause(io_err);
390        assert!(err.cause.is_some());
391    }
392
393    #[test]
394    fn with_cause_source_returns_cause() {
395        use std::error::Error;
396        use std::io;
397        let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
398        let err = AppError::new(ErrorCode::ConnectionFailed, "conn failed").with_cause(io_err);
399        assert!(err.source().is_some());
400    }
401
402    // ── Convenience constructors ──────────────────────────────────────────────
403
404    #[test]
405    fn not_found_without_id() {
406        let err = AppError::not_found("User", None);
407        assert_eq!(err.code, ErrorCode::NotFound);
408        assert!(err.message.contains("User"));
409        assert!(!err.retryable);
410    }
411
412    #[test]
413    fn not_found_with_id() {
414        let err = AppError::not_found("User", Some("42"));
415        assert_eq!(err.code, ErrorCode::NotFound);
416        assert!(err.message.contains("42"));
417    }
418
419    #[test]
420    fn unauthorized_sets_code_and_not_retryable() {
421        let err = AppError::unauthorized("token missing");
422        assert_eq!(err.code, ErrorCode::Unauthorized);
423        assert!(!err.retryable);
424    }
425
426    #[test]
427    fn invalid_input_sets_code() {
428        let err = AppError::invalid_input("email", "must contain @");
429        assert_eq!(err.code, ErrorCode::InvalidInput);
430        assert!(err.message.contains("email"));
431        assert!(err.message.contains("must contain @"));
432    }
433
434    #[test]
435    fn timeout_is_retryable() {
436        let err = AppError::timeout("db query");
437        assert_eq!(err.code, ErrorCode::Timeout);
438        assert!(err.retryable);
439        assert!(err.message.contains("db query"));
440    }
441
442    #[test]
443    fn rate_limited_is_retryable() {
444        let err = AppError::rate_limited();
445        assert_eq!(err.code, ErrorCode::RateLimited);
446        assert!(err.retryable);
447    }
448
449    // ── Display ───────────────────────────────────────────────────────────────
450
451    #[test]
452    fn display_includes_message() {
453        let err = AppError::new(ErrorCode::NotFound, "item not found");
454        let display = format!("{err}");
455        assert!(display.contains("item not found"), "display was: {display}");
456    }
457
458    #[test]
459    fn display_includes_code() {
460        let err = AppError::new(ErrorCode::NotFound, "item not found");
461        let display = format!("{err}");
462        assert!(display.contains("NOT_FOUND"), "display was: {display}");
463    }
464
465    // ── Query helpers ─────────────────────────────────────────────────────────
466
467    #[test]
468    fn is_retryable_reflects_retryable_field() {
469        let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
470        assert!(err.is_retryable());
471    }
472
473    #[test]
474    fn is_not_found_true_for_not_found_code() {
475        let err = AppError::not_found("Resource", None);
476        assert!(err.is_not_found());
477    }
478
479    #[test]
480    fn is_not_found_false_for_other_code() {
481        let err = AppError::new(ErrorCode::Internal, "err");
482        assert!(!err.is_not_found());
483    }
484
485    #[test]
486    fn is_unauthorized_true_for_unauthorized_code() {
487        let err = AppError::unauthorized("denied");
488        assert!(err.is_unauthorized());
489    }
490
491    #[test]
492    fn is_unauthorized_true_for_token_expired() {
493        let err = AppError::token_expired();
494        assert!(err.is_unauthorized());
495    }
496}