gitlab/api/
error.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use 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/// Errors which may occur when creating form data.
20#[derive(Debug, Error)]
21#[non_exhaustive]
22pub enum BodyError {
23    /// Body data could not be serialized from form parameters.
24    #[error("failed to URL encode form parameters: {}", source)]
25    UrlEncoded {
26        /// The source of the error.
27        #[from]
28        source: serde_urlencoded::ser::Error,
29    },
30    /// Body data could not be serialized to JSON from form parameters.
31    #[error("failed to JSON encode form parameters: {}", source)]
32    JsonEncoded {
33        /// The source of the error.
34        #[from]
35        source: serde_json::Error,
36    },
37}
38
39/// Errors which may occur when using API endpoints.
40#[derive(Debug, Error)]
41#[non_exhaustive]
42pub enum ApiError<E>
43where
44    E: Error + Send + Sync + 'static,
45{
46    /// The client encountered an error.
47    #[error("client error: {}", source)]
48    Client {
49        /// The client error.
50        source: E,
51    },
52    /// Authentication failed.
53    #[error("failed to authenticate: {}", source)]
54    Auth {
55        /// The source of the error.
56        #[from]
57        source: crate::AuthError,
58    },
59    /// The URL failed to parse.
60    #[error("failed to parse url: {}", source)]
61    UrlParse {
62        /// The source of the error.
63        #[from]
64        source: url::ParseError,
65    },
66    /// Body data could not be created.
67    #[error("failed to create form data: {}", source)]
68    Body {
69        /// The source of the error.
70        #[from]
71        source: BodyError,
72    },
73    /// JSON deserialization from GitLab failed.
74    #[error("could not parse JSON response: {}", source)]
75    Json {
76        /// The source of the error.
77        #[from]
78        source: serde_json::Error,
79    },
80    /// The resource has been moved permanently.
81    #[error("moved permanently to: {}", location.as_ref().map(AsRef::as_ref).unwrap_or("<UNKNOWN>"))]
82    MovedPermanently {
83        /// The new location for the resource.
84        location: Option<String>,
85    },
86    /// GitLab returned an error without JSON information.
87    #[error("gitlab internal server error {}", status)]
88    GitlabService {
89        /// The status code for the return.
90        status: http::StatusCode,
91        /// The error data from GitLab.
92        data: Vec<u8>,
93    },
94    /// Failed to parse an expected data type from JSON.
95    #[error("could not parse {} data from JSON: {}", typename, source)]
96    DataType {
97        /// The source of the error.
98        source: serde_json::Error,
99        /// The name of the type that could not be deserialized.
100        typename: &'static str,
101    },
102    /// An error with pagination occurred.
103    #[error("failed to handle for pagination: {}", source)]
104    Pagination {
105        /// The source of the error.
106        #[from]
107        source: PaginationError,
108    },
109    /// The client does not understand how to use an endpoint for the given URL base.
110    #[error("unsupported URL base: {:?}", url_base)]
111    UnsupportedUrlBase {
112        /// The URL base that is not supported.
113        url_base: UrlBase,
114    },
115    /// GitLab returned an error message with an HTTP error.
116    #[error("gitlab server error ({}): {}", status, msg)]
117    GitlabWithStatus {
118        /// The HTTP status code.
119        status: http::StatusCode,
120        /// The error message from GitLab.
121        msg: String,
122    },
123    /// GitLab returned an error object with an HTTP error.
124    #[error("gitlab server error ({}): {:?}", status, obj)]
125    GitlabObjectWithStatus {
126        /// The HTTP status code.
127        status: http::StatusCode,
128        /// The error object from GitLab.
129        obj: serde_json::Value,
130    },
131    /// GitLab returned an HTTP error with JSON we did not recognize.
132    #[error("gitlab server error ({}): {:?}", status, obj)]
133    GitlabUnrecognizedWithStatus {
134        /// The HTTP status code.
135        status: http::StatusCode,
136        /// The full object from GitLab.
137        obj: serde_json::Value,
138    },
139    /// GitLab returned an error message with an HTTP error.
140    #[error("gitlab rate limited until {}", rl_reset)]
141    GitlabRateLimited {
142        /// The rate limit.
143        rl_limit: usize,
144        /// The name of the rate limit in place.
145        rl_name: String,
146        /// The number of API usages observed.
147        rl_observed: usize,
148        /// The number of API requests remaining.
149        rl_remaining: usize,
150        /// When the rate limit resets.
151        rl_reset: DateTime<Utc>,
152        /// How long to wait before another request.
153        retry_after: Duration,
154    },
155}
156
157impl<E> ApiError<E>
158where
159    E: Error + Send + Sync + 'static,
160{
161    /// Create an API error in a client error.
162    pub fn client(source: E) -> Self {
163        ApiError::Client {
164            source,
165        }
166    }
167
168    /// Wrap a client error in another wrapper.
169    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"), // u64::MAX
565            ("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}