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]
135 pub fn from_wire(value: &str) -> Option<Self> {
136 Some(match value {
137 "SERVICE_UNAVAILABLE" => ErrorCode::ServiceUnavailable,
138 "CONNECTION_FAILED" => ErrorCode::ConnectionFailed,
139 "TIMEOUT" => ErrorCode::Timeout,
140 "RATE_LIMITED" => ErrorCode::RateLimited,
141 "NOT_FOUND" => ErrorCode::NotFound,
142 "ALREADY_EXISTS" => ErrorCode::AlreadyExists,
143 "CONFLICT" => ErrorCode::Conflict,
144 "INVALID_INPUT" => ErrorCode::InvalidInput,
145 "MISSING_FIELD" => ErrorCode::MissingField,
146 "INVALID_FORMAT" => ErrorCode::InvalidFormat,
147 "UNAUTHORIZED" => ErrorCode::Unauthorized,
148 "FORBIDDEN" => ErrorCode::Forbidden,
149 "TOKEN_EXPIRED" => ErrorCode::TokenExpired,
150 "INVALID_TOKEN" => ErrorCode::InvalidToken,
151 "INTERNAL_ERROR" => ErrorCode::Internal,
152 "DATABASE_ERROR" => ErrorCode::DatabaseError,
153 "EXTERNAL_SERVICE_ERROR" => ErrorCode::ExternalService,
154 "CANCELLED" => ErrorCode::Cancelled,
155 _ => return None,
156 })
157 }
158}
159
160impl std::fmt::Display for ErrorCode {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 f.write_str(self.as_str())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
173 fn retryable_service_unavailable() {
174 assert!(ErrorCode::ServiceUnavailable.is_retryable());
175 }
176
177 #[test]
178 fn retryable_connection_failed() {
179 assert!(ErrorCode::ConnectionFailed.is_retryable());
180 }
181
182 #[test]
183 fn retryable_timeout() {
184 assert!(ErrorCode::Timeout.is_retryable());
185 }
186
187 #[test]
188 fn retryable_rate_limited() {
189 assert!(ErrorCode::RateLimited.is_retryable());
190 }
191
192 #[test]
193 fn retryable_external_service() {
194 assert!(ErrorCode::ExternalService.is_retryable());
195 }
196
197 #[test]
198 fn not_retryable_not_found() {
199 assert!(!ErrorCode::NotFound.is_retryable());
200 }
201
202 #[test]
203 fn not_retryable_already_exists() {
204 assert!(!ErrorCode::AlreadyExists.is_retryable());
205 }
206
207 #[test]
208 fn not_retryable_conflict() {
209 assert!(!ErrorCode::Conflict.is_retryable());
210 }
211
212 #[test]
213 fn not_retryable_invalid_input() {
214 assert!(!ErrorCode::InvalidInput.is_retryable());
215 }
216
217 #[test]
218 fn not_retryable_missing_field() {
219 assert!(!ErrorCode::MissingField.is_retryable());
220 }
221
222 #[test]
223 fn not_retryable_invalid_format() {
224 assert!(!ErrorCode::InvalidFormat.is_retryable());
225 }
226
227 #[test]
228 fn not_retryable_unauthorized() {
229 assert!(!ErrorCode::Unauthorized.is_retryable());
230 }
231
232 #[test]
233 fn not_retryable_forbidden() {
234 assert!(!ErrorCode::Forbidden.is_retryable());
235 }
236
237 #[test]
238 fn not_retryable_token_expired() {
239 assert!(!ErrorCode::TokenExpired.is_retryable());
240 }
241
242 #[test]
243 fn not_retryable_invalid_token() {
244 assert!(!ErrorCode::InvalidToken.is_retryable());
245 }
246
247 #[test]
248 fn not_retryable_internal() {
249 assert!(!ErrorCode::Internal.is_retryable());
250 }
251
252 #[test]
253 fn not_retryable_database_error() {
254 assert!(!ErrorCode::DatabaseError.is_retryable());
255 }
256
257 #[test]
260 fn http_status_not_found_is_404() {
261 assert_eq!(
262 ErrorCode::NotFound.http_status(),
263 http::StatusCode::NOT_FOUND
264 );
265 }
266
267 #[test]
268 fn http_status_unauthorized_is_401() {
269 assert_eq!(
270 ErrorCode::Unauthorized.http_status(),
271 http::StatusCode::UNAUTHORIZED
272 );
273 }
274
275 #[test]
276 fn http_status_internal_is_500() {
277 assert_eq!(
278 ErrorCode::Internal.http_status(),
279 http::StatusCode::INTERNAL_SERVER_ERROR
280 );
281 }
282
283 #[test]
284 fn http_status_rate_limited_is_429() {
285 assert_eq!(
286 ErrorCode::RateLimited.http_status(),
287 http::StatusCode::TOO_MANY_REQUESTS
288 );
289 }
290
291 #[test]
294 fn as_str_connection_failed() {
295 assert_eq!(ErrorCode::ConnectionFailed.as_str(), "CONNECTION_FAILED");
296 }
297
298 #[test]
299 fn as_str_not_found() {
300 assert_eq!(ErrorCode::NotFound.as_str(), "NOT_FOUND");
301 }
302
303 #[test]
304 fn as_str_unauthorized() {
305 assert_eq!(ErrorCode::Unauthorized.as_str(), "UNAUTHORIZED");
306 }
307
308 #[test]
309 fn as_str_internal() {
310 assert_eq!(ErrorCode::Internal.as_str(), "INTERNAL_ERROR");
311 }
312
313 #[test]
314 fn display_uses_as_str() {
315 assert_eq!(format!("{}", ErrorCode::Timeout), "TIMEOUT");
316 }
317
318 #[test]
324 fn serde_repr_matches_as_str_for_all_variants() {
325 const ALL: &[ErrorCode] = &[
326 ErrorCode::ServiceUnavailable,
327 ErrorCode::ConnectionFailed,
328 ErrorCode::Timeout,
329 ErrorCode::RateLimited,
330 ErrorCode::NotFound,
331 ErrorCode::AlreadyExists,
332 ErrorCode::Conflict,
333 ErrorCode::InvalidInput,
334 ErrorCode::MissingField,
335 ErrorCode::InvalidFormat,
336 ErrorCode::Unauthorized,
337 ErrorCode::Forbidden,
338 ErrorCode::TokenExpired,
339 ErrorCode::InvalidToken,
340 ErrorCode::Internal,
341 ErrorCode::DatabaseError,
342 ErrorCode::ExternalService,
343 ErrorCode::Cancelled,
344 ];
345 for code in ALL {
346 let json = serde_json::to_string(code).expect("serialize");
347 let wire = json.trim_matches('"');
348 assert_eq!(wire, code.as_str(), "serde/as_str drift for {code:?}");
349 let back: ErrorCode = serde_json::from_str(&json).expect("deserialize");
350 assert_eq!(back, *code, "roundtrip drift for {code:?}");
351 assert_eq!(
352 ErrorCode::from_wire(code.as_str()),
353 Some(*code),
354 "from_wire/as_str drift for {code:?}"
355 );
356 }
357 }
358
359 #[test]
360 fn from_wire_returns_none_for_unknown_codes() {
361 assert_eq!(ErrorCode::from_wire("NOT_A_REAL_CODE"), None);
362 assert_eq!(ErrorCode::from_wire(""), None);
363 assert_eq!(ErrorCode::from_wire("UNKNOWN"), None);
365 }
366}