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 or system before completion
57    /// (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
128impl std::fmt::Display for ErrorCode {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    // ── is_retryable ─────────────────────────────────────────────────────────
139
140    #[test]
141    fn retryable_service_unavailable() {
142        assert!(ErrorCode::ServiceUnavailable.is_retryable());
143    }
144
145    #[test]
146    fn retryable_connection_failed() {
147        assert!(ErrorCode::ConnectionFailed.is_retryable());
148    }
149
150    #[test]
151    fn retryable_timeout() {
152        assert!(ErrorCode::Timeout.is_retryable());
153    }
154
155    #[test]
156    fn retryable_rate_limited() {
157        assert!(ErrorCode::RateLimited.is_retryable());
158    }
159
160    #[test]
161    fn retryable_external_service() {
162        assert!(ErrorCode::ExternalService.is_retryable());
163    }
164
165    #[test]
166    fn not_retryable_not_found() {
167        assert!(!ErrorCode::NotFound.is_retryable());
168    }
169
170    #[test]
171    fn not_retryable_already_exists() {
172        assert!(!ErrorCode::AlreadyExists.is_retryable());
173    }
174
175    #[test]
176    fn not_retryable_conflict() {
177        assert!(!ErrorCode::Conflict.is_retryable());
178    }
179
180    #[test]
181    fn not_retryable_invalid_input() {
182        assert!(!ErrorCode::InvalidInput.is_retryable());
183    }
184
185    #[test]
186    fn not_retryable_missing_field() {
187        assert!(!ErrorCode::MissingField.is_retryable());
188    }
189
190    #[test]
191    fn not_retryable_invalid_format() {
192        assert!(!ErrorCode::InvalidFormat.is_retryable());
193    }
194
195    #[test]
196    fn not_retryable_unauthorized() {
197        assert!(!ErrorCode::Unauthorized.is_retryable());
198    }
199
200    #[test]
201    fn not_retryable_forbidden() {
202        assert!(!ErrorCode::Forbidden.is_retryable());
203    }
204
205    #[test]
206    fn not_retryable_token_expired() {
207        assert!(!ErrorCode::TokenExpired.is_retryable());
208    }
209
210    #[test]
211    fn not_retryable_invalid_token() {
212        assert!(!ErrorCode::InvalidToken.is_retryable());
213    }
214
215    #[test]
216    fn not_retryable_internal() {
217        assert!(!ErrorCode::Internal.is_retryable());
218    }
219
220    #[test]
221    fn not_retryable_database_error() {
222        assert!(!ErrorCode::DatabaseError.is_retryable());
223    }
224
225    // ── http_status ───────────────────────────────────────────────────────────
226
227    #[test]
228    fn http_status_not_found_is_404() {
229        assert_eq!(
230            ErrorCode::NotFound.http_status(),
231            http::StatusCode::NOT_FOUND
232        );
233    }
234
235    #[test]
236    fn http_status_unauthorized_is_401() {
237        assert_eq!(
238            ErrorCode::Unauthorized.http_status(),
239            http::StatusCode::UNAUTHORIZED
240        );
241    }
242
243    #[test]
244    fn http_status_internal_is_500() {
245        assert_eq!(
246            ErrorCode::Internal.http_status(),
247            http::StatusCode::INTERNAL_SERVER_ERROR
248        );
249    }
250
251    #[test]
252    fn http_status_rate_limited_is_429() {
253        assert_eq!(
254            ErrorCode::RateLimited.http_status(),
255            http::StatusCode::TOO_MANY_REQUESTS
256        );
257    }
258
259    // ── as_str ────────────────────────────────────────────────────────────────
260
261    #[test]
262    fn as_str_connection_failed() {
263        assert_eq!(ErrorCode::ConnectionFailed.as_str(), "CONNECTION_FAILED");
264    }
265
266    #[test]
267    fn as_str_not_found() {
268        assert_eq!(ErrorCode::NotFound.as_str(), "NOT_FOUND");
269    }
270
271    #[test]
272    fn as_str_unauthorized() {
273        assert_eq!(ErrorCode::Unauthorized.as_str(), "UNAUTHORIZED");
274    }
275
276    #[test]
277    fn as_str_internal() {
278        assert_eq!(ErrorCode::Internal.as_str(), "INTERNAL_ERROR");
279    }
280
281    #[test]
282    fn display_uses_as_str() {
283        assert_eq!(format!("{}", ErrorCode::Timeout), "TIMEOUT");
284    }
285
286    // ── serde ↔ as_str parity ──────────────────────────────────────────────
287    //
288    // `as_str()` is hand-maintained alongside serde's wire encoding; this guards
289    // against the two drifting. Every variant must appear here so a newly added
290    // code fails the test until its string mapping is verified.
291    #[test]
292    fn serde_repr_matches_as_str_for_all_variants() {
293        const ALL: &[ErrorCode] = &[
294            ErrorCode::ServiceUnavailable,
295            ErrorCode::ConnectionFailed,
296            ErrorCode::Timeout,
297            ErrorCode::RateLimited,
298            ErrorCode::NotFound,
299            ErrorCode::AlreadyExists,
300            ErrorCode::Conflict,
301            ErrorCode::InvalidInput,
302            ErrorCode::MissingField,
303            ErrorCode::InvalidFormat,
304            ErrorCode::Unauthorized,
305            ErrorCode::Forbidden,
306            ErrorCode::TokenExpired,
307            ErrorCode::InvalidToken,
308            ErrorCode::Internal,
309            ErrorCode::DatabaseError,
310            ErrorCode::ExternalService,
311            ErrorCode::Cancelled,
312        ];
313        for code in ALL {
314            let json = serde_json::to_string(code).expect("serialize");
315            let wire = json.trim_matches('"');
316            assert_eq!(wire, code.as_str(), "serde/as_str drift for {code:?}");
317            let back: ErrorCode = serde_json::from_str(&json).expect("deserialize");
318            assert_eq!(back, *code, "roundtrip drift for {code:?}");
319        }
320    }
321}