Skip to main content

kerniq_core/
error.rs

1//! The Kerniq error model.
2//!
3//! Every error produced by the framework — malformed input, missing routes,
4//! timeouts, authentication failures — and every error returned by an
5//! application handler is rendered with the same JSON envelope:
6//!
7//! ```json
8//! {
9//!   "error": {
10//!     "code": "USER_NOT_FOUND",
11//!     "message": "The requested user does not exist"
12//!   }
13//! }
14//! ```
15//!
16//! An optional `details` field carries structured data such as field-level
17//! validation failures.
18//!
19//! # Public messages versus diagnostic sources
20//!
21//! An [`Error`] has two kinds of information:
22//!
23//! * a **public** status, code, message and optional details, which are sent
24//!   to the client, and
25//! * an optional **source** error, which is *never* sent to the client. It is
26//!   recorded through `tracing` when the response is produced (at `ERROR`
27//!   level for 5xx responses, `DEBUG` otherwise).
28//!
29//! [`Error::internal`] uses a generic public message, so database errors,
30//! file paths and similar implementation details cannot leak to clients.
31//!
32//! # Domain errors
33//!
34//! Application error types convert into [`Error`] with an ordinary `From`
35//! implementation, which makes `?` work in handlers:
36//!
37//! ```
38//! use kerniq_core::Error;
39//!
40//! #[derive(Debug, thiserror::Error)]
41//! enum AccountError {
42//!     #[error("account {0} not found")]
43//!     NotFound(u64),
44//!     #[error("insufficient funds")]
45//!     InsufficientFunds,
46//! }
47//!
48//! impl From<AccountError> for Error {
49//!     fn from(err: AccountError) -> Self {
50//!         match err {
51//!             AccountError::NotFound(_) => Error::not_found("The requested account does not exist")
52//!                 .with_code("ACCOUNT_NOT_FOUND"),
53//!             AccountError::InsufficientFunds => {
54//!                 Error::unprocessable("The account balance is too low").with_code("INSUFFICIENT_FUNDS")
55//!             }
56//!         }
57//!     }
58//! }
59//! ```
60//!
61//! Kerniq deliberately does **not** provide a blanket `From<E: std::error::Error>`
62//! conversion: it would make the implementation above impossible (it would
63//! overlap), and it would silently turn every domain error into a 500.
64//! Use [`ResultExt::or_internal`] when an unexpected failure should become a
65//! 500 response.
66
67use std::borrow::Cow;
68use std::fmt;
69
70use axum_core::response::{IntoResponse, Response};
71use http::StatusCode;
72use http::header::{self, HeaderName, HeaderValue};
73use serde::Serialize;
74
75/// A type-erased, thread-safe error used as an [`Error`] source.
76pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
77
78/// A `Result` whose error type defaults to the Kerniq [`Error`].
79pub type Result<T, E = Error> = std::result::Result<T, E>;
80
81/// The public message used for errors whose details must not reach clients.
82pub const INTERNAL_ERROR_MESSAGE: &str = "An internal server error occurred";
83
84/// The framework error type.
85///
86/// `Error` is a single pointer wide, so `Result<T, Error>` stays small on the
87/// success path. It implements [`IntoResponse`], so handlers can return
88/// `Result<T>` directly.
89///
90/// See the [module documentation](self) for the response format and the
91/// exposure policy.
92pub struct Error {
93    inner: Box<Inner>,
94}
95
96struct Inner {
97    status: StatusCode,
98    code: Cow<'static, str>,
99    message: Cow<'static, str>,
100    details: Option<serde_json::Value>,
101    headers: Vec<(HeaderName, HeaderValue)>,
102    source: Option<BoxError>,
103}
104
105macro_rules! constructors {
106    ($( $(#[$doc:meta])* $name:ident => $status:ident, $code:literal; )*) => {
107        $(
108            $(#[$doc])*
109            pub fn $name(message: impl Into<Cow<'static, str>>) -> Self {
110                Self::new(StatusCode::$status, $code, message)
111            }
112        )*
113    };
114}
115
116impl Error {
117    /// Creates an error with an explicit status, machine-readable code and
118    /// public message.
119    pub fn new(
120        status: StatusCode,
121        code: impl Into<Cow<'static, str>>,
122        message: impl Into<Cow<'static, str>>,
123    ) -> Self {
124        Self {
125            inner: Box::new(Inner {
126                status,
127                code: code.into(),
128                message: message.into(),
129                details: None,
130                headers: Vec::new(),
131                source: None,
132            }),
133        }
134    }
135
136    constructors! {
137        /// `400 Bad Request` with code `BAD_REQUEST`.
138        bad_request => BAD_REQUEST, "BAD_REQUEST";
139        /// `401 Unauthorized` with code `UNAUTHORIZED`.
140        unauthorized => UNAUTHORIZED, "UNAUTHORIZED";
141        /// `403 Forbidden` with code `FORBIDDEN`.
142        forbidden => FORBIDDEN, "FORBIDDEN";
143        /// `404 Not Found` with code `NOT_FOUND`.
144        not_found => NOT_FOUND, "NOT_FOUND";
145        /// `405 Method Not Allowed` with code `METHOD_NOT_ALLOWED`.
146        method_not_allowed => METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED";
147        /// `408 Request Timeout` with code `REQUEST_TIMEOUT`.
148        timeout => REQUEST_TIMEOUT, "REQUEST_TIMEOUT";
149        /// `409 Conflict` with code `CONFLICT`.
150        conflict => CONFLICT, "CONFLICT";
151        /// `413 Payload Too Large` with code `PAYLOAD_TOO_LARGE`.
152        payload_too_large => PAYLOAD_TOO_LARGE, "PAYLOAD_TOO_LARGE";
153        /// `415 Unsupported Media Type` with code `UNSUPPORTED_MEDIA_TYPE`.
154        unsupported_media_type => UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE";
155        /// `422 Unprocessable Entity` with code `UNPROCESSABLE_ENTITY`.
156        unprocessable => UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY";
157        /// `429 Too Many Requests` with code `TOO_MANY_REQUESTS`.
158        too_many_requests => TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS";
159        /// `503 Service Unavailable` with code `SERVICE_UNAVAILABLE`.
160        service_unavailable => SERVICE_UNAVAILABLE, "SERVICE_UNAVAILABLE";
161    }
162
163    /// `422 Unprocessable Entity` with code `VALIDATION_FAILED` and the given
164    /// structured details (typically a map of field names to messages).
165    pub fn validation(details: impl Serialize) -> Self {
166        Self::new(
167            StatusCode::UNPROCESSABLE_ENTITY,
168            "VALIDATION_FAILED",
169            "The request failed validation",
170        )
171        .with_details(details)
172    }
173
174    /// `500 Internal Server Error` with a generic public message.
175    ///
176    /// The `source` is logged when the response is produced but is never sent
177    /// to the client.
178    pub fn internal(source: impl Into<BoxError>) -> Self {
179        Self::new(
180            StatusCode::INTERNAL_SERVER_ERROR,
181            "INTERNAL_ERROR",
182            INTERNAL_ERROR_MESSAGE,
183        )
184        .with_source(source)
185    }
186
187    /// Replaces the machine-readable error code.
188    #[must_use]
189    pub fn with_code(mut self, code: impl Into<Cow<'static, str>>) -> Self {
190        self.inner.code = code.into();
191        self
192    }
193
194    /// Replaces the public message.
195    #[must_use]
196    pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
197        self.inner.message = message.into();
198        self
199    }
200
201    /// Attaches structured, client-visible details.
202    ///
203    /// If `details` fails to serialize, the error is kept and the details are
204    /// omitted (the failure is logged).
205    #[must_use]
206    pub fn with_details(mut self, details: impl Serialize) -> Self {
207        match serde_json::to_value(details) {
208            Ok(value) => self.inner.details = Some(value),
209            Err(err) => tracing::warn!(error = %err, "failed to serialize error details"),
210        }
211        self
212    }
213
214    /// Attaches a diagnostic source error. It is logged, never sent to clients.
215    #[must_use]
216    pub fn with_source(mut self, source: impl Into<BoxError>) -> Self {
217        self.inner.source = Some(source.into());
218        self
219    }
220
221    /// Adds a response header (for example `Retry-After` or `WWW-Authenticate`).
222    #[must_use]
223    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
224        self.inner.headers.push((name, value));
225        self
226    }
227
228    /// The HTTP status code.
229    pub fn status(&self) -> StatusCode {
230        self.inner.status
231    }
232
233    /// The machine-readable error code.
234    pub fn code(&self) -> &str {
235        &self.inner.code
236    }
237
238    /// The public, client-visible message.
239    pub fn message(&self) -> &str {
240        &self.inner.message
241    }
242
243    /// Client-visible structured details, if any.
244    pub fn details(&self) -> Option<&serde_json::Value> {
245        self.inner.details.as_ref()
246    }
247
248    /// Extra response headers attached to this error.
249    pub fn headers(&self) -> &[(HeaderName, HeaderValue)] {
250        &self.inner.headers
251    }
252
253    /// The diagnostic source, if any.
254    pub fn source_ref(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
255        self.inner.source.as_deref()
256    }
257
258    /// Serializes the client-visible JSON body.
259    pub fn to_json_body(&self) -> Vec<u8> {
260        #[derive(Serialize)]
261        struct Envelope<'a> {
262            error: Body<'a>,
263        }
264        #[derive(Serialize)]
265        struct Body<'a> {
266            code: &'a str,
267            message: &'a str,
268            #[serde(skip_serializing_if = "Option::is_none")]
269            details: Option<&'a serde_json::Value>,
270        }
271        let envelope = Envelope {
272            error: Body {
273                code: &self.inner.code,
274                message: &self.inner.message,
275                details: self.inner.details.as_ref(),
276            },
277        };
278        // Serializing strings and an existing `Value` cannot fail.
279        serde_json::to_vec(&envelope).unwrap_or_default()
280    }
281
282    fn log(&self) {
283        let status = self.inner.status.as_u16();
284        let code: &str = &self.inner.code;
285        match (&self.inner.source, self.inner.status.is_server_error()) {
286            (Some(source), true) => {
287                tracing::error!(status, code, error = %ErrorChain(source.as_ref()), "request failed")
288            }
289            (None, true) => {
290                tracing::error!(status, code, message = %self.inner.message, "request failed")
291            }
292            (Some(source), false) => {
293                tracing::debug!(status, code, error = %ErrorChain(source.as_ref()), "request rejected")
294            }
295            (None, false) => tracing::debug!(status, code, "request rejected"),
296        }
297    }
298}
299
300impl IntoResponse for Error {
301    fn into_response(self) -> Response {
302        self.log();
303        let body = self.to_json_body();
304        let Inner {
305            status, headers, ..
306        } = *self.inner;
307        let mut response = (status, body).into_response();
308        let map = response.headers_mut();
309        map.insert(
310            header::CONTENT_TYPE,
311            HeaderValue::from_static("application/json"),
312        );
313        for (name, value) in headers {
314            map.append(name, value);
315        }
316        response
317    }
318}
319
320impl fmt::Display for Error {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(
323            f,
324            "{} {}: {}",
325            self.inner.status.as_u16(),
326            self.inner.code,
327            self.inner.message
328        )?;
329        if let Some(source) = &self.inner.source {
330            write!(f, ": {}", ErrorChain(source.as_ref()))?;
331        }
332        Ok(())
333    }
334}
335
336impl fmt::Debug for Error {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        // `Debug` is what `fn main() -> Result<()>` prints, so keep it readable.
339        if f.alternate() {
340            f.debug_struct("Error")
341                .field("status", &self.inner.status)
342                .field("code", &self.inner.code)
343                .field("message", &self.inner.message)
344                .field("details", &self.inner.details)
345                .field("source", &self.inner.source)
346                .finish()
347        } else {
348            fmt::Display::fmt(self, f)
349        }
350    }
351}
352
353impl std::error::Error for Error {
354    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
355        self.inner
356            .source
357            .as_ref()
358            .map(|s| s.as_ref() as &(dyn std::error::Error + 'static))
359    }
360}
361
362impl From<StatusCode> for Error {
363    /// Creates an error from a bare status code, using the canonical reason
364    /// phrase as the message and an upper-snake-case code.
365    fn from(status: StatusCode) -> Self {
366        let reason = status.canonical_reason().unwrap_or("Error");
367        let code = reason.to_ascii_uppercase().replace([' ', '-'], "_");
368        Self::new(status, code, reason)
369    }
370}
371
372impl From<std::io::Error> for Error {
373    fn from(err: std::io::Error) -> Self {
374        Self::internal(err)
375    }
376}
377
378impl From<serde_json::Error> for Error {
379    fn from(err: serde_json::Error) -> Self {
380        Self::internal(err)
381    }
382}
383
384impl From<http::Error> for Error {
385    fn from(err: http::Error) -> Self {
386        Self::internal(err)
387    }
388}
389
390impl From<crate::ServerError> for Error {
391    fn from(err: crate::ServerError) -> Self {
392        Self::new(
393            StatusCode::INTERNAL_SERVER_ERROR,
394            "SERVER_ERROR",
395            INTERNAL_ERROR_MESSAGE,
396        )
397        .with_source(err)
398    }
399}
400
401impl From<crate::ConfigError> for Error {
402    fn from(err: crate::ConfigError) -> Self {
403        Self::new(
404            StatusCode::INTERNAL_SERVER_ERROR,
405            "CONFIG_ERROR",
406            INTERNAL_ERROR_MESSAGE,
407        )
408        .with_source(err)
409    }
410}
411
412/// Formats an error and its `source()` chain on one line.
413struct ErrorChain<'a>(&'a (dyn std::error::Error + 'static));
414
415impl fmt::Display for ErrorChain<'_> {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        write!(f, "{}", self.0)?;
418        let mut current = self.0.source();
419        while let Some(source) = current {
420            write!(f, ": {source}")?;
421            current = source.source();
422        }
423        Ok(())
424    }
425}
426
427/// Conversions from arbitrary `Result`s into Kerniq errors.
428pub trait ResultExt<T> {
429    /// Maps any error into a `500 Internal Server Error`, keeping it as the
430    /// (logged, never exposed) source.
431    fn or_internal(self) -> Result<T>;
432
433    /// Replaces any error with `error`, keeping the original as its source.
434    fn or_error(self, error: Error) -> Result<T>;
435}
436
437impl<T, E> ResultExt<T> for std::result::Result<T, E>
438where
439    E: Into<BoxError>,
440{
441    fn or_internal(self) -> Result<T> {
442        self.map_err(Error::internal)
443    }
444
445    fn or_error(self, error: Error) -> Result<T> {
446        self.map_err(|source| error.with_source(source))
447    }
448}
449
450/// Conversions from `Option`s into Kerniq errors.
451pub trait OptionExt<T> {
452    /// Maps `None` to a `404 Not Found` with the given public message.
453    fn or_not_found(self, message: impl Into<Cow<'static, str>>) -> Result<T>;
454
455    /// Maps `None` to the given error.
456    fn or_error(self, error: Error) -> Result<T>;
457}
458
459impl<T> OptionExt<T> for Option<T> {
460    fn or_not_found(self, message: impl Into<Cow<'static, str>>) -> Result<T> {
461        self.ok_or_else(|| Error::not_found(message))
462    }
463
464    fn or_error(self, error: Error) -> Result<T> {
465        self.ok_or(error)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use http_body_util::BodyExt;
473
474    async fn body_json(response: Response) -> serde_json::Value {
475        let bytes = response.into_body().collect().await.unwrap().to_bytes();
476        serde_json::from_slice(&bytes).unwrap()
477    }
478
479    #[test]
480    fn error_is_pointer_sized() {
481        assert_eq!(std::mem::size_of::<Error>(), std::mem::size_of::<usize>());
482    }
483
484    #[tokio::test]
485    async fn renders_standard_envelope() {
486        let err = Error::not_found("The requested user does not exist").with_code("USER_NOT_FOUND");
487        let response = err.into_response();
488        assert_eq!(response.status(), StatusCode::NOT_FOUND);
489        assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json");
490        let body = body_json(response).await;
491        assert_eq!(
492            body,
493            serde_json::json!({"error": {"code": "USER_NOT_FOUND", "message": "The requested user does not exist"}})
494        );
495    }
496
497    #[tokio::test]
498    async fn internal_errors_never_expose_their_source() {
499        let source = std::io::Error::other("connection to db.internal:5432 refused");
500        let response = Error::internal(source).into_response();
501        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
502        let text = body_json(response).await.to_string();
503        assert!(!text.contains("db.internal"));
504        assert!(text.contains(INTERNAL_ERROR_MESSAGE));
505    }
506
507    #[tokio::test]
508    async fn validation_details_and_headers() {
509        let response = Error::validation(serde_json::json!({"email": ["invalid"]}))
510            .with_header(header::RETRY_AFTER, HeaderValue::from_static("1"))
511            .into_response();
512        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
513        assert_eq!(response.headers()[header::RETRY_AFTER], "1");
514        let body = body_json(response).await;
515        assert_eq!(body["error"]["code"], "VALIDATION_FAILED");
516        assert_eq!(body["error"]["details"]["email"][0], "invalid");
517    }
518
519    #[test]
520    fn from_status_code() {
521        let err = Error::from(StatusCode::IM_A_TEAPOT);
522        assert_eq!(err.code(), "I'M_A_TEAPOT");
523        assert_eq!(err.status(), StatusCode::IM_A_TEAPOT);
524        let err = Error::from(StatusCode::GATEWAY_TIMEOUT);
525        assert_eq!(err.code(), "GATEWAY_TIMEOUT");
526    }
527
528    #[test]
529    fn result_and_option_extensions() {
530        let r: std::result::Result<(), std::io::Error> = Err(std::io::Error::other("boom"));
531        let err = r.or_internal().unwrap_err();
532        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
533        assert!(err.to_string().contains("boom"));
534
535        let err = None::<u8>.or_not_found("missing").unwrap_err();
536        assert_eq!(err.status(), StatusCode::NOT_FOUND);
537        assert_eq!(err.message(), "missing");
538    }
539
540    #[test]
541    fn domain_error_conversion_with_question_mark() {
542        #[derive(Debug)]
543        struct Missing;
544        impl From<Missing> for Error {
545            fn from(_: Missing) -> Self {
546                Error::not_found("gone").with_code("GONE")
547            }
548        }
549        fn handler() -> Result<()> {
550            Err(Missing)?;
551            Ok(())
552        }
553        assert_eq!(handler().unwrap_err().code(), "GONE");
554    }
555}