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,
11/// and an optional cause chain compatible with `std::error::Error`.
12///
13/// Fields are private to preserve the error's invariants. `http_status` is fully determined by `code`
14/// and can never drift from it. `retryable` is seeded from `code`'s default
15/// but may be intentionally overridden via the [`AppError::retryable`] builder;
16/// read access is via the getter methods
17/// and mutation via the builder methods (`with_*`, `retryable`, `context`).
18///
19/// `details` deliberately uses [`serde_json::Value`]:
20/// it models RFC 9457 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 response —
201    /// 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 response —
209    /// 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 response —
217    /// 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 preserved in logs
242    /// 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    /// Append a trailing hint after the existing error message.
260    ///
261    /// The counterpart to [`context`](Self::context): where `context` prepends call-site context,
262    /// `hint` appends advisory guidance (for example a "did you mean …?" suggestion) as a new sentence,
263    /// preserving the code, cause, structured details, and retryability of the original error.
264    ///
265    /// An empty or whitespace-only hint is a no-op, so an optional
266    /// or derived suggestion can be threaded through without a conditional at the call site.
267    /// The hint is trimmed of surrounding whitespace
268    /// and appended onto the existing message (separated by a single space when the message is non-empty);
269    /// the caller supplies any punctuation within the `hint` itself.
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// # use rskit_errors::{AppError, ErrorCode};
275    /// let err = AppError::invalid_input("task", "no such task 'buld'")
276    ///     .hint("Did you mean 'build'?");
277    /// assert_eq!(
278    ///     err.message(),
279    ///     "invalid task: no such task 'buld' Did you mean 'build'?"
280    /// );
281    /// ```
282    #[must_use]
283    pub fn hint(mut self, hint: impl Into<String>) -> Self {
284        let hint = hint.into();
285        let hint = hint.trim();
286        if hint.is_empty() {
287            return self;
288        }
289        if !self.message.is_empty() {
290            self.message.push(' ');
291        }
292        self.message.push_str(hint);
293        self
294    }
295
296    // ── Query helpers ───────────────────────────────────────────────────
297
298    /// Returns `true` if the operation that produced this error is safe to retry.
299    pub fn is_retryable(&self) -> bool {
300        self.retryable
301    }
302
303    /// Returns `true` if this error indicates a missing resource.
304    pub fn is_not_found(&self) -> bool {
305        self.code == ErrorCode::NotFound
306    }
307
308    /// Returns `true` if this error is an authentication/authorisation failure.
309    pub fn is_unauthorized(&self) -> bool {
310        matches!(
311            self.code,
312            ErrorCode::Unauthorized | ErrorCode::TokenExpired | ErrorCode::InvalidToken
313        )
314    }
315
316    /// Machine-readable error classification.
317    pub const fn code(&self) -> ErrorCode {
318        self.code
319    }
320
321    /// Human-readable error message.
322    pub fn message(&self) -> &str {
323        &self.message
324    }
325
326    /// Canonical HTTP status code for this error.
327    pub const fn http_status(&self) -> http::StatusCode {
328        self.http_status
329    }
330
331    /// Additional structured error details.
332    pub fn details(&self) -> &HashMap<String, Value> {
333        &self.details
334    }
335
336    /// Underlying source error, if one was attached.
337    pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
338        self.cause.as_deref()
339    }
340}
341
342impl serde::Serialize for AppError {
343    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
344        use serde::ser::SerializeStruct;
345        let mut s = ser.serialize_struct("AppError", 4)?;
346        s.serialize_field("code", &self.code)?;
347        s.serialize_field("message", &self.message)?;
348        s.serialize_field("retryable", &self.retryable)?;
349        if !self.details.is_empty() {
350            s.serialize_field("details", &self.details)?;
351        }
352        s.end()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    // ── AppError::new ─────────────────────────────────────────────────────────
361
362    #[test]
363    fn new_sets_code_and_message() {
364        let err = AppError::new(ErrorCode::NotFound, "item not found");
365        assert_eq!(err.code, ErrorCode::NotFound);
366        assert_eq!(err.message, "item not found");
367    }
368
369    #[test]
370    fn new_sets_retryable_true_for_retryable_code() {
371        let err = AppError::new(ErrorCode::ConnectionFailed, "conn error");
372        assert!(err.retryable);
373    }
374
375    #[test]
376    fn new_sets_retryable_false_for_non_retryable_code() {
377        let err = AppError::new(ErrorCode::Unauthorized, "no access");
378        assert!(!err.retryable);
379    }
380
381    #[test]
382    fn new_sets_http_status_from_code() {
383        let err = AppError::new(ErrorCode::NotFound, "missing");
384        assert_eq!(err.http_status, http::StatusCode::NOT_FOUND);
385    }
386
387    #[test]
388    fn new_starts_with_empty_details() {
389        let err = AppError::new(ErrorCode::Internal, "oops");
390        assert!(err.details.is_empty());
391    }
392
393    #[test]
394    fn new_starts_with_no_cause() {
395        let err = AppError::new(ErrorCode::Internal, "oops");
396        assert!(err.cause.is_none());
397    }
398
399    // ── with_detail ───────────────────────────────────────────────────────────
400
401    #[test]
402    fn with_detail_stores_single_kv() {
403        let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
404        assert_eq!(
405            err.details.get("field").and_then(|v| v.as_str()),
406            Some("email")
407        );
408    }
409
410    #[test]
411    fn with_detail_stores_multiple_kv() {
412        let err = AppError::new(ErrorCode::InvalidInput, "bad")
413            .with_detail("field", "email")
414            .with_detail("reason", "invalid format");
415        assert_eq!(err.details.len(), 2);
416        assert!(err.details.contains_key("field"));
417        assert!(err.details.contains_key("reason"));
418    }
419
420    // ── with_cause ────────────────────────────────────────────────────────────
421
422    #[test]
423    fn with_cause_stores_cause() {
424        use std::io;
425        let io_err = io::Error::new(io::ErrorKind::TimedOut, "timed out");
426        let err = AppError::new(ErrorCode::Timeout, "timed out").with_cause(io_err);
427        assert!(err.cause.is_some());
428    }
429
430    #[test]
431    fn with_cause_source_returns_cause() {
432        use std::error::Error;
433        use std::io;
434        let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
435        let err = AppError::new(ErrorCode::ConnectionFailed, "conn failed").with_cause(io_err);
436        assert!(err.source().is_some());
437    }
438
439    // ── Convenience constructors ──────────────────────────────────────────────
440
441    #[test]
442    fn hint_appends_after_message_preserving_code() {
443        let err =
444            AppError::invalid_input("task", "no such task 'buld'").hint("Did you mean 'build'?");
445        assert_eq!(err.code, ErrorCode::InvalidInput);
446        assert_eq!(
447            err.message,
448            "invalid task: no such task 'buld' Did you mean 'build'?"
449        );
450    }
451
452    #[test]
453    fn hint_preserves_cause_and_details() {
454        use std::io;
455        let io_err = io::Error::new(io::ErrorKind::NotFound, "missing");
456        let err = AppError::new(ErrorCode::InvalidInput, "bad")
457            .with_detail("field", "task")
458            .with_cause(io_err)
459            .hint("try again");
460        assert_eq!(err.message, "bad try again");
461        assert_eq!(err.code, ErrorCode::InvalidInput);
462        assert_eq!(err.retryable, ErrorCode::InvalidInput.is_retryable());
463        assert_eq!(err.http_status, ErrorCode::InvalidInput.http_status());
464        assert!(err.cause.is_some());
465        assert_eq!(
466            err.details.get("field").and_then(|v| v.as_str()),
467            Some("task")
468        );
469    }
470
471    #[test]
472    fn hint_is_a_no_op_for_an_empty_hint() {
473        let err = AppError::invalid_input("task", "no such task 'buld'").hint("");
474        assert_eq!(err.message, "invalid task: no such task 'buld'");
475    }
476
477    #[test]
478    fn hint_is_a_no_op_for_a_whitespace_only_hint() {
479        let err = AppError::invalid_input("task", "no such task 'buld'").hint("   \t");
480        assert_eq!(err.message, "invalid task: no such task 'buld'");
481    }
482
483    #[test]
484    fn hint_trims_surrounding_whitespace_to_a_single_separator() {
485        let err = AppError::new(ErrorCode::InvalidInput, "bad").hint("  try again  ");
486        assert_eq!(err.message, "bad try again");
487    }
488
489    #[test]
490    fn not_found_without_id() {
491        let err = AppError::not_found("User", None);
492        assert_eq!(err.code, ErrorCode::NotFound);
493        assert!(err.message.contains("User"));
494        assert!(!err.retryable);
495    }
496
497    #[test]
498    fn not_found_with_id() {
499        let err = AppError::not_found("User", Some("42"));
500        assert_eq!(err.code, ErrorCode::NotFound);
501        assert!(err.message.contains("42"));
502    }
503
504    #[test]
505    fn unauthorized_sets_code_and_not_retryable() {
506        let err = AppError::unauthorized("token missing");
507        assert_eq!(err.code, ErrorCode::Unauthorized);
508        assert!(!err.retryable);
509    }
510
511    #[test]
512    fn invalid_input_sets_code() {
513        let err = AppError::invalid_input("email", "must contain @");
514        assert_eq!(err.code, ErrorCode::InvalidInput);
515        assert!(err.message.contains("email"));
516        assert!(err.message.contains("must contain @"));
517    }
518
519    #[test]
520    fn timeout_is_retryable() {
521        let err = AppError::timeout("db query");
522        assert_eq!(err.code, ErrorCode::Timeout);
523        assert!(err.retryable);
524        assert!(err.message.contains("db query"));
525    }
526
527    #[test]
528    fn rate_limited_is_retryable() {
529        let err = AppError::rate_limited();
530        assert_eq!(err.code, ErrorCode::RateLimited);
531        assert!(err.retryable);
532    }
533
534    // ── Display ───────────────────────────────────────────────────────────────
535
536    #[test]
537    fn display_includes_message() {
538        let err = AppError::new(ErrorCode::NotFound, "item not found");
539        let display = format!("{err}");
540        assert!(display.contains("item not found"), "display was: {display}");
541    }
542
543    #[test]
544    fn display_includes_code() {
545        let err = AppError::new(ErrorCode::NotFound, "item not found");
546        let display = format!("{err}");
547        assert!(display.contains("NOT_FOUND"), "display was: {display}");
548    }
549
550    // ── Query helpers ─────────────────────────────────────────────────────────
551
552    #[test]
553    fn is_retryable_reflects_retryable_field() {
554        let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
555        assert!(err.is_retryable());
556    }
557
558    #[test]
559    fn is_not_found_true_for_not_found_code() {
560        let err = AppError::not_found("Resource", None);
561        assert!(err.is_not_found());
562    }
563
564    #[test]
565    fn is_not_found_false_for_other_code() {
566        let err = AppError::new(ErrorCode::Internal, "err");
567        assert!(!err.is_not_found());
568    }
569
570    #[test]
571    fn is_unauthorized_true_for_unauthorized_code() {
572        let err = AppError::unauthorized("denied");
573        assert!(err.is_unauthorized());
574    }
575
576    #[test]
577    fn is_unauthorized_true_for_token_expired() {
578        let err = AppError::token_expired();
579        assert!(err.is_unauthorized());
580    }
581}