Skip to main content

rainier_support/
error.rs

1//! The framework error type.
2//!
3//! Every Rainier component fails with the same [`Error`], and every `Error`
4//! carries the two things the HTTP layer needs to render it without knowing
5//! where it came from: an [`ErrorKind`] that maps to a status code, and an
6//! optional structured `details` payload.
7//!
8//! That is what keeps the crate graph acyclic. `rainier-validation` cannot
9//! add a `Validation` variant to an enum in `rainier-support` (that would be a
10//! cycle), so instead it builds `Error::validation(details)` and the exception
11//! handler in `rainier-http` renders `details` as JSON. The same trick serves
12//! auth, authorization, and model-not-found — no component needs to know the
13//! others' error types.
14
15use std::fmt;
16
17/// The class of failure, which is also the HTTP status it renders as.
18///
19/// Mirrors the exceptions Laravel special-cases in its handler
20/// (`AuthenticationException` → 401, `ValidationException` → 422, …) so the
21/// same "throw a domain error, get the right response" flow works here.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ErrorKind {
24    /// 400 — the request was malformed.
25    BadRequest,
26    /// 401 — no (or invalid) credentials. Raised by guards.
27    Unauthenticated,
28    /// 403 — authenticated but not permitted. Raised by gates/policies.
29    Unauthorized,
30    /// 404 — no such route or model.
31    NotFound,
32    /// 405 — the route exists but not for this method.
33    MethodNotAllowed,
34    /// 409 — the request conflicts with current state.
35    Conflict,
36    /// 413 — the body exceeded the configured limit.
37    PayloadTooLarge,
38    /// 422 — the request was well-formed but failed validation.
39    Validation,
40    /// 429 — throttled.
41    TooManyRequests,
42    /// 500 — an unhandled internal failure.
43    Internal,
44    /// 503 — the app is down for maintenance or a dependency is unavailable.
45    ServiceUnavailable,
46    /// Any other status, set explicitly.
47    Status(u16),
48}
49
50impl ErrorKind {
51    /// The HTTP status this kind renders as.
52    pub fn status(self) -> u16 {
53        match self {
54            ErrorKind::BadRequest => 400,
55            ErrorKind::Unauthenticated => 401,
56            ErrorKind::Unauthorized => 403,
57            ErrorKind::NotFound => 404,
58            ErrorKind::MethodNotAllowed => 405,
59            ErrorKind::Conflict => 409,
60            ErrorKind::PayloadTooLarge => 413,
61            ErrorKind::Validation => 422,
62            ErrorKind::TooManyRequests => 429,
63            ErrorKind::Internal => 500,
64            ErrorKind::ServiceUnavailable => 503,
65            ErrorKind::Status(code) => code,
66        }
67    }
68
69    /// `true` for 5xx — the errors worth logging as faults rather than as
70    /// ordinary client mistakes.
71    pub fn is_server_error(self) -> bool {
72        self.status() >= 500
73    }
74}
75
76/// A framework error: a [`kind`](Error::kind), a human message, an optional
77/// structured `details` payload, and an optional underlying cause.
78#[derive(Debug)]
79pub struct Error {
80    kind: ErrorKind,
81    message: String,
82    details: Option<serde_json::Value>,
83    source: Option<anyhow::Error>,
84}
85
86impl Error {
87    /// Build an error of an explicit kind.
88    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
89        Self { kind, message: message.into(), details: None, source: None }
90    }
91
92    /// 500 — an internal failure.
93    pub fn internal(message: impl Into<String>) -> Self {
94        Self::new(ErrorKind::Internal, message)
95    }
96
97    /// 400 — a malformed request.
98    pub fn bad_request(message: impl Into<String>) -> Self {
99        Self::new(ErrorKind::BadRequest, message)
100    }
101
102    /// 401 — missing or invalid credentials.
103    pub fn unauthenticated(message: impl Into<String>) -> Self {
104        Self::new(ErrorKind::Unauthenticated, message)
105    }
106
107    /// 403 — authenticated but not permitted.
108    pub fn unauthorized(message: impl Into<String>) -> Self {
109        Self::new(ErrorKind::Unauthorized, message)
110    }
111
112    /// 404 — nothing matched.
113    pub fn not_found(message: impl Into<String>) -> Self {
114        Self::new(ErrorKind::NotFound, message)
115    }
116
117    /// 409 — conflicting state.
118    pub fn conflict(message: impl Into<String>) -> Self {
119        Self::new(ErrorKind::Conflict, message)
120    }
121
122    /// 408 — the request took longer than this service is prepared to wait.
123    ///
124    /// What a [`Timeout`](https://docs.rs/rainier-middleware) middleware
125    /// returns. `408` rather than `504`: the timeout is this service's own
126    /// decision about its own handler, not a report about something upstream.
127    pub fn request_timeout(message: impl Into<String>) -> Self {
128        Self::new(ErrorKind::Status(408), message)
129    }
130
131    /// 429 — throttled.
132    ///
133    /// The caller should also send a `retry-after`; nothing here can know what
134    /// to put in it.
135    pub fn too_many_requests(message: impl Into<String>) -> Self {
136        Self::new(ErrorKind::TooManyRequests, message)
137    }
138
139    /// 503 — a dependency is down, or the application is in maintenance.
140    ///
141    /// The honest answer when a health check cannot reach the database. A 500
142    /// says "this service is broken"; a 503 says "try again", which is what a
143    /// load balancer and a retrying client both need to hear.
144    pub fn service_unavailable(message: impl Into<String>) -> Self {
145        Self::new(ErrorKind::ServiceUnavailable, message)
146    }
147
148    /// 422 with a structured field-error payload — what a failed
149    /// [`FormRequest`](https://laravel.com/docs/validation#form-request-validation)
150    /// produces.
151    pub fn validation(details: serde_json::Value) -> Self {
152        Self::new(ErrorKind::Validation, "The given data was invalid.").with_details(details)
153    }
154
155    /// Attach a structured payload rendered alongside the message.
156    pub fn with_details(mut self, details: serde_json::Value) -> Self {
157        self.details = Some(details);
158        self
159    }
160
161    /// Attach the underlying cause.
162    pub fn with_source(mut self, source: impl Into<anyhow::Error>) -> Self {
163        self.source = Some(source.into());
164        self
165    }
166
167    /// Re-label the kind (and therefore the status) without losing the rest.
168    pub fn with_kind(mut self, kind: ErrorKind) -> Self {
169        self.kind = kind;
170        self
171    }
172
173    /// This error's kind.
174    pub fn kind(&self) -> ErrorKind {
175        self.kind
176    }
177
178    /// The HTTP status this error renders as.
179    pub fn status(&self) -> u16 {
180        self.kind.status()
181    }
182
183    /// The human-readable message.
184    pub fn message(&self) -> &str {
185        &self.message
186    }
187
188    /// The structured payload, if any.
189    pub fn details(&self) -> Option<&serde_json::Value> {
190        self.details.as_ref()
191    }
192
193    /// The underlying cause, if one was attached.
194    pub fn source_error(&self) -> Option<&anyhow::Error> {
195        self.source.as_ref()
196    }
197}
198
199impl fmt::Display for Error {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(f, "{}", self.message)?;
202        if let Some(source) = &self.source {
203            write!(f, ": {source}")?;
204        }
205        Ok(())
206    }
207}
208
209impl std::error::Error for Error {
210    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
211        self.source.as_ref().map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
212    }
213}
214
215// `anyhow::Error` is what rainier_orm fails with, so this conversion is the
216// seam between the ORM's errors and the framework's. A database failure is
217// internal until something upstream re-labels it (e.g. a repository turning a
218// missing row into `not_found`).
219impl From<anyhow::Error> for Error {
220    fn from(source: anyhow::Error) -> Self {
221        Self::internal(source.to_string()).with_source(source)
222    }
223}
224
225impl From<std::io::Error> for Error {
226    fn from(source: std::io::Error) -> Self {
227        let kind = match source.kind() {
228            std::io::ErrorKind::NotFound => ErrorKind::NotFound,
229            std::io::ErrorKind::PermissionDenied => ErrorKind::Unauthorized,
230            _ => ErrorKind::Internal,
231        };
232        Self::new(kind, source.to_string()).with_source(anyhow::Error::new(source))
233    }
234}
235
236impl From<serde_json::Error> for Error {
237    fn from(source: serde_json::Error) -> Self {
238        Self::bad_request(format!("malformed JSON: {source}"))
239            .with_source(anyhow::Error::new(source))
240    }
241}
242
243impl From<std::fmt::Error> for Error {
244    fn from(source: std::fmt::Error) -> Self {
245        Self::internal(source.to_string())
246    }
247}
248
249/// The framework result type.
250pub type Result<T, E = Error> = std::result::Result<T, E>;
251
252/// Adds `.context(..)` to any `Result` whose error converts into [`Error`],
253/// the way `anyhow::Context` does — but preserving the [`ErrorKind`] when the
254/// error already is a Rainier error, so adding context to a 404 does not
255/// silently turn it into a 500.
256pub trait Context<T> {
257    /// Prefix the error message with `context`.
258    fn context(self, context: impl fmt::Display) -> Result<T>;
259
260    /// Prefix the error message with a lazily built `context`.
261    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T>;
262}
263
264impl<T, E> Context<T> for Result<T, E>
265where
266    E: Into<Error>,
267{
268    fn context(self, context: impl fmt::Display) -> Result<T> {
269        self.map_err(|e| {
270            let e = e.into();
271            let kind = e.kind();
272            Error::new(kind, format!("{context}: {e}"))
273        })
274    }
275
276    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T> {
277        self.map_err(|e| {
278            let e = e.into();
279            let kind = e.kind();
280            Error::new(kind, format!("{}: {e}", context()))
281        })
282    }
283}
284
285impl<T> Context<T> for Option<T> {
286    fn context(self, context: impl fmt::Display) -> Result<T> {
287        self.ok_or_else(|| Error::internal(context.to_string()))
288    }
289
290    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T> {
291        self.ok_or_else(|| Error::internal(context().to_string()))
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn kinds_map_to_statuses() {
301        assert_eq!(ErrorKind::Validation.status(), 422);
302        assert_eq!(ErrorKind::Unauthenticated.status(), 401);
303        assert_eq!(ErrorKind::Status(418).status(), 418);
304        assert!(ErrorKind::Internal.is_server_error());
305        assert!(!ErrorKind::NotFound.is_server_error());
306    }
307
308    #[test]
309    fn context_preserves_the_kind() {
310        let err: Result<()> = Err(Error::not_found("no such post"));
311        let err = err.context("loading the post").unwrap_err();
312        assert_eq!(err.kind(), ErrorKind::NotFound);
313        assert_eq!(err.message(), "loading the post: no such post");
314    }
315
316    #[test]
317    fn validation_errors_carry_their_details() {
318        let err = Error::validation(serde_json::json!({ "email": ["required"] }));
319        assert_eq!(err.status(), 422);
320        assert!(err.details().is_some());
321    }
322
323    #[test]
324    fn anyhow_errors_arrive_as_internal() {
325        let err: Error = anyhow::anyhow!("connection reset").into();
326        assert_eq!(err.status(), 500);
327        assert!(err.source_error().is_some());
328    }
329}