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
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 #[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 #[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 #[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 #[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}