error_envelope/
error.rs

1use crate::Code;
2use serde::{Serialize, Serializer};
3use std::fmt;
4use std::time::Duration;
5
6/// Structured error envelope for HTTP APIs.
7#[derive(Debug, Clone)]
8pub struct Error {
9    /// Machine-readable error code.
10    pub code: Code,
11    /// Human-readable error message.
12    pub message: String,
13    /// Optional structured details (e.g., field-level validation errors).
14    pub details: Option<serde_json::Value>,
15    /// Optional trace ID for distributed tracing.
16    pub trace_id: Option<String>,
17    /// Whether the client should retry this request.
18    pub retryable: bool,
19
20    /// HTTP status code.
21    pub status: u16,
22    /// Optional retry-after duration for rate limiting.
23    pub retry_after: Option<Duration>,
24
25    cause_message: Option<String>,
26}
27
28impl Error {
29    /// Creates a new error with the given code, status, and message.
30    pub fn new(code: Code, status: u16, message: impl Into<String>) -> Self {
31        let message = message.into();
32        let message = if message.is_empty() {
33            code.default_message().to_string()
34        } else {
35            message
36        };
37
38        let status = if status == 0 {
39            code.default_status()
40        } else {
41            status
42        };
43
44        Self {
45            code,
46            message,
47            details: None,
48            trace_id: None,
49            retryable: code.is_retryable_default(),
50            status,
51            retry_after: None,
52            cause_message: None,
53        }
54    }
55
56    /// Creates a new error with a formatted message.
57    ///
58    /// This is a semantic alias for `new()` that signals the message
59    /// is typically constructed with `format!()`.
60    ///
61    /// # Example
62    /// ```
63    /// use error_envelope::{Error, Code};
64    /// let user_id = 123;
65    /// let err = Error::newf(Code::NotFound, 404, format!("user {} not found", user_id));
66    /// ```
67    pub fn newf(code: Code, status: u16, message: impl Into<String>) -> Self {
68        Self::new(code, status, message)
69    }
70
71    /// Creates a new error that wraps an underlying cause.
72    pub fn wrap(
73        code: Code,
74        status: u16,
75        message: impl Into<String>,
76        cause: impl std::error::Error,
77    ) -> Self {
78        let mut err = Self::new(code, status, message);
79        err.cause_message = Some(cause.to_string());
80        err
81    }
82
83    /// Adds structured details to the error.
84    pub fn with_details(mut self, details: serde_json::Value) -> Self {
85        self.details = Some(details);
86        self
87    }
88
89    /// Adds a trace ID for distributed tracing.
90    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
91        self.trace_id = Some(trace_id.into());
92        self
93    }
94
95    /// Sets whether the error is retryable.
96    pub fn with_retryable(mut self, retryable: bool) -> Self {
97        self.retryable = retryable;
98        self
99    }
100
101    /// Overrides the HTTP status code.
102    pub fn with_status(mut self, status: u16) -> Self {
103        if status != 0 {
104            self.status = status;
105        }
106        self
107    }
108
109    /// Sets the retry-after duration for rate-limited responses.
110    pub fn with_retry_after(mut self, duration: Duration) -> Self {
111        self.retry_after = Some(duration);
112        self
113    }
114
115    /// Attaches a cause message from an underlying error.
116    ///
117    /// Useful when mapping domain errors (e.g., thiserror) to HTTP errors
118    /// while preserving the underlying error message for debugging.
119    ///
120    /// # Example
121    /// ```
122    /// use error_envelope::{Error, Code};
123    ///
124    /// #[derive(Debug)]
125    /// struct DatabaseError;
126    /// impl std::fmt::Display for DatabaseError {
127    ///     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
128    ///         write!(f, "connection timeout")
129    ///     }
130    /// }
131    /// impl std::error::Error for DatabaseError {}
132    ///
133    /// let db_err = DatabaseError;
134    /// let err = Error::new(Code::Internal, 500, "Database failure")
135    ///     .with_cause_message(db_err);
136    /// ```
137    pub fn with_cause_message(mut self, cause: impl std::error::Error) -> Self {
138        self.cause_message = Some(cause.to_string());
139        self
140    }
141
142    /// Returns the cause message if available.
143    pub fn cause(&self) -> Option<&str> {
144        self.cause_message.as_deref()
145    }
146
147    /// Returns the HTTP status code.
148    pub fn status(&self) -> u16 {
149        self.status
150    }
151}
152
153impl fmt::Display for Error {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        if let Some(ref cause) = self.cause_message {
156            write!(f, "{:?}: {} ({})", self.code, self.message, cause)
157        } else {
158            write!(f, "{:?}: {}", self.code, self.message)
159        }
160    }
161}
162
163impl std::error::Error for Error {
164    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
165        // Since we only store the cause message, we can't return the original error
166        None
167    }
168}
169
170// Custom serialization to include retry_after as human-readable duration
171impl Serialize for Error {
172    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
173    where
174        S: Serializer,
175    {
176        use serde::ser::SerializeStruct;
177
178        // Count actual fields that will be serialized
179        let mut field_count = 3; // code, message, retryable (always present)
180        if self.details.is_some() {
181            field_count += 1;
182        }
183        if self.trace_id.is_some() {
184            field_count += 1;
185        }
186        if self.retry_after.is_some() {
187            field_count += 1;
188        }
189
190        let mut state = serializer.serialize_struct("Error", field_count)?;
191
192        state.serialize_field("code", &self.code)?;
193        state.serialize_field("message", &self.message)?;
194
195        if self.details.is_some() {
196            state.serialize_field("details", &self.details)?;
197        }
198
199        if self.trace_id.is_some() {
200            state.serialize_field("trace_id", &self.trace_id)?;
201        }
202
203        state.serialize_field("retryable", &self.retryable)?;
204
205        if let Some(ref duration) = self.retry_after {
206            let secs = duration.as_secs();
207            let formatted = if secs < 60 {
208                format!("{}s", secs)
209            } else {
210                format!("{}m{}s", secs / 60, secs % 60)
211            };
212            state.serialize_field("retry_after", &formatted)?;
213        }
214
215        state.end()
216    }
217}