use dragonfly_api::errordetails::v2::Backend;
use reqwest;
use std::collections::HashMap;
use tonic::Code;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error{"request timeout: {0}"}]
RequestTimeout(String),
#[error{"invalid argument: {0}"}]
InvalidArgument(String),
#[error{"request internal error: {0}"}]
Internal(String),
#[error{"host {0} not found"}]
HostNotFound(String),
#[allow(clippy::enum_variant_names)]
#[error(transparent)]
BackendError(#[from] BackendError),
#[allow(clippy::enum_variant_names)]
#[error(transparent)]
ProxyError(#[from] ProxyError),
#[allow(clippy::enum_variant_names)]
#[error(transparent)]
DfdaemonError(#[from] DfdaemonError),
#[allow(clippy::enum_variant_names)]
#[error(transparent)]
NetAddrParseError(#[from] std::net::AddrParseError),
#[error(transparent)]
TonicStatus(#[from] tonic::Status),
#[allow(clippy::enum_variant_names)]
#[error(transparent)]
TonicTransportError(#[from] tonic::transport::Error),
}
impl Error {
pub(crate) fn is_retryable(&self) -> bool {
match self {
Error::RequestTimeout(_) | Error::Internal(_) | Error::DfdaemonError(_) => true,
Error::ProxyError(ProxyError { status_code, .. })
| Error::BackendError(BackendError { status_code, .. }) => {
status_code.is_none_or(|status| {
status.is_server_error()
|| status == reqwest::StatusCode::REQUEST_TIMEOUT
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS
})
}
Error::TonicStatus(status) => matches!(
status.code(),
Code::Internal | Code::Unavailable | Code::Unknown | Code::Aborted
),
Error::InvalidArgument(_)
| Error::HostNotFound(_)
| Error::NetAddrParseError(_)
| Error::TonicTransportError(_) => false,
}
}
pub(crate) fn from_status(status: tonic::Status) -> Error {
if let Ok(backend) = serde_json::from_slice::<Backend>(status.details()) {
return Error::BackendError(BackendError {
message: Some(backend.message),
header: backend.header,
status_code: backend
.status_code
.and_then(|code| u16::try_from(code).ok())
.and_then(|code| reqwest::StatusCode::from_u16(code).ok()),
});
}
match status.code() {
Code::DeadlineExceeded => Error::RequestTimeout(status.message().to_string()),
_ => Error::TonicStatus(status),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error(
"backend server error, message: {message:?}, header: {header:?}, status_code: {status_code:?}"
)]
pub struct BackendError {
pub message: Option<String>,
pub header: HashMap<String, String>,
pub status_code: Option<reqwest::StatusCode>,
}
#[derive(Debug, thiserror::Error)]
#[error(
"proxy server error, message: {message:?}, header: {header:?}, status_code: {status_code:?}"
)]
pub struct ProxyError {
pub message: Option<String>,
pub header: HashMap<String, String>,
pub status_code: Option<reqwest::StatusCode>,
}
#[derive(Debug, thiserror::Error)]
#[error("dfdaemon error, message: {message:?}")]
pub struct DfdaemonError {
pub message: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
fn proxy(status_code: Option<StatusCode>) -> Error {
Error::ProxyError(ProxyError {
message: None,
header: HashMap::new(),
status_code,
})
}
fn backend(status_code: Option<StatusCode>) -> Error {
Error::BackendError(BackendError {
message: None,
header: HashMap::new(),
status_code,
})
}
fn status(code: Code) -> Error {
Error::TonicStatus(tonic::Status::new(code, "status"))
}
#[test]
fn is_retryable_marks_only_transient_failures() {
let test_cases = vec![
(Error::RequestTimeout("timeout".to_string()), true),
(Error::Internal("boom".to_string()), true),
(Error::DfdaemonError(DfdaemonError { message: None }), true),
(proxy(None), true),
(backend(None), true),
(proxy(Some(StatusCode::INTERNAL_SERVER_ERROR)), true),
(proxy(Some(StatusCode::BAD_GATEWAY)), true),
(proxy(Some(StatusCode::SERVICE_UNAVAILABLE)), true),
(proxy(Some(StatusCode::REQUEST_TIMEOUT)), true),
(proxy(Some(StatusCode::TOO_MANY_REQUESTS)), true),
(backend(Some(StatusCode::INTERNAL_SERVER_ERROR)), true),
(backend(Some(StatusCode::REQUEST_TIMEOUT)), true),
(backend(Some(StatusCode::TOO_MANY_REQUESTS)), true),
(proxy(Some(StatusCode::BAD_REQUEST)), false),
(proxy(Some(StatusCode::UNAUTHORIZED)), false),
(proxy(Some(StatusCode::FORBIDDEN)), false),
(proxy(Some(StatusCode::NOT_FOUND)), false),
(proxy(Some(StatusCode::RANGE_NOT_SATISFIABLE)), false),
(backend(Some(StatusCode::NOT_FOUND)), false),
(status(Code::Internal), true),
(status(Code::Unavailable), true),
(status(Code::Unknown), true),
(status(Code::Aborted), true),
(status(Code::InvalidArgument), false),
(status(Code::NotFound), false),
(status(Code::PermissionDenied), false),
(status(Code::Unauthenticated), false),
(status(Code::ResourceExhausted), false),
(Error::InvalidArgument("bad".to_string()), false),
(Error::HostNotFound("host".to_string()), false),
];
for (err, expected) in test_cases {
assert_eq!(err.is_retryable(), expected, "error: {err:?}");
}
}
#[test]
fn from_status_keeps_the_backend_status_and_retryability() {
let backend_status = |status_code: Option<i32>| {
tonic::Status::with_details(
Code::Internal,
"backend error",
serde_json::to_vec(&Backend {
message: "origin said no".to_string(),
header: HashMap::from([("x-served-by".to_string(), "origin".to_string())]),
status_code,
})
.unwrap()
.into(),
)
};
let test_cases: Vec<(tonic::Status, fn(Error))> = vec![
(backend_status(Some(404)), |err| {
assert!(matches!(
&err,
Error::BackendError(BackendError {
message: Some(message),
header,
status_code: Some(StatusCode::NOT_FOUND),
}) if message == "origin said no" && header["x-served-by"] == "origin"
));
assert!(!err.is_retryable());
}),
(backend_status(Some(503)), |err| {
assert!(matches!(
err,
Error::BackendError(BackendError {
status_code: Some(StatusCode::SERVICE_UNAVAILABLE),
..
})
));
assert!(err.is_retryable());
}),
(backend_status(None), |err| {
assert!(matches!(
err,
Error::BackendError(BackendError {
status_code: None,
..
})
));
assert!(err.is_retryable());
}),
(backend_status(Some(99999)), |err| {
assert!(matches!(
err,
Error::BackendError(BackendError {
status_code: None,
..
})
));
assert!(err.is_retryable());
}),
(
tonic::Status::with_details(Code::Internal, "not json", b"{".to_vec().into()),
|err| {
assert!(
matches!(&err, Error::TonicStatus(status) if status.code() == Code::Internal)
);
assert!(err.is_retryable());
},
),
(tonic::Status::deadline_exceeded("too slow"), |err| {
assert!(matches!(err, Error::RequestTimeout(_)));
assert!(err.is_retryable());
}),
(tonic::Status::internal("storage is full"), |err| {
assert!(
matches!(&err, Error::TonicStatus(status) if status.code() == Code::Internal)
);
assert!(err.is_retryable());
}),
(tonic::Status::not_found("no task"), |err| {
assert!(
matches!(&err, Error::TonicStatus(status) if status.code() == Code::NotFound)
);
assert!(!err.is_retryable());
}),
];
for (status, expect) in test_cases {
expect(Error::from_status(status));
}
}
}