use crate::util::error::HttpError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ErrorClass {
Retryable,
NonRetryable,
}
const RETRYABLE_OVERRIDES: &[&str] = &[
"temporarily rate-limited",
"upstream_provider_shared_pool",
];
const NON_RETRYABLE_HINTS: &[&str] = &[
"insufficient balance",
"insufficient_quota",
"quota exhausted",
"quota exceeded",
"error code 1113",
];
pub(crate) fn classify_err(err: &anyhow::Error) -> ErrorClass {
if let Some(http_err) = err.downcast_ref::<HttpError>() {
let body_lower = http_err.body.to_lowercase();
if (400..500).contains(&http_err.status) && http_err.status != 408 && http_err.status != 429
{
return ErrorClass::NonRetryable;
}
if http_err.status == 429 {
if RETRYABLE_OVERRIDES.iter().any(|h| body_lower.contains(h)) {
return ErrorClass::Retryable;
}
if NON_RETRYABLE_HINTS.iter().any(|h| body_lower.contains(h)) {
return ErrorClass::NonRetryable;
}
}
return ErrorClass::Retryable;
}
ErrorClass::Retryable
}
#[cfg(test)]
mod tests {
use super::*;
fn test_err(status: u16, body: &str) -> anyhow::Error {
anyhow::Error::from(HttpError {
status,
body: body.into(),
context: "test".into(),
})
}
#[test]
fn retryable_error_classification() {
let is_non_retryable =
|e: &anyhow::Error| matches!(classify_err(e), ErrorClass::NonRetryable);
assert!(is_non_retryable(&test_err(401, "Unauthorized")));
assert!(is_non_retryable(&test_err(403, "Forbidden")));
assert!(is_non_retryable(&test_err(400, "invalid api key")));
assert!(is_non_retryable(&test_err(429, "insufficient balance")));
assert!(is_non_retryable(&test_err(429, "insufficient_quota")));
assert!(is_non_retryable(&test_err(429, "quota exhausted")));
assert!(is_non_retryable(&test_err(429, "error code 1113")));
assert!(!is_non_retryable(&anyhow::anyhow!("500 Server Error")));
assert!(!is_non_retryable(&anyhow::anyhow!("502 Bad Gateway")));
assert!(!is_non_retryable(&anyhow::anyhow!(
"503 Service Unavailable"
)));
assert!(!is_non_retryable(&anyhow::anyhow!("connection reset")));
assert!(!is_non_retryable(&anyhow::anyhow!(
"model overloaded, try again later"
)));
}
#[test]
fn classify_err_typed_path() {
assert!(matches!(
classify_err(&test_err(429, "Too Many Requests")),
ErrorClass::Retryable
));
assert!(matches!(
classify_err(&test_err(429, "rate limit exceeded")),
ErrorClass::Retryable
));
assert!(matches!(
classify_err(&test_err(408, "Request Timeout")),
ErrorClass::Retryable
));
assert_eq!(
classify_err(&test_err(
502,
"Your chosen model is down or we received an invalid response from it"
)),
ErrorClass::Retryable
);
assert_eq!(
classify_err(&test_err(502, "upstream model not found")),
ErrorClass::Retryable
);
}
#[test]
fn context_window_error_classification() {
let is_non_retryable =
|e: &anyhow::Error| matches!(classify_err(e), ErrorClass::NonRetryable);
assert!(is_non_retryable(&test_err(
400,
"request (8968 tokens) exceeds the available context size (8448 tokens)",
)));
assert!(is_non_retryable(&test_err(
400,
"This model's maximum context length is 8192 tokens",
)));
assert!(is_non_retryable(&test_err(
400,
"maximum context length of this model is 128K tokens",
)));
assert!(is_non_retryable(&test_err(401, "Unauthorized")));
}
#[test]
fn tool_schema_error_detection() {
use ErrorClass::NonRetryable;
for msg in [
r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'recall' which was not in request"}}"#,
"tool 'search' which was not in request",
"function 'foo' not found in tool list",
"invalid_tool_call: no matching function",
] {
assert!(
matches!(classify_err(&test_err(400, msg)), NonRetryable),
"should detect: {msg}"
);
}
assert!(
matches!(
classify_err(&test_err(400, "invalid api key provided")),
NonRetryable
),
"pure 400 should be NonRetryable"
);
}
#[test]
fn non_retryable_hints_are_classified_non_retryable() {
for hint in NON_RETRYABLE_HINTS {
let err = test_err(429, hint);
assert!(
matches!(classify_err(&err), ErrorClass::NonRetryable),
"hint '{hint}' should be classified as NonRetryable"
);
}
}
#[test]
fn proxy_5xx_with_hint_text_is_retryable() {
for hint in NON_RETRYABLE_HINTS {
let err = test_err(502, &format!("upstream error: {hint}"));
assert!(
matches!(classify_err(&err), ErrorClass::Retryable),
"502 with hint '{hint}' should remain Retryable"
);
}
}
#[test]
fn upstream_rate_limit_overrides_quota_hint() {
let body = concat!(
r#"{"error":{"code":429,"message":"Provider returned error","metadata":"#,
r#"{"provider_name":"Qwen","provider_error_code":"insufficient_quota","#,
r#""raw":"upstream error: temporarily rate-limited upstream, please retry"}}}"#,
);
let err = test_err(429, body);
assert!(
matches!(classify_err(&err), ErrorClass::Retryable),
"429 with 'temporarily rate-limited' in metadata.raw should be Retryable \
despite also containing 'insufficient_quota'"
);
}
#[test]
fn upstream_shared_pool_overrides_quota_hint() {
let body = concat!(
r#"{"error":{"code":429,"message":"Provider returned error","metadata":"#,
r#"{"provider_name":"Alibaba","provider_error_code":"insufficient_quota","#,
r#""limit_source":"upstream_provider_shared_pool"}}}"#,
);
let err = test_err(429, body);
assert!(
matches!(classify_err(&err), ErrorClass::Retryable),
"429 with 'upstream_provider_shared_pool' should be Retryable \
despite also containing 'insufficient_quota'"
);
}
#[test]
fn genuine_quota_exhaustion_still_non_retryable() {
let body = r#"{"error":{"code":429,"message":"You have exceeded your quota. insufficient_quota"}}"#;
let err = test_err(429, body);
assert!(
matches!(classify_err(&err), ErrorClass::NonRetryable),
"genuine quota exhaustion 429 without override should remain NonRetryable"
);
}
#[test]
fn retryable_overrides_do_not_affect_non_429() {
let err = test_err(400, "Bad Request: temporarily rate-limited");
assert!(
matches!(classify_err(&err), ErrorClass::NonRetryable),
"400 with override text should still be NonRetryable"
);
}
}