use std::any;
use std::error::Error;
use std::fmt::Debug;
use std::str::FromStr;
use std::time::Duration;
use chrono::{DateTime, Utc};
use log::warn;
use thiserror::Error;
use crate::api::{PaginationError, UrlBase};
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BodyError {
#[error("failed to URL encode form parameters: {}", source)]
UrlEncoded {
#[from]
source: serde_urlencoded::ser::Error,
},
#[error("failed to JSON encode form parameters: {}", source)]
JsonEncoded {
#[from]
source: serde_json::Error,
},
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ApiError<E>
where
E: Error + Send + Sync + 'static,
{
#[error("client error: {}", source)]
Client {
source: E,
},
#[error("failed to authenticate: {}", source)]
Auth {
#[from]
source: crate::AuthError,
},
#[error("failed to parse url: {}", source)]
UrlParse {
#[from]
source: url::ParseError,
},
#[error("failed to create form data: {}", source)]
Body {
#[from]
source: BodyError,
},
#[error("could not parse JSON response: {}", source)]
Json {
#[from]
source: serde_json::Error,
},
#[error("moved permanently to: {}", location.as_ref().map(AsRef::as_ref).unwrap_or("<UNKNOWN>"))]
MovedPermanently {
location: Option<String>,
},
#[error("gitlab internal server error {}", status)]
GitlabService {
status: http::StatusCode,
data: Vec<u8>,
},
#[error("could not parse {} data from JSON: {}", typename, source)]
DataType {
source: serde_json::Error,
typename: &'static str,
},
#[error("failed to handle for pagination: {}", source)]
Pagination {
#[from]
source: PaginationError,
},
#[error("unsupported URL base: {:?}", url_base)]
UnsupportedUrlBase {
url_base: UrlBase,
},
#[error("gitlab server error ({}): {}", status, msg)]
GitlabWithStatus {
status: http::StatusCode,
msg: String,
},
#[error("gitlab server error ({}): {:?}", status, obj)]
GitlabObjectWithStatus {
status: http::StatusCode,
obj: serde_json::Value,
},
#[error("gitlab server error ({}): {:?}", status, obj)]
GitlabUnrecognizedWithStatus {
status: http::StatusCode,
obj: serde_json::Value,
},
#[error("gitlab rate limited until {}", rl_reset)]
GitlabRateLimited {
rl_limit: usize,
rl_name: String,
rl_observed: usize,
rl_remaining: usize,
rl_reset: DateTime<Utc>,
retry_after: Duration,
},
}
impl<E> ApiError<E>
where
E: Error + Send + Sync + 'static,
{
pub fn client(source: E) -> Self {
ApiError::Client {
source,
}
}
pub fn map_client<F, W>(self, f: F) -> ApiError<W>
where
F: FnOnce(E) -> W,
W: Error + Send + Sync + 'static,
{
match self {
Self::Client {
source,
} => ApiError::client(f(source)),
Self::UrlParse {
source,
} => {
ApiError::UrlParse {
source,
}
},
Self::Auth {
source,
} => {
ApiError::Auth {
source,
}
},
Self::Body {
source,
} => {
ApiError::Body {
source,
}
},
Self::Json {
source,
} => {
ApiError::Json {
source,
}
},
Self::MovedPermanently {
location,
} => {
ApiError::MovedPermanently {
location,
}
},
Self::GitlabWithStatus {
status,
msg,
} => {
ApiError::GitlabWithStatus {
status,
msg,
}
},
Self::GitlabService {
status,
data,
} => {
ApiError::GitlabService {
status,
data,
}
},
Self::GitlabObjectWithStatus {
status,
obj,
} => {
ApiError::GitlabObjectWithStatus {
status,
obj,
}
},
Self::GitlabUnrecognizedWithStatus {
status,
obj,
} => {
ApiError::GitlabUnrecognizedWithStatus {
status,
obj,
}
},
Self::DataType {
source,
typename,
} => {
ApiError::DataType {
source,
typename,
}
},
Self::Pagination {
source,
} => {
ApiError::Pagination {
source,
}
},
Self::UnsupportedUrlBase {
url_base,
} => {
ApiError::UnsupportedUrlBase {
url_base,
}
},
Self::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
} => {
ApiError::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
}
},
}
}
pub(crate) fn moved_permanently(raw_location: Option<&http::HeaderValue>) -> Self {
let location = raw_location.map(|v| String::from_utf8_lossy(v.as_bytes()).into());
Self::MovedPermanently {
location,
}
}
pub(crate) fn server_error(status: http::StatusCode, body: &bytes::Bytes) -> Self {
Self::GitlabService {
status,
data: body.into_iter().copied().collect(),
}
}
fn header_parse<T, D>(headers: &http::HeaderMap, name: &str, default: D) -> T
where
D: Into<T>,
T: FromStr,
<T as FromStr>::Err: Debug,
{
let opt_value = headers.get(name);
if let Some(value) = opt_value {
match value.to_str().map(|value| value.parse()) {
Ok(Ok(t)) => t,
Ok(Err(err)) => {
warn!(target: "gitlab", "failed to parse header '{name}: {value:?}' into value: {err:?}");
default.into()
},
Err(err) => {
warn!(target: "gitlab", "could not represent header '{name}' as a string: {err:?}");
default.into()
},
}
} else {
warn!(target: "gitlab", "missing rate limit header '{name}'");
default.into()
}
}
pub(crate) fn from_gitlab_rate_limit(headers: &http::HeaderMap) -> Self {
let rl_limit = Self::header_parse(headers, "RateLimit-Limit", 0usize);
let rl_name = Self::header_parse(headers, "RateLimit-Name", "");
let rl_observed = Self::header_parse(headers, "RateLimit-Observed", 0usize);
let rl_remaining = Self::header_parse(headers, "RateLimit-Remaining", 0usize);
let rl_reset = DateTime::<Utc>::from_timestamp(
Self::header_parse(headers, "RateLimit-Reset", 0i64),
0,
)
.unwrap_or_else(|| {
DateTime::<Utc>::from_timestamp(0, 0).expect("zero-timestamp should be valid")
});
let retry_after = Duration::from_secs(Self::header_parse(headers, "Retry-After", 0u64));
Self::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
}
}
pub(crate) fn from_gitlab_with_status(
status: http::StatusCode,
value: serde_json::Value,
) -> Self {
let error_value = value
.pointer("/message")
.or_else(|| value.pointer("/error"));
if let Some(error_value) = error_value {
if let Some(msg) = error_value.as_str() {
ApiError::GitlabWithStatus {
status,
msg: msg.into(),
}
} else {
ApiError::GitlabObjectWithStatus {
status,
obj: error_value.clone(),
}
}
} else {
ApiError::GitlabUnrecognizedWithStatus {
status,
obj: value,
}
}
}
pub(crate) fn data_type<T>(source: serde_json::Error) -> Self {
ApiError::DataType {
source,
typename: any::type_name::<T>(),
}
}
pub(crate) fn unsupported_url_base(url_base: UrlBase) -> Self {
Self::UnsupportedUrlBase {
url_base,
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use http::{HeaderMap, HeaderName, HeaderValue};
use serde_json::json;
use thiserror::Error;
use crate::api::ApiError;
#[derive(Debug, Error)]
#[error("my error")]
enum MyError {}
#[test]
fn gitlab_error_error() {
let obj = json!({
"error": "error contents",
});
let expected_status = http::StatusCode::NOT_FOUND;
let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
if let ApiError::GitlabWithStatus {
status,
msg,
} = err
{
assert_eq!(status, expected_status);
assert_eq!(msg, "error contents");
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_string() {
let obj = json!({
"message": "error contents",
});
let expected_status = http::StatusCode::NOT_FOUND;
let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
if let ApiError::GitlabWithStatus {
status,
msg,
} = err
{
assert_eq!(status, expected_status);
assert_eq!(msg, "error contents");
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_object() {
let err_obj = json!({
"blah": "foo",
});
let obj = json!({
"message": err_obj,
});
let expected_status = http::StatusCode::NOT_FOUND;
let err: ApiError<MyError> = ApiError::from_gitlab_with_status(expected_status, obj);
if let ApiError::GitlabObjectWithStatus {
status,
obj,
} = err
{
assert_eq!(status, expected_status);
assert_eq!(obj, err_obj);
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_unrecognized() {
let err_obj = json!({
"some_weird_key": "an even weirder value",
});
let expected_status = http::StatusCode::NOT_FOUND;
let err: ApiError<MyError> =
ApiError::from_gitlab_with_status(expected_status, err_obj.clone());
if let ApiError::GitlabUnrecognizedWithStatus {
status,
obj,
} = err
{
assert_eq!(status, expected_status);
assert_eq!(obj, err_obj);
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_rate_limited() {
let reset = Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap();
let headers = [
("ratelimit-limit", "5"),
("ratelimit-name", "gitlab_error_test"),
("ratelimit-observed", "100"),
("ratelimit-remaining", "10"),
("ratelimit-reset", "1735603200"),
("retry-after", "1000"),
]
.into_iter()
.map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
.collect();
let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
if let ApiError::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
} = err
{
assert_eq!(rl_limit, 5);
assert_eq!(rl_name, "gitlab_error_test");
assert_eq!(rl_observed, 100);
assert_eq!(rl_remaining, 10);
assert_eq!(rl_reset, reset);
assert_eq!(retry_after, Duration::from_secs(1000));
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_rate_limited_missing_fields() {
let headers = HeaderMap::new();
let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
if let ApiError::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
} = err
{
assert_eq!(rl_limit, 0);
assert_eq!(rl_name, "");
assert_eq!(rl_observed, 0);
assert_eq!(rl_remaining, 0);
assert_eq!(rl_reset, DateTime::<Utc>::from_timestamp(0, 0).unwrap());
assert_eq!(retry_after, Duration::from_secs(0));
} else {
panic!("unexpected error: {}", err);
}
}
#[test]
fn gitlab_error_message_rate_limited_invalid_fields() {
let headers = [
("ratelimit-limit", "-1"),
("ratelimit-name", "how to make invalid?"),
("ratelimit-observed", "-1"),
("ratelimit-remaining", "-1"),
("ratelimit-reset", "18446744073709551616"), ("retry-after", "-1"),
]
.into_iter()
.map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
.collect();
let err: ApiError<MyError> = ApiError::from_gitlab_rate_limit(&headers);
if let ApiError::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
} = err
{
assert_eq!(rl_limit, 0);
assert_eq!(rl_name, "how to make invalid?");
assert_eq!(rl_observed, 0);
assert_eq!(rl_remaining, 0);
assert_eq!(rl_reset, DateTime::<Utc>::from_timestamp(0, 0).unwrap());
assert_eq!(retry_after, Duration::from_secs(0));
} else {
panic!("unexpected error: {}", err);
}
}
mod client {
use std::time::Duration;
use chrono::{TimeZone, Utc};
use http::{HeaderName, HeaderValue};
use serde_json::json;
use crate::api::endpoint_prelude::*;
use crate::api::{ApiError, Query};
use crate::test::client::{ExpectedUrl, SingleTestClient};
struct Dummy;
impl Endpoint for Dummy {
fn method(&self) -> Method {
Method::GET
}
fn endpoint(&self) -> Cow<'static, str> {
"dummy".into()
}
}
#[test]
fn gitlab_error_message_rate_limited_plumbed() {
let reset = Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap();
let endpoint = ExpectedUrl::builder()
.endpoint("dummy")
.status(http::StatusCode::TOO_MANY_REQUESTS)
.build()
.unwrap();
let client = SingleTestClient::new_json_headers(
endpoint,
[
("ratelimit-limit", "5"),
("ratelimit-name", "gitlab_error_test"),
("ratelimit-observed", "100"),
("ratelimit-remaining", "10"),
("ratelimit-reset", "1735603200"),
("retry-after", "1000"),
]
.into_iter()
.map(|(n, v)| (HeaderName::from_static(n), HeaderValue::from_static(v)))
.collect(),
&json!({
"value": 0,
}),
);
let res: Result<(), _> = Dummy.query(&client);
let err = res.unwrap_err();
if let ApiError::GitlabRateLimited {
rl_limit,
rl_name,
rl_observed,
rl_remaining,
rl_reset,
retry_after,
} = err
{
assert_eq!(rl_limit, 5);
assert_eq!(rl_name, "gitlab_error_test");
assert_eq!(rl_observed, 100);
assert_eq!(rl_remaining, 10);
assert_eq!(rl_reset, reset);
assert_eq!(retry_after, Duration::from_secs(1000));
} else {
panic!("unexpected error: {}", err);
}
}
}
}