1use thiserror::Error;
4
5pub type Result<T> = std::result::Result<T, ClientError>;
7
8#[derive(Debug)]
13pub struct HttpError {
14 inner: reqwest::Error,
15}
16
17impl std::fmt::Display for HttpError {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 self.inner.fmt(f)
20 }
21}
22
23impl std::error::Error for HttpError {
24 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
25 self.inner.source()
26 }
27}
28
29impl From<reqwest::Error> for HttpError {
30 fn from(err: reqwest::Error) -> Self {
31 Self { inner: err }
32 }
33}
34
35#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum ClientError {
39 #[error("[{status_code}] unauthorized: {message}")]
41 Unauthorized {
42 status_code: u16,
44 message: String,
46 },
47
48 #[error("[{status_code}] forbidden: {message}")]
50 Forbidden {
51 status_code: u16,
53 message: String,
55 },
56
57 #[error("[{status_code}] not found: {message}")]
59 NotFound {
60 status_code: u16,
62 message: String,
64 },
65
66 #[error("[{status_code}] conflict: {message}")]
68 Conflict {
69 status_code: u16,
71 message: String,
73 },
74
75 #[error("[{status_code}] invalid request: {message}")]
77 InvalidRequest {
78 status_code: u16,
80 message: String,
82 },
83
84 #[error("[{status_code}] unprocessable entity: {message}")]
86 UnprocessableEntity {
87 status_code: u16,
89 message: String,
91 },
92
93 #[error("[{status_code}] rate limited: {message}")]
95 RateLimited {
96 status_code: u16,
98 message: String,
100 },
101
102 #[error("[{status_code}] server error: {message}")]
104 ServerError {
105 status_code: u16,
107 message: String,
109 },
110
111 #[error("http error: {0}")]
113 Http(#[from] HttpError),
114
115 #[error("json error: {0}")]
117 Json(#[from] serde_json::Error),
118
119 #[error("invalid url: {0}")]
121 InvalidUrl(String),
122
123 #[error("configuration error: {0}")]
125 Configuration(String),
126}
127
128#[derive(Debug, Clone, serde::Deserialize)]
130#[non_exhaustive]
131pub struct ApiErrorResponse {
132 pub status: String,
134 pub code: String,
136 pub message: String,
138 #[serde(default)]
140 pub details: serde_json::Value,
141}
142
143impl ClientError {
144 pub fn from_response(status_code: u16, body: &str) -> Self {
146 let message = serde_json::from_str::<ApiErrorResponse>(body)
147 .map_or_else(|_| body.to_string(), |e| e.message);
148
149 match status_code {
150 401 => Self::Unauthorized {
151 status_code,
152 message,
153 },
154 403 => Self::Forbidden {
155 status_code,
156 message,
157 },
158 404 => Self::NotFound {
159 status_code,
160 message,
161 },
162 409 => Self::Conflict {
163 status_code,
164 message,
165 },
166 400 => Self::InvalidRequest {
167 status_code,
168 message,
169 },
170 422 => Self::UnprocessableEntity {
171 status_code,
172 message,
173 },
174 429 => Self::RateLimited {
175 status_code,
176 message,
177 },
178 500..=599 => Self::ServerError {
179 status_code,
180 message,
181 },
182 _ => Self::ServerError {
183 status_code,
184 message: format!("unexpected status {status_code}: {message}"),
185 },
186 }
187 }
188
189 pub fn status_code(&self) -> Option<u16> {
191 match self {
192 Self::Unauthorized { status_code, .. }
193 | Self::Forbidden { status_code, .. }
194 | Self::NotFound { status_code, .. }
195 | Self::Conflict { status_code, .. }
196 | Self::InvalidRequest { status_code, .. }
197 | Self::UnprocessableEntity { status_code, .. }
198 | Self::RateLimited { status_code, .. }
199 | Self::ServerError { status_code, .. } => Some(*status_code),
200 _ => None,
201 }
202 }
203}
204
205#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
213 fn from_response_401() {
214 let err = ClientError::from_response(401, "bad creds");
215 assert!(matches!(
216 err,
217 ClientError::Unauthorized {
218 status_code: 401,
219 ..
220 }
221 ));
222 assert_eq!(err.status_code(), Some(401));
223 assert!(err.to_string().contains("unauthorized"));
224 }
225
226 #[test]
227 fn from_response_403() {
228 let err = ClientError::from_response(403, "denied");
229 assert!(matches!(
230 err,
231 ClientError::Forbidden {
232 status_code: 403,
233 ..
234 }
235 ));
236 assert_eq!(err.status_code(), Some(403));
237 }
238
239 #[test]
240 fn from_response_404() {
241 let err = ClientError::from_response(404, "gone");
242 assert!(matches!(
243 err,
244 ClientError::NotFound {
245 status_code: 404,
246 ..
247 }
248 ));
249 assert_eq!(err.status_code(), Some(404));
250 }
251
252 #[test]
253 fn from_response_409() {
254 let err = ClientError::from_response(409, "conflict");
255 assert!(matches!(
256 err,
257 ClientError::Conflict {
258 status_code: 409,
259 ..
260 }
261 ));
262 assert_eq!(err.status_code(), Some(409));
263 }
264
265 #[test]
266 fn from_response_400() {
267 let err = ClientError::from_response(400, "bad req");
268 assert!(matches!(
269 err,
270 ClientError::InvalidRequest {
271 status_code: 400,
272 ..
273 }
274 ));
275 assert_eq!(err.status_code(), Some(400));
276 }
277
278 #[test]
279 fn from_response_422() {
280 let err = ClientError::from_response(422, "unprocessable");
281 assert!(matches!(
282 err,
283 ClientError::UnprocessableEntity {
284 status_code: 422,
285 ..
286 }
287 ));
288 assert_eq!(err.status_code(), Some(422));
289 }
290
291 #[test]
292 fn from_response_429() {
293 let err = ClientError::from_response(429, "slow down");
294 assert!(matches!(
295 err,
296 ClientError::RateLimited {
297 status_code: 429,
298 ..
299 }
300 ));
301 assert_eq!(err.status_code(), Some(429));
302 }
303
304 #[test]
305 fn from_response_500() {
306 let err = ClientError::from_response(500, "oops");
307 assert!(matches!(
308 err,
309 ClientError::ServerError {
310 status_code: 500,
311 ..
312 }
313 ));
314 assert_eq!(err.status_code(), Some(500));
315 }
316
317 #[test]
318 fn from_response_503() {
319 let err = ClientError::from_response(503, "unavailable");
320 assert!(matches!(
321 err,
322 ClientError::ServerError {
323 status_code: 503,
324 ..
325 }
326 ));
327 }
328
329 #[test]
330 fn from_response_unexpected_status() {
331 let err = ClientError::from_response(418, "teapot");
332 assert!(matches!(
333 err,
334 ClientError::ServerError {
335 status_code: 418,
336 ..
337 }
338 ));
339 assert!(err.to_string().contains("unexpected status 418"));
340 }
341
342 #[test]
345 fn from_response_json_body_extracts_message() {
346 let body =
347 r#"{"status":"error","code":"AUTH_FAILED","message":"token expired","details":{}}"#;
348 let err = ClientError::from_response(401, body);
349 match err {
350 ClientError::Unauthorized { message, .. } => assert_eq!(message, "token expired"),
351 other => panic!("expected Unauthorized, got: {other:?}"),
352 }
353 }
354
355 #[test]
356 fn from_response_plain_text_body() {
357 let err = ClientError::from_response(401, "plain text error");
358 match err {
359 ClientError::Unauthorized { message, .. } => assert_eq!(message, "plain text error"),
360 other => panic!("expected Unauthorized, got: {other:?}"),
361 }
362 }
363
364 #[test]
367 fn status_code_none_for_non_http_errors() {
368 let err = ClientError::InvalidUrl("bad url".to_string());
369 assert_eq!(err.status_code(), None);
370
371 let err = ClientError::Configuration("missing key".to_string());
372 assert_eq!(err.status_code(), None);
373 }
374
375 #[test]
378 fn display_format_includes_status_code() {
379 let err = ClientError::from_response(404, "not here");
380 assert!(err.to_string().contains("[404]"));
381 }
382}