use std::time::Duration;
use tonic::Code;
use crate::retry::retry_after;
use crate::token_cache::TokenCacheError;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("transport error: {0}")]
Transport(String),
#[error("token refresh failed: {0}")]
TokenRefresh(#[from] TokenCacheError),
#[error("invalid path prefix '{prefix}': {reason}")]
InvalidPathPrefix { prefix: String, reason: String },
#[error("rejected: {code:?} {message}")]
Rejected { code: Code, message: String },
#[error("rate limited: {message}")]
RateLimited {
message: String,
retry_after: Option<Duration>,
},
#[error("server error: {code:?} {message}")]
ServerError { code: Code, message: String },
#[error("unexpected proto response: {0}")]
ProtoMismatch(String),
}
impl Error {
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport(_) | Self::ServerError { .. } | Self::RateLimited { .. } => true,
Self::TokenRefresh(_)
| Self::InvalidPathPrefix { .. }
| Self::Rejected { .. }
| Self::ProtoMismatch(_) => false,
}
}
}
#[must_use]
pub fn classify_status(status: &tonic::Status) -> Error {
let code = status.code();
let message = status.message().to_string();
match code {
Code::ResourceExhausted => Error::RateLimited {
message,
retry_after: retry_after(status),
},
Code::InvalidArgument
| Code::NotFound
| Code::AlreadyExists
| Code::PermissionDenied
| Code::Unauthenticated
| Code::FailedPrecondition
| Code::OutOfRange
| Code::Aborted
| Code::Cancelled => Error::Rejected { code, message },
Code::Internal | Code::Unknown | Code::DataLoss | Code::Unimplemented => {
Error::ServerError { code, message }
}
Code::DeadlineExceeded => Error::ServerError { code, message },
Code::Unavailable => Error::Transport(message),
Code::Ok => Error::Transport(format!("classify_status called on Code::Ok: {message}")),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use tonic::Status;
use tonic::metadata::MetadataValue;
#[test]
fn resource_exhausted_with_metadata_routes_to_rate_limited() {
let mut status = Status::resource_exhausted("Rate limit exceeded");
status
.metadata_mut()
.insert("retry-after", MetadataValue::from_static("60"));
match classify_status(&status) {
Error::RateLimited { message, retry_after } => {
assert_eq!(message, "Rate limit exceeded");
assert_eq!(retry_after, Some(Duration::from_secs(60)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn resource_exhausted_without_metadata_routes_to_rate_limited_none() {
let status = Status::resource_exhausted("Rate limit exceeded");
match classify_status(&status) {
Error::RateLimited { retry_after, .. } => assert_eq!(retry_after, None),
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn invalid_argument_routes_to_rejected() {
let status = Status::invalid_argument("bad template");
match classify_status(&status) {
Error::Rejected { code, message } => {
assert_eq!(code, Code::InvalidArgument);
assert_eq!(message, "bad template");
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn permission_denied_routes_to_rejected() {
let status = Status::permission_denied("ops face requires an application credential");
match classify_status(&status) {
Error::Rejected { code, .. } => assert_eq!(code, Code::PermissionDenied),
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn internal_routes_to_server_error() {
let status = Status::internal("boom");
match classify_status(&status) {
Error::ServerError { code, .. } => assert_eq!(code, Code::Internal),
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn unimplemented_routes_to_server_error() {
let status = Status::unimplemented("not yet wired");
match classify_status(&status) {
Error::ServerError { code, .. } => assert_eq!(code, Code::Unimplemented),
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn deadline_exceeded_routes_to_server_error() {
let status = Status::deadline_exceeded("slow upstream");
match classify_status(&status) {
Error::ServerError { code, .. } => assert_eq!(code, Code::DeadlineExceeded),
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn unavailable_routes_to_transport() {
let status = Status::unavailable("conn refused");
match classify_status(&status) {
Error::Transport(msg) => assert_eq!(msg, "conn refused"),
other => panic!("expected Transport, got {other:?}"),
}
}
#[test]
fn ok_degrades_to_transport() {
let status = Status::ok("");
match classify_status(&status) {
Error::Transport(_) => {}
other => panic!("expected Transport, got {other:?}"),
}
}
#[test]
fn is_retryable_partitions_the_variant_set() {
assert!(Error::Transport("conn reset".to_owned()).is_retryable());
assert!(
Error::ServerError { code: Code::Internal, message: String::new() }.is_retryable()
);
assert!(
Error::RateLimited {
message: String::new(),
retry_after: Some(Duration::from_secs(1)),
}
.is_retryable()
);
assert!(!Error::TokenRefresh(TokenCacheError::Fetch("no token".to_owned())).is_retryable());
assert!(
!Error::InvalidPathPrefix { prefix: "/x".to_owned(), reason: String::new() }
.is_retryable()
);
assert!(
!Error::Rejected { code: Code::PermissionDenied, message: String::new() }
.is_retryable()
);
assert!(!Error::ProtoMismatch("missing field".to_owned()).is_retryable());
}
}