1use std::any;
8use std::error::Error;
9use std::fmt::Debug;
10use std::str::FromStr;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use log::warn;
15use thiserror::Error;
16
17use crate::api::{PaginationError, UrlBase};
18
19#[derive(Debug, Error)]
21#[non_exhaustive]
22pub enum BodyError {
23 #[error("failed to URL encode form parameters: {}", source)]
25 UrlEncoded {
26 #[from]
28 source: serde_urlencoded::ser::Error,
29 },
30 #[error("failed to JSON encode form parameters: {}", source)]
32 JsonEncoded {
33 #[from]
35 source: serde_json::Error,
36 },
37}
38
39#[derive(Debug, Error)]
41#[non_exhaustive]
42pub enum ApiError<E>
43where
44 E: Error + Send + Sync + 'static,
45{
46 #[error("client error: {}", source)]
48 Client {
49 source: E,
51 },
52 #[error("failed to authenticate: {}", source)]
54 Auth {
55 #[from]
57 source: crate::AuthError,
58 },
59 #[error("failed to parse url: {}", source)]
61 UrlParse {
62 #[from]
64 source: url::ParseError,
65 },
66 #[error("failed to create form data: {}", source)]
68 Body {
69 #[from]
71 source: BodyError,
72 },
73 #[error("could not parse JSON response: {}", source)]
75 Json {
76 #[from]
78 source: serde_json::Error,
79 },
80 #[error("moved permanently to: {}", location.as_ref().map(AsRef::as_ref).unwrap_or("<UNKNOWN>"))]
82 MovedPermanently {
83 location: Option<String>,
85 },
86 #[error("gitlab internal server error {}", status)]
88 GitlabService {
89 status: http::StatusCode,
91 data: Vec<u8>,
93 },
94 #[error("could not parse {} data from JSON: {}", typename, source)]
96 DataType {
97 source: serde_json::Error,
99 typename: &'static str,
101 },
102 #[error("failed to handle for pagination: {}", source)]
104 Pagination {
105 #[from]
107 source: PaginationError,
108 },
109 #[error("unsupported URL base: {:?}", url_base)]
111 UnsupportedUrlBase {
112 url_base: UrlBase,
114 },
115 #[error("gitlab server error ({}): {}", status, msg)]
117 GitlabWithStatus {
118 status: http::StatusCode,
120 msg: String,
122 },
123 #[error("gitlab server error ({}): {:?}", status, obj)]
125 GitlabObjectWithStatus {
126 status: http::StatusCode,
128 obj: serde_json::Value,
130 },
131 #[error("gitlab server error ({}): {:?}", status, obj)]
133 GitlabUnrecognizedWithStatus {
134 status: http::StatusCode,
136 obj: serde_json::Value,
138 },
139 #[error("gitlab rate limited until {}", rl_reset)]
141 GitlabRateLimited {
142 rl_limit: usize,
144 rl_name: String,
146 rl_observed: usize,
148 rl_remaining: usize,
150 rl_reset: DateTime<Utc>,
152 retry_after: Duration,
154 },
155}
156
157impl<E> ApiError<E>
158where
159 E: Error + Send + Sync + 'static,
160{
161 pub fn client(source: E) -> Self {
163 ApiError::Client {
164 source,
165 }
166 }
167
168 pub fn map_client<F, W>(self, f: F) -> ApiError<W>
170 where
171 F: FnOnce(E) -> W,
172 W: Error + Send + Sync + 'static,
173 {
174 match self {
175 Self::Client {
176 source,
177 } => ApiError::client(f(source)),
178 Self::UrlParse {
179 source,
180 } => {
181 ApiError::UrlParse {
182 source,
183 }
184 },
185 Self::Auth {
186 source,
187 } => {
188 ApiError::Auth {
189 source,
190 }
191 },
192 Self::Body {
193 source,
194 } => {
195 ApiError::Body {
196 source,
197 }
198 },
199 Self::Json {
200 source,
201 } => {
202 ApiError::Json {
203 source,
204 }
205 },
206 Self::MovedPermanently {
207 location,
208 } => {
209 ApiError::MovedPermanently {
210 location,
211 }
212 },
213 Self::GitlabWithStatus {
214 status,
215 msg,
216 } => {
217 ApiError::GitlabWithStatus {
218 status,
219 msg,
220 }
221 },
222 Self::GitlabService {
223 status,
224 data,
225 } => {
226 ApiError::GitlabService {
227 status,
228 data,
229 }
230 },
231 Self::GitlabObjectWithStatus {
232 status,
233 obj,
234 } => {
235 ApiError::GitlabObjectWithStatus {
236 status,
237 obj,
238 }
239 },
240 Self::GitlabUnrecognizedWithStatus {
241 status,
242 obj,
243 } => {
244 ApiError::GitlabUnrecognizedWithStatus {
245 status,
246 obj,
247 }
248 },
249 Self::DataType {
250 source,
251 typename,
252 } => {
253 ApiError::DataType {
254 source,
255 typename,
256 }
257 },
258 Self::Pagination {
259 source,
260 } => {
261 ApiError::Pagination {
262 source,
263 }
264 },
265 Self::UnsupportedUrlBase {
266 url_base,
267 } => {
268 ApiError::UnsupportedUrlBase {
269 url_base,
270 }
271 },
272 Self::GitlabRateLimited {
273 rl_limit,
274 rl_name,
275 rl_observed,
276 rl_remaining,
277 rl_reset,
278 retry_after,
279 } => {
280 ApiError::GitlabRateLimited {
281 rl_limit,
282 rl_name,
283 rl_observed,
284 rl_remaining,
285 rl_reset,
286 retry_after,
287 }
288 },
289 }
290 }
291
292 pub(crate) fn moved_permanently(raw_location: Option<&http::HeaderValue>) -> Self {
293 let location = raw_location.map(|v| String::from_utf8_lossy(v.as_bytes()).into());
294 Self::MovedPermanently {
295 location,
296 }
297 }
298
299 pub(crate) fn server_error(status: http::StatusCode, body: &bytes::Bytes) -> Self {
300 Self::GitlabService {
301 status,
302 data: body.into_iter().copied().collect(),
303 }
304 }
305
306 fn header_parse<T, D>(headers: &http::HeaderMap, name: &str, default: D) -> T
307 where
308 D: Into<T>,
309 T: FromStr,
310 <T as FromStr>::Err: Debug,
311 {
312 let opt_value = headers.get(name);
313 if let Some(value) = opt_value {
314 match value.to_str().map(|value| value.parse()) {
315 Ok(Ok(t)) => t,
316 Ok(Err(err)) => {
317 warn!(target: "gitlab", "failed to parse header '{}: {:?}' into value: {:?}", name, value, err);
318 default.into()
319 },
320 Err(err) => {
321 warn!(target: "gitlab", "could not represent header '{}' as a string: {:?}", name, err);
322 default.into()
323 },
324 }
325 } else {
326 warn!(target: "gitlab", "missing rate limit header '{}'", name);
327 default.into()
328 }
329 }
330
331 pub(crate) fn from_gitlab_rate_limit(headers: &http::HeaderMap) -> Self {
332 let rl_limit = Self::header_parse(headers, "RateLimit-Limit", 0usize);
333 let rl_name = Self::header_parse(headers, "RateLimit-Name", "");
334 let rl_observed = Self::header_parse(headers, "RateLimit-Observed", 0usize);
335 let rl_remaining = Self::header_parse(headers, "RateLimit-Remaining", 0usize);
336 let rl_reset = DateTime::<Utc>::from_timestamp(
337 Self::header_parse(headers, "RateLimit-Reset", 0i64),
338 0,
339 )
340 .unwrap_or_else(|| {
341 DateTime::<Utc>::from_timestamp(0, 0).expect("zero-timestamp should be valid")
342 });
343 let retry_after = Duration::from_secs(Self::header_parse(headers, "Retry-After", 0u64));
344
345 Self::GitlabRateLimited {
346 rl_limit,
347 rl_name,
348 rl_observed,
349 rl_remaining,
350 rl_reset,
351 retry_after,
352 }
353 }
354
355 pub(crate) fn from_gitlab_with_status(
356 status: http::StatusCode,
357 value: serde_json::Value,
358 ) -> Self {
359 let error_value = value
360 .pointer("/message")
361 .or_else(|| value.pointer("/error"));
362
363 if let Some(error_value) = error_value {
364 if let Some(msg) = error_value.as_str() {
365 ApiError::GitlabWithStatus {
366 status,
367 msg: msg.into(),
368 }
369 } else {
370 ApiError::GitlabObjectWithStatus {
371 status,
372 obj: error_value.clone(),
373 }
374 }
375 } else {
376 ApiError::GitlabUnrecognizedWithStatus {
377 status,
378 obj: value,
379 }
380 }
381 }
382
383 pub(crate) fn data_type<T>(source: serde_json::Error) -> Self {
384 ApiError::DataType {
385 source,
386 typename: any::type_name::<T>(),
387 }
388 }
389
390 pub(crate) fn unsupported_url_base(url_base: UrlBase) -> Self {
391 Self::UnsupportedUrlBase {
392 url_base,
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use std::time::Duration;
400
401 use chrono::{DateTime, TimeZone, Utc};
402 use http::{HeaderMap, HeaderName, HeaderValue};
403 use serde_json::json;
404 use thiserror::Error;
405
406 use crate::api::ApiError;
407
408 #[derive(Debug, Error)]
409 #[error("my error")]
410 enum MyError {}
411
412 #[test]
413 fn gitlab_error_error() {
414 let obj = json!({
415 "error": "error contents",
416 });
417
418 let expected_status = http::StatusCode::NOT_FOUND;
419 let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
420 if let ApiError::GitlabWithStatus {
421 status,
422 msg,
423 } = err
424 {
425 assert_eq!(status, expected_status);
426 assert_eq!(msg, "error contents");
427 } else {
428 panic!("unexpected error: {}", err);
429 }
430 }
431
432 #[test]
433 fn gitlab_error_message_string() {
434 let obj = json!({
435 "message": "error contents",
436 });
437
438 let expected_status = http::StatusCode::NOT_FOUND;
439 let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
440 if let ApiError::GitlabWithStatus {
441 status,
442 msg,
443 } = err
444 {
445 assert_eq!(status, expected_status);
446 assert_eq!(msg, "error contents");
447 } else {
448 panic!("unexpected error: {}", err);
449 }
450 }
451
452 #[test]
453 fn gitlab_error_message_object() {
454 let err_obj = json!({
455 "blah": "foo",
456 });
457 let obj = json!({
458 "message": err_obj,
459 });
460
461 let expected_status = http::StatusCode::NOT_FOUND;
462 let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
463 if let ApiError::GitlabObjectWithStatus {
464 status,
465 obj,
466 } = err
467 {
468 assert_eq!(status, expected_status);
469 assert_eq!(obj, err_obj);
470 } else {
471 panic!("unexpected error: {}", err);
472 }
473 }
474
475 #[test]
476 fn gitlab_error_message_unrecognized() {
477 let err_obj = json!({
478 "some_weird_key": "an even weirder value",
479 });
480
481 let expected_status = http::StatusCode::NOT_FOUND;
482 let err: ApiError<MyError> =
483 ApiError::from_gitlab_with_status(expected_status, err_obj.clone());
484 if let ApiError::GitlabUnrecognizedWithStatus {
485 status,
486 obj,
487 } = err
488 {
489 assert_eq!(status, expected_status);
490 assert_eq!(obj, err_obj);
491 } else {
492 panic!("unexpected error: {}", err);
493 }
494 }
495
496 #[test]
497 fn gitlab_error_message_rate_limited() {
498 let reset = Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap();
499 let headers = [
500 ("ratelimit-limit", "5"),
501 ("ratelimit-name", "gitlab_error_test"),
502 ("ratelimit-observed", "100"),
503 ("ratelimit-remaining", "10"),
504 ("ratelimit-reset", "1735603200"),
505 ("retry-after", "1000"),
506 ]
507 .into_iter()
508 .map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
509 .collect();
510
511 let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
512 if let ApiError::GitlabRateLimited {
513 rl_limit,
514 rl_name,
515 rl_observed,
516 rl_remaining,
517 rl_reset,
518 retry_after,
519 } = err
520 {
521 assert_eq!(rl_limit, 5);
522 assert_eq!(rl_name, "gitlab_error_test");
523 assert_eq!(rl_observed, 100);
524 assert_eq!(rl_remaining, 10);
525 assert_eq!(rl_reset, reset);
526 assert_eq!(retry_after, Duration::from_secs(1000));
527 } else {
528 panic!("unexpected error: {}", err);
529 }
530 }
531
532 #[test]
533 fn gitlab_error_message_rate_limited_missing_fields() {
534 let headers = HeaderMap::new();
535
536 let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
537 if let ApiError::GitlabRateLimited {
538 rl_limit,
539 rl_name,
540 rl_observed,
541 rl_remaining,
542 rl_reset,
543 retry_after,
544 } = err
545 {
546 assert_eq!(rl_limit, 0);
547 assert_eq!(rl_name, "");
548 assert_eq!(rl_observed, 0);
549 assert_eq!(rl_remaining, 0);
550 assert_eq!(rl_reset, DateTime::<Utc>::from_timestamp(0, 0).unwrap());
551 assert_eq!(retry_after, Duration::from_secs(0));
552 } else {
553 panic!("unexpected error: {}", err);
554 }
555 }
556
557 #[test]
558 fn gitlab_error_message_rate_limited_invalid_fields() {
559 let headers = [
560 ("ratelimit-limit", "-1"),
561 ("ratelimit-name", "how to make invalid?"),
562 ("ratelimit-observed", "-1"),
563 ("ratelimit-remaining", "-1"),
564 ("ratelimit-reset", "18446744073709551616"), ("retry-after", "-1"),
566 ]
567 .into_iter()
568 .map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
569 .collect();
570
571 let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
572 if let ApiError::GitlabRateLimited {
573 rl_limit,
574 rl_name,
575 rl_observed,
576 rl_remaining,
577 rl_reset,
578 retry_after,
579 } = err
580 {
581 assert_eq!(rl_limit, 0);
582 assert_eq!(rl_name, "how to make invalid?");
583 assert_eq!(rl_observed, 0);
584 assert_eq!(rl_remaining, 0);
585 assert_eq!(rl_reset, DateTime::<Utc>::from_timestamp(0, 0).unwrap());
586 assert_eq!(retry_after, Duration::from_secs(0));
587 } else {
588 panic!("unexpected error: {}", err);
589 }
590 }
591
592 mod client {
593 use std::time::Duration;
594
595 use chrono::{TimeZone, Utc};
596 use http::{HeaderName, HeaderValue};
597 use serde_json::json;
598
599 use crate::api::endpoint_prelude::*;
600 use crate::api::{ApiError, Query};
601 use crate::test::client::{ExpectedUrl, SingleTestClient};
602
603 struct Dummy;
604
605 impl Endpoint for Dummy {
606 fn method(&self) -> Method {
607 Method::GET
608 }
609
610 fn endpoint(&self) -> Cow<'static, str> {
611 "dummy".into()
612 }
613 }
614
615 #[test]
616 fn gitlab_error_message_rate_limited_plumbed() {
617 let reset = Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap();
618 let endpoint = ExpectedUrl::builder()
619 .endpoint("dummy")
620 .status(http::StatusCode::TOO_MANY_REQUESTS)
621 .build()
622 .unwrap();
623 let client = SingleTestClient::new_json_headers(
624 endpoint,
625 [
626 ("ratelimit-limit", "5"),
627 ("ratelimit-name", "gitlab_error_test"),
628 ("ratelimit-observed", "100"),
629 ("ratelimit-remaining", "10"),
630 ("ratelimit-reset", "1735603200"),
631 ("retry-after", "1000"),
632 ]
633 .into_iter()
634 .map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
635 .collect(),
636 &json!({
637 "value": 0,
638 }),
639 );
640
641 let res: Result<(), _> = Dummy.query(&client);
642 let err = res.unwrap_err();
643 if let ApiError::GitlabRateLimited {
644 rl_limit,
645 rl_name,
646 rl_observed,
647 rl_remaining,
648 rl_reset,
649 retry_after,
650 } = err
651 {
652 assert_eq!(rl_limit, 5);
653 assert_eq!(rl_name, "gitlab_error_test");
654 assert_eq!(rl_observed, 100);
655 assert_eq!(rl_remaining, 10);
656 assert_eq!(rl_reset, reset);
657 assert_eq!(retry_after, Duration::from_secs(1000));
658 } else {
659 panic!("unexpected error: {}", err);
660 }
661 }
662 }
663}