Skip to main content

rskit_errors/
code.rs

1/// Machine-readable error code.
2///
3/// Defined as an enum so downstream code gets exhaustive match checking.
4/// `#[non_exhaustive]` allows new variants to be added without a SemVer break.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
6#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
7#[non_exhaustive]
8pub enum ErrorCode {
9    // ── Connection / availability (all retryable) ──────────────────────
10    /// The downstream service is unavailable; retryable.
11    ServiceUnavailable,
12    /// Could not establish a connection to a dependency; retryable.
13    ConnectionFailed,
14    /// An operation exceeded its deadline; retryable.
15    Timeout,
16    /// Request was rejected because a rate limit was exceeded; retryable.
17    RateLimited,
18
19    // ── Resource ───────────────────────────────────────────────────────
20    /// The requested resource does not exist.
21    NotFound,
22    /// A resource with the same identity already exists.
23    AlreadyExists,
24    /// The request conflicts with the current state of a resource.
25    Conflict,
26
27    // ── Validation ─────────────────────────────────────────────────────
28    /// One or more input fields failed validation.
29    InvalidInput,
30    /// A required field was absent from the request.
31    MissingField,
32    /// A field value could not be parsed into the expected format.
33    InvalidFormat,
34
35    // ── Auth ───────────────────────────────────────────────────────────
36    /// The caller has not been authenticated.
37    Unauthorized,
38    /// The caller is authenticated but does not have permission.
39    Forbidden,
40    /// The authentication token has expired.
41    TokenExpired,
42    /// The authentication token is malformed or unrecognised.
43    InvalidToken,
44
45    // ── Internal ───────────────────────────────────────────────────────
46    /// An unexpected internal error occurred.
47    #[serde(rename = "INTERNAL_ERROR")]
48    Internal,
49    /// A database operation failed.
50    DatabaseError,
51    /// An external service returned an error; retryable.
52    #[serde(rename = "EXTERNAL_SERVICE_ERROR")]
53    ExternalService,
54
55    // ── Lifecycle ─────────────────────────────────────────────────────
56    /// The operation was cancelled by the caller
57    /// or system before completion (e.g., context cancellation, client disconnect).
58    Cancelled,
59}
60
61impl ErrorCode {
62    /// Returns `true` for transient errors worth retrying.
63    pub fn is_retryable(self) -> bool {
64        matches!(
65            self,
66            ErrorCode::ServiceUnavailable
67                | ErrorCode::ConnectionFailed
68                | ErrorCode::Timeout
69                | ErrorCode::RateLimited
70                | ErrorCode::ExternalService
71        )
72    }
73
74    /// Canonical HTTP status code for this error.
75    pub fn http_status(self) -> http::StatusCode {
76        match self {
77            ErrorCode::ServiceUnavailable => http::StatusCode::SERVICE_UNAVAILABLE,
78            ErrorCode::ConnectionFailed => http::StatusCode::BAD_GATEWAY,
79            ErrorCode::Timeout => http::StatusCode::GATEWAY_TIMEOUT,
80            ErrorCode::RateLimited => http::StatusCode::TOO_MANY_REQUESTS,
81            ErrorCode::NotFound => http::StatusCode::NOT_FOUND,
82            ErrorCode::AlreadyExists => http::StatusCode::CONFLICT,
83            ErrorCode::Conflict => http::StatusCode::CONFLICT,
84            ErrorCode::InvalidInput | ErrorCode::MissingField | ErrorCode::InvalidFormat => {
85                http::StatusCode::UNPROCESSABLE_ENTITY
86            }
87            ErrorCode::Unauthorized => http::StatusCode::UNAUTHORIZED,
88            ErrorCode::Forbidden => http::StatusCode::FORBIDDEN,
89            ErrorCode::TokenExpired | ErrorCode::InvalidToken => http::StatusCode::UNAUTHORIZED,
90            ErrorCode::Internal | ErrorCode::DatabaseError => {
91                http::StatusCode::INTERNAL_SERVER_ERROR
92            }
93            ErrorCode::ExternalService => http::StatusCode::BAD_GATEWAY,
94            // Use standard 408 instead of non-standard 499 so HTTP mappings are portable.
95            ErrorCode::Cancelled => http::StatusCode::REQUEST_TIMEOUT,
96            #[allow(unreachable_patterns)]
97            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
98        }
99    }
100
101    /// Stable string representation (for logging / serialisation).
102    pub fn as_str(self) -> &'static str {
103        match self {
104            ErrorCode::ServiceUnavailable => "SERVICE_UNAVAILABLE",
105            ErrorCode::ConnectionFailed => "CONNECTION_FAILED",
106            ErrorCode::Timeout => "TIMEOUT",
107            ErrorCode::RateLimited => "RATE_LIMITED",
108            ErrorCode::NotFound => "NOT_FOUND",
109            ErrorCode::AlreadyExists => "ALREADY_EXISTS",
110            ErrorCode::Conflict => "CONFLICT",
111            ErrorCode::InvalidInput => "INVALID_INPUT",
112            ErrorCode::MissingField => "MISSING_FIELD",
113            ErrorCode::InvalidFormat => "INVALID_FORMAT",
114            ErrorCode::Unauthorized => "UNAUTHORIZED",
115            ErrorCode::Forbidden => "FORBIDDEN",
116            ErrorCode::TokenExpired => "TOKEN_EXPIRED",
117            ErrorCode::InvalidToken => "INVALID_TOKEN",
118            ErrorCode::Internal => "INTERNAL_ERROR",
119            ErrorCode::DatabaseError => "DATABASE_ERROR",
120            ErrorCode::ExternalService => "EXTERNAL_SERVICE_ERROR",
121            ErrorCode::Cancelled => "CANCELLED",
122            #[allow(unreachable_patterns)]
123            _ => "UNKNOWN",
124        }
125    }
126
127    /// Parse the stable string produced by [`as_str`](Self::as_str) back into a code —
128    /// the inverse of `as_str`.
129    ///
130    /// Returns `None` for an unrecognised string (for example a code introduced by a newer peer over a versioned wire),
131    /// letting callers choose a typed fallback rather than guessing.
132    /// The mapping is kept in lock-step with `as_str` by a parity test.
133    #[must_use]
134    pub fn from_wire(value: &str) -> Option<Self> {
135        Some(match value {
136            "SERVICE_UNAVAILABLE" => ErrorCode::ServiceUnavailable,
137            "CONNECTION_FAILED" => ErrorCode::ConnectionFailed,
138            "TIMEOUT" => ErrorCode::Timeout,
139            "RATE_LIMITED" => ErrorCode::RateLimited,
140            "NOT_FOUND" => ErrorCode::NotFound,
141            "ALREADY_EXISTS" => ErrorCode::AlreadyExists,
142            "CONFLICT" => ErrorCode::Conflict,
143            "INVALID_INPUT" => ErrorCode::InvalidInput,
144            "MISSING_FIELD" => ErrorCode::MissingField,
145            "INVALID_FORMAT" => ErrorCode::InvalidFormat,
146            "UNAUTHORIZED" => ErrorCode::Unauthorized,
147            "FORBIDDEN" => ErrorCode::Forbidden,
148            "TOKEN_EXPIRED" => ErrorCode::TokenExpired,
149            "INVALID_TOKEN" => ErrorCode::InvalidToken,
150            "INTERNAL_ERROR" => ErrorCode::Internal,
151            "DATABASE_ERROR" => ErrorCode::DatabaseError,
152            "EXTERNAL_SERVICE_ERROR" => ErrorCode::ExternalService,
153            "CANCELLED" => ErrorCode::Cancelled,
154            _ => return None,
155        })
156    }
157}
158
159impl std::fmt::Display for ErrorCode {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.write_str(self.as_str())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // ── is_retryable ─────────────────────────────────────────────────────────
170
171    #[test]
172    fn retryable_service_unavailable() {
173        assert!(ErrorCode::ServiceUnavailable.is_retryable());
174    }
175
176    #[test]
177    fn retryable_connection_failed() {
178        assert!(ErrorCode::ConnectionFailed.is_retryable());
179    }
180
181    #[test]
182    fn retryable_timeout() {
183        assert!(ErrorCode::Timeout.is_retryable());
184    }
185
186    #[test]
187    fn retryable_rate_limited() {
188        assert!(ErrorCode::RateLimited.is_retryable());
189    }
190
191    #[test]
192    fn retryable_external_service() {
193        assert!(ErrorCode::ExternalService.is_retryable());
194    }
195
196    #[test]
197    fn not_retryable_not_found() {
198        assert!(!ErrorCode::NotFound.is_retryable());
199    }
200
201    #[test]
202    fn not_retryable_already_exists() {
203        assert!(!ErrorCode::AlreadyExists.is_retryable());
204    }
205
206    #[test]
207    fn not_retryable_conflict() {
208        assert!(!ErrorCode::Conflict.is_retryable());
209    }
210
211    #[test]
212    fn not_retryable_invalid_input() {
213        assert!(!ErrorCode::InvalidInput.is_retryable());
214    }
215
216    #[test]
217    fn not_retryable_missing_field() {
218        assert!(!ErrorCode::MissingField.is_retryable());
219    }
220
221    #[test]
222    fn not_retryable_invalid_format() {
223        assert!(!ErrorCode::InvalidFormat.is_retryable());
224    }
225
226    #[test]
227    fn not_retryable_unauthorized() {
228        assert!(!ErrorCode::Unauthorized.is_retryable());
229    }
230
231    #[test]
232    fn not_retryable_forbidden() {
233        assert!(!ErrorCode::Forbidden.is_retryable());
234    }
235
236    #[test]
237    fn not_retryable_token_expired() {
238        assert!(!ErrorCode::TokenExpired.is_retryable());
239    }
240
241    #[test]
242    fn not_retryable_invalid_token() {
243        assert!(!ErrorCode::InvalidToken.is_retryable());
244    }
245
246    #[test]
247    fn not_retryable_internal() {
248        assert!(!ErrorCode::Internal.is_retryable());
249    }
250
251    #[test]
252    fn not_retryable_database_error() {
253        assert!(!ErrorCode::DatabaseError.is_retryable());
254    }
255
256    // ── http_status ───────────────────────────────────────────────────────────
257
258    #[test]
259    fn http_status_not_found_is_404() {
260        assert_eq!(
261            ErrorCode::NotFound.http_status(),
262            http::StatusCode::NOT_FOUND
263        );
264    }
265
266    #[test]
267    fn http_status_unauthorized_is_401() {
268        assert_eq!(
269            ErrorCode::Unauthorized.http_status(),
270            http::StatusCode::UNAUTHORIZED
271        );
272    }
273
274    #[test]
275    fn http_status_internal_is_500() {
276        assert_eq!(
277            ErrorCode::Internal.http_status(),
278            http::StatusCode::INTERNAL_SERVER_ERROR
279        );
280    }
281
282    #[test]
283    fn http_status_rate_limited_is_429() {
284        assert_eq!(
285            ErrorCode::RateLimited.http_status(),
286            http::StatusCode::TOO_MANY_REQUESTS
287        );
288    }
289
290    // ── as_str ────────────────────────────────────────────────────────────────
291
292    #[test]
293    fn as_str_connection_failed() {
294        assert_eq!(ErrorCode::ConnectionFailed.as_str(), "CONNECTION_FAILED");
295    }
296
297    #[test]
298    fn as_str_not_found() {
299        assert_eq!(ErrorCode::NotFound.as_str(), "NOT_FOUND");
300    }
301
302    #[test]
303    fn as_str_unauthorized() {
304        assert_eq!(ErrorCode::Unauthorized.as_str(), "UNAUTHORIZED");
305    }
306
307    #[test]
308    fn as_str_internal() {
309        assert_eq!(ErrorCode::Internal.as_str(), "INTERNAL_ERROR");
310    }
311
312    #[test]
313    fn display_uses_as_str() {
314        assert_eq!(format!("{}", ErrorCode::Timeout), "TIMEOUT");
315    }
316
317    // ── serde ↔ as_str parity ──────────────────────────────────────────────
318    //
319    // `as_str()` is hand-maintained alongside serde's wire encoding;
320    // this guards against the two drifting. Every variant must appear here
321    // so a newly added code fails the test until its string mapping is verified.
322    #[test]
323    fn serde_repr_matches_as_str_for_all_variants() {
324        const ALL: &[ErrorCode] = &[
325            ErrorCode::ServiceUnavailable,
326            ErrorCode::ConnectionFailed,
327            ErrorCode::Timeout,
328            ErrorCode::RateLimited,
329            ErrorCode::NotFound,
330            ErrorCode::AlreadyExists,
331            ErrorCode::Conflict,
332            ErrorCode::InvalidInput,
333            ErrorCode::MissingField,
334            ErrorCode::InvalidFormat,
335            ErrorCode::Unauthorized,
336            ErrorCode::Forbidden,
337            ErrorCode::TokenExpired,
338            ErrorCode::InvalidToken,
339            ErrorCode::Internal,
340            ErrorCode::DatabaseError,
341            ErrorCode::ExternalService,
342            ErrorCode::Cancelled,
343        ];
344        for code in ALL {
345            let json = serde_json::to_string(code).expect("serialize");
346            let wire = json.trim_matches('"');
347            assert_eq!(wire, code.as_str(), "serde/as_str drift for {code:?}");
348            let back: ErrorCode = serde_json::from_str(&json).expect("deserialize");
349            assert_eq!(back, *code, "roundtrip drift for {code:?}");
350            assert_eq!(
351                ErrorCode::from_wire(code.as_str()),
352                Some(*code),
353                "from_wire/as_str drift for {code:?}"
354            );
355        }
356    }
357
358    #[test]
359    fn from_wire_returns_none_for_unknown_codes() {
360        assert_eq!(ErrorCode::from_wire("NOT_A_REAL_CODE"), None);
361        assert_eq!(ErrorCode::from_wire(""), None);
362        // The `as_str` fallback string is not itself a real code.
363        assert_eq!(ErrorCode::from_wire("UNKNOWN"), None);
364    }
365}