use anyhow::Result;
pub fn is_recoverable(err: &anyhow::Error) -> bool {
use misanthropic::client::{AnthropicError, Error as ClientError};
for cause in err.chain() {
if let Some(client_err) = cause.downcast_ref::<ClientError>() {
return client_error_recoverable(client_err);
}
if let Some(a) = cause.downcast_ref::<AnthropicError>() {
return anthropic_err_recoverable(a);
}
}
true
}
pub fn client_error_recoverable(err: &misanthropic::client::Error) -> bool {
!crate::reactor::RetryAfter::is_fatal(err)
}
fn anthropic_err_recoverable(
err: &misanthropic::client::AnthropicError,
) -> bool {
!crate::reactor::RetryAfter::is_fatal(err)
}
pub async fn retry_recoverable<F, Fut, T>(
label: &str,
max_retries: usize,
mut f: F,
) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut delay = std::time::Duration::from_secs(1);
let mut attempt = 0usize;
loop {
match f().await {
Ok(v) => return Ok(v),
Err(e) => {
if !is_recoverable(&e) {
tracing::error!(
"{label}: non-recoverable error, not retrying: {e}"
);
return Err(e);
}
if attempt >= max_retries {
tracing::error!(
"{label}: giving up after {max_retries} retries: {e}"
);
return Err(e);
}
tracing::warn!(
"{label}: recoverable error (attempt {}/{max_retries}), \
retrying in {}s: {e}",
attempt + 1,
delay.as_secs(),
);
tokio::time::sleep(delay).await;
attempt += 1;
delay = std::cmp::min(
delay * 2,
std::time::Duration::from_secs(30),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use misanthropic::client::{AnthropicError, Error as ClientError};
use std::num::NonZeroU16;
fn anyhowed<E>(err: E) -> anyhow::Error
where
E: std::error::Error + Send + Sync + 'static,
{
anyhow::Error::new(err)
}
#[test]
fn is_recoverable_anthropic_5xx_retries() {
assert!(is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::API {
message: "internal error".to_string(),
}
))));
}
#[test]
fn is_recoverable_rate_limit_retries() {
assert!(is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::RateLimit {
message: "too many requests".to_string(),
retry_after: None,
}
))));
}
#[test]
fn is_recoverable_overloaded_retries() {
assert!(is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::Overloaded {
message: "overloaded".to_string(),
retry_after: None,
}
))));
}
#[test]
fn is_recoverable_unknown_5xx_retries() {
assert!(is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::Unknown {
code: Some(NonZeroU16::new(502).unwrap()),
message: "bad gateway".to_string(),
}
))));
}
#[test]
fn is_recoverable_unknown_no_code_does_not_retry() {
assert!(!is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::Unknown {
code: None,
message: "future_error_type: something new".to_string(),
}
))));
}
#[test]
fn is_recoverable_anthropic_4xx_does_not_retry() {
assert!(!is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::InvalidRequest {
message: "bad request".to_string(),
}
))));
}
#[test]
fn is_recoverable_auth_does_not_retry() {
assert!(!is_recoverable(&anyhowed(ClientError::Anthropic(
AnthropicError::Authentication {
message: "bad key".to_string(),
}
))));
}
#[test]
fn is_recoverable_parse_does_not_retry() {
let parse_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
assert!(!is_recoverable(&anyhowed(ClientError::Parse(parse_err))));
}
#[test]
fn is_recoverable_non_json_response_retries() {
assert!(is_recoverable(&anyhowed(ClientError::NonJsonResponse {
status: 502,
body: "<html>Bad Gateway</html>".to_string(),
})));
}
#[test]
fn is_recoverable_unknown_error_defaults_to_retry() {
assert!(is_recoverable(&anyhow::anyhow!(
"some random backend error"
)));
}
#[tokio::test]
async fn retry_recoverable_succeeds_immediately() {
let mut attempts = 0;
let result: Result<i32> = retry_recoverable("test", 3, || {
attempts += 1;
async { Ok(42) }
})
.await;
assert!(matches!(result, Ok(42)));
assert_eq!(attempts, 1);
}
#[tokio::test]
async fn retry_recoverable_stops_on_non_recoverable() {
let mut attempts = 0;
let result: Result<i32> = retry_recoverable("test", 5, || {
attempts += 1;
async {
Err(anyhow::Error::new(ClientError::Anthropic(
AnthropicError::Authentication {
message: "no".to_string(),
},
)))
}
})
.await;
assert!(result.is_err());
assert_eq!(
attempts, 1,
"auth errors should short-circuit without retrying"
);
}
#[tokio::test]
async fn retry_recoverable_gives_up_after_max_retries() {
let mut attempts = 0;
let result: Result<i32> = retry_recoverable("test", 0, || {
attempts += 1;
async {
Err(anyhow::Error::new(ClientError::Anthropic(
AnthropicError::Overloaded {
message: "busy".to_string(),
retry_after: None,
},
)))
}
})
.await;
assert!(result.is_err());
assert_eq!(attempts, 1, "max_retries=0 means one attempt, no retries");
}
#[test]
fn client_error_recoverable_matches_the_anyhow_path() {
let cases: Vec<(ClientError, bool)> = vec![
(
ClientError::NonJsonResponse {
status: 503,
body: "upstream connect error".into(),
},
true,
),
(
ClientError::Anthropic(AnthropicError::Overloaded {
message: "overloaded".into(),
retry_after: None,
}),
true,
),
(
ClientError::Anthropic(AnthropicError::InvalidRequest {
message: "bad".into(),
}),
false,
),
(ClientError::UnexpectedResponse { message: "huh" }, false),
];
for (err, expected) in cases {
let direct = client_error_recoverable(&err);
assert_eq!(
direct, expected,
"client_error_recoverable disagreed for {err:?}"
);
assert_eq!(
is_recoverable(&anyhowed(err)),
expected,
"the two classification paths disagreed"
);
}
}
}