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