1#[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 ServiceUnavailable,
12 ConnectionFailed,
14 Timeout,
16 RateLimited,
18
19 NotFound,
22 AlreadyExists,
24 Conflict,
26
27 InvalidInput,
30 MissingField,
32 InvalidFormat,
34
35 Unauthorized,
38 Forbidden,
40 TokenExpired,
42 InvalidToken,
44
45 #[serde(rename = "INTERNAL_ERROR")]
48 Internal,
49 DatabaseError,
51 #[serde(rename = "EXTERNAL_SERVICE_ERROR")]
53 ExternalService,
54
55 Cancelled,
59}
60
61impl ErrorCode {
62 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 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 ErrorCode::Cancelled => http::StatusCode::REQUEST_TIMEOUT,
96 #[allow(unreachable_patterns)]
97 _ => http::StatusCode::INTERNAL_SERVER_ERROR,
98 }
99 }
100
101 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 #[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 #[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 #[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 #[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 #[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 assert_eq!(ErrorCode::from_wire("UNKNOWN"), None);
364 }
365}