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 match client_err {
ClientError::HTTP(_) => true, ClientError::Parse(_) => false,
ClientError::UnexpectedResponse { .. } => false,
ClientError::Anthropic(a) => anthropic_err_recoverable(a),
ClientError::NonJsonResponse { .. } => true,
};
}
if let Some(a) = cause.downcast_ref::<AnthropicError>() {
return anthropic_err_recoverable(a);
}
}
true
}
fn anthropic_err_recoverable(
err: &misanthropic::client::AnthropicError,
) -> bool {
use misanthropic::client::AnthropicError::*;
match err {
RateLimit { .. } | API { .. } | Overloaded { .. } | Timeout { .. } => {
true
}
Unknown { code, .. } => matches!(code, Some(c) if c.get() >= 500),
InvalidRequest { .. }
| Authentication { .. }
| Billing { .. }
| Permission { .. }
| NotFound { .. }
| RequestTooLarge { .. } => false,
}
}
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");
}
}