Skip to main content

jerrycan_core/
error.rs

1//! jerrycan's single error type. Every error carries a stable `code` (JC####)
2//! that maps to a documentation anchor — the error-driven-docs contract (spec §8).
3
4use http::StatusCode;
5use std::fmt;
6
7/// Convenience alias used across jerrycan and generated apps.
8pub type Result<T, E = Error> = std::result::Result<T, E>;
9
10/// The one error type of the framework (spec §4.1 "Errors").
11///
12/// Production responses render only `code` + `message` as JSON; internals
13/// (sources, backtraces) are for logs — enforced in Phase 1's observe layer.
14#[derive(Debug)]
15pub struct Error {
16    status: StatusCode,
17    code: &'static str,
18    message: String,
19    details: Option<serde_json::Value>,
20}
21
22impl Error {
23    /// Build an error with an explicit status and stable code.
24    pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
25        Self {
26            status,
27            code,
28            message: message.into(),
29            details: None,
30        }
31    }
32
33    pub fn bad_request(message: impl Into<String>) -> Self {
34        Self::new(StatusCode::BAD_REQUEST, "JC0400", message)
35    }
36    pub fn not_found() -> Self {
37        Self::new(StatusCode::NOT_FOUND, "JC0404", "not found")
38    }
39    pub fn method_not_allowed() -> Self {
40        Self::new(
41            StatusCode::METHOD_NOT_ALLOWED,
42            "JC0405",
43            "method not allowed",
44        )
45    }
46    /// The write conflicts with existing state (e.g. a unique key already taken).
47    pub fn conflict(message: impl Into<String>) -> Self {
48        Self::new(StatusCode::CONFLICT, "JC0409", message)
49    }
50    pub fn payload_too_large() -> Self {
51        Self::new(StatusCode::PAYLOAD_TOO_LARGE, "JC0413", "payload too large")
52    }
53    /// The request's content type is not what this endpoint consumes (e.g. a
54    /// `Multipart` extractor on a non-`multipart/form-data` request).
55    pub fn unsupported_media_type() -> Self {
56        Self::new(
57            StatusCode::UNSUPPORTED_MEDIA_TYPE,
58            "JC0415",
59            "unsupported media type",
60        )
61    }
62    pub fn unprocessable(message: impl Into<String>) -> Self {
63        Self::new(StatusCode::UNPROCESSABLE_ENTITY, "JC0422", message)
64    }
65    /// The client exceeded its configured rate limit for the current window
66    /// (the rate-limit extension; spec §v2.2). The response also carries a
67    /// `Retry-After` header, which the middleware sets — `Error` has no header
68    /// channel.
69    pub fn too_many_requests() -> Self {
70        Self::new(
71            StatusCode::TOO_MANY_REQUESTS,
72            "JC0429",
73            "rate limit exceeded",
74        )
75    }
76    /// A background job exhausted its retries and was dead-lettered, or failed
77    /// irrecoverably (the jobs engine; spec §v2.3). Surfaced in operator logs and
78    /// the dead-letter table, not to an HTTP client.
79    pub fn job_failed(message: impl Into<String>) -> Self {
80        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "JC0521", message)
81    }
82    /// Authentication is required or failed (spec §4.4 auth).
83    pub fn unauthorized() -> Self {
84        Self::new(
85            StatusCode::UNAUTHORIZED,
86            "JC0401",
87            "authentication required",
88        )
89    }
90    /// Authenticated but not permitted.
91    pub fn forbidden() -> Self {
92        Self::new(StatusCode::FORBIDDEN, "JC0403", "forbidden")
93    }
94    /// The handler exceeded the configured time budget (spec §4.4 timeouts).
95    pub fn handler_timeout() -> Self {
96        Self::new(
97            StatusCode::SERVICE_UNAVAILABLE,
98            "JC0503",
99            "handler timed out",
100        )
101    }
102    pub fn internal(message: impl Into<String>) -> Self {
103        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "JC0500", message)
104    }
105    /// A handler or dependency asked for a type no provider supplies (spec §4.3).
106    pub fn missing_dependency(type_name: &str) -> Self {
107        Self::new(
108            StatusCode::INTERNAL_SERVER_ERROR,
109            "JC1001",
110            format!("no provider registered for dependency `{type_name}`"),
111        )
112    }
113
114    /// Dependency factories recursed past the depth limit (cycle, or absurd chain).
115    pub fn dependency_cycle() -> Self {
116        Self::new(
117            StatusCode::INTERNAL_SERVER_ERROR,
118            "JC1002",
119            "dependency cycle or chain too deep",
120        )
121    }
122
123    /// An HTTP extractor ran inside a task context (no request to read from).
124    pub fn task_context() -> Self {
125        Self::new(
126            StatusCode::INTERNAL_SERVER_ERROR,
127            "JC1003",
128            "dependency requires an HTTP request",
129        )
130    }
131
132    /// Attach machine-readable detail (e.g. validation violations). Rendered
133    /// as a `details` key in the response body; absent otherwise.
134    pub fn with_details(mut self, details: serde_json::Value) -> Self {
135        self.details = Some(details);
136        self
137    }
138
139    pub fn details(&self) -> Option<&serde_json::Value> {
140        self.details.as_ref()
141    }
142
143    pub fn status(&self) -> StatusCode {
144        self.status
145    }
146    pub fn code(&self) -> &'static str {
147        self.code
148    }
149    pub fn message(&self) -> &str {
150        &self.message
151    }
152}
153
154impl fmt::Display for Error {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{}: {}", self.code, self.message)
157    }
158}
159
160impl std::error::Error for Error {}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn errors_carry_status_and_stable_code() {
168        assert_eq!(Error::not_found().status(), StatusCode::NOT_FOUND);
169        assert_eq!(Error::not_found().code(), "JC0404");
170        assert_eq!(Error::method_not_allowed().code(), "JC0405");
171        assert_eq!(Error::bad_request("nope").status(), StatusCode::BAD_REQUEST);
172        assert_eq!(Error::payload_too_large().code(), "JC0413");
173        assert_eq!(Error::unsupported_media_type().code(), "JC0415");
174        assert_eq!(Error::unsupported_media_type().status().as_u16(), 415);
175        assert_eq!(Error::unprocessable("bad field").code(), "JC0422");
176        assert_eq!(Error::too_many_requests().code(), "JC0429");
177        assert_eq!(Error::too_many_requests().status().as_u16(), 429);
178        assert_eq!(Error::job_failed("boom").code(), "JC0521");
179        assert_eq!(Error::job_failed("boom").status().as_u16(), 500);
180        assert_eq!(
181            Error::internal("boom").status(),
182            StatusCode::INTERNAL_SERVER_ERROR
183        );
184        let e = Error::missing_dependency("app::Db");
185        assert_eq!(e.code(), "JC1001");
186        assert_eq!(e.status(), StatusCode::INTERNAL_SERVER_ERROR);
187        assert!(e.message().contains("app::Db"));
188        assert_eq!(Error::dependency_cycle().code(), "JC1002");
189        assert_eq!(Error::handler_timeout().code(), "JC0503");
190        assert_eq!(
191            Error::handler_timeout().status(),
192            StatusCode::SERVICE_UNAVAILABLE
193        );
194        assert_eq!(Error::unauthorized().code(), "JC0401");
195        assert_eq!(Error::unauthorized().status(), StatusCode::UNAUTHORIZED);
196        assert_eq!(Error::forbidden().code(), "JC0403");
197        assert_eq!(Error::forbidden().status(), StatusCode::FORBIDDEN);
198    }
199
200    #[test]
201    fn details_attach_and_default_to_none() {
202        let plain = Error::unprocessable("validation failed");
203        assert!(plain.details().is_none());
204        let detailed = Error::unprocessable("validation failed").with_details(
205            serde_json::json!([{ "field": "title", "message": "must not be empty" }]),
206        );
207        assert!(detailed.details().unwrap().is_array());
208    }
209
210    #[test]
211    fn display_includes_code_and_message() {
212        let e = Error::bad_request("missing body");
213        assert_eq!(format!("{e}"), "JC0400: missing body");
214    }
215}