use std::time::Duration;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
pub const SUBJECT_ENGINE: &str = "engine";
pub const SUBJECT_ANTHROPIC: &str = "engine (Anthropic)";
pub const SUBJECT_GEMINI: &str = "engine (Gemini)";
pub const SUBJECT_RESPONSES: &str = "engine (OpenAI Responses)";
pub const SUBJECT_EMBEDDER: &str = "embedder";
pub const RETRYABLE_STATUSES: &[u16] = &[408, 429, 500, 502, 503, 504, 529];
const TRANSIENT_ERROR_NAMES: &[&str] = &[
"api_error",
"overloaded_error",
"rate_limit_error",
"timeout_error",
"unavailable_error",
"server_error",
"rate_limit_exceeded",
"service_unavailable",
"internal",
"internal_error",
"unavailable",
"resource_exhausted",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EngineErrorKind {
Transport,
Status,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct EngineError {
pub kind: EngineErrorKind,
pub status: Option<u16>,
pub retry_after: Option<Duration>,
pub message: String,
}
impl EngineError {
pub fn transport(err: &reqwest::Error) -> Self {
Self {
kind: EngineErrorKind::Transport,
status: None,
retry_after: None,
message: chain_text(err),
}
}
pub fn status(subject: &str, status: StatusCode, headers: &HeaderMap, body: &str) -> Self {
let detail: String = body.trim().chars().take(500).collect();
let message = if detail.is_empty() {
format!("{subject} returned status {status}")
} else {
format!("{subject} returned status {status}: {detail}")
};
Self {
kind: EngineErrorKind::Status,
status: Some(status.as_u16()),
retry_after: retry_after_from_headers(headers).or_else(|| retry_after_from_body(body)),
message,
}
}
pub fn is_transient(&self) -> bool {
match self.kind {
EngineErrorKind::Transport => true,
EngineErrorKind::Status => self.status.is_some_and(|s| RETRYABLE_STATUSES.contains(&s)),
}
}
}
pub async fn check_status(
subject: &str,
response: reqwest::Response,
) -> Result<reqwest::Response, EngineError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let headers = response.headers().clone();
let body = response.text().await.unwrap_or_default();
let err = EngineError::status(subject, status, &headers, &body);
tracing::warn!(
%subject,
%status,
retry_after = ?err.retry_after,
transient = err.is_transient(),
body = %err.message,
"engine returned an error status"
);
Err(err)
}
pub fn stream_error_transient(name: &str, status: Option<u16>) -> bool {
if status.is_some_and(|s| RETRYABLE_STATUSES.contains(&s)) {
return true;
}
let name = name.trim().to_ascii_lowercase();
TRANSIENT_ERROR_NAMES.contains(&name.as_str())
}
pub fn stream_error_text(name: &str, message: &str) -> String {
match (name.trim(), message.trim()) {
("", "") => "unknown engine error".to_string(),
("", msg) => msg.to_string(),
(name, "") => name.to_string(),
(name, msg) => format!("{name}: {msg}"),
}
}
pub fn chain_text(err: &dyn std::error::Error) -> String {
let mut out = err.to_string();
let mut cur = err.source();
while let Some(e) = cur {
let s = e.to_string();
if !out.contains(&s) {
out.push_str(": ");
out.push_str(&s);
}
cur = e.source();
}
out
}
fn retry_after_from_headers(headers: &HeaderMap) -> Option<Duration> {
if let Some(ms) = header_number(headers, "retry-after-ms") {
return Some(Duration::from_secs_f64(ms / 1000.0));
}
header_number(headers, "retry-after").map(Duration::from_secs_f64)
}
fn header_number(headers: &HeaderMap, name: &str) -> Option<f64> {
let raw = headers.get(name)?.to_str().ok()?;
let value: f64 = raw.trim().parse().ok()?;
(value.is_finite() && value >= 0.0).then_some(value)
}
fn retry_after_from_body(body: &str) -> Option<Duration> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let details = value.get("error")?.get("details")?.as_array()?;
details
.iter()
.find_map(|d| parse_proto_duration(d.get("retryDelay")?.as_str()?))
}
fn parse_proto_duration(raw: &str) -> Option<Duration> {
let secs: f64 = raw.trim().strip_suffix('s')?.parse().ok()?;
(secs.is_finite() && secs >= 0.0).then(|| Duration::from_secs_f64(secs))
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
#[test]
fn status_message_matches_the_pre_existing_wording() {
let err = EngineError::status(
SUBJECT_ENGINE,
StatusCode::TOO_MANY_REQUESTS,
&HeaderMap::new(),
" {\"error\":{\"message\":\"slow down\"}} ",
);
assert_eq!(
err.to_string(),
"engine returned status 429 Too Many Requests: {\"error\":{\"message\":\"slow down\"}}"
);
let bare = EngineError::status(
SUBJECT_ANTHROPIC,
StatusCode::BAD_GATEWAY,
&HeaderMap::new(),
" ",
);
assert_eq!(
bare.to_string(),
"engine (Anthropic) returned status 502 Bad Gateway"
);
}
#[test]
fn long_bodies_are_truncated_to_500_chars() {
let body = "x".repeat(900);
let err = EngineError::status(
SUBJECT_GEMINI,
StatusCode::INTERNAL_SERVER_ERROR,
&HeaderMap::new(),
&body,
);
assert_eq!(err.message.matches('x').count(), 500);
}
#[test]
fn transient_statuses_are_exactly_the_documented_set() {
for status in [408u16, 429, 500, 502, 503, 504, 529] {
let err = EngineError {
kind: EngineErrorKind::Status,
status: Some(status),
retry_after: None,
message: String::new(),
};
assert!(err.is_transient(), "{status} must be retryable");
}
for status in [400u16, 401, 402, 403, 404, 405, 413, 422] {
let err = EngineError {
kind: EngineErrorKind::Status,
status: Some(status),
retry_after: None,
message: String::new(),
};
assert!(!err.is_transient(), "{status} must not be retryable");
}
}
#[test]
fn transport_failures_are_always_transient() {
let err = EngineError {
kind: EngineErrorKind::Transport,
status: None,
retry_after: None,
message: String::new(),
};
assert!(err.is_transient());
}
#[test]
fn retry_after_seconds_header_is_read() {
let err = EngineError::status(
SUBJECT_ENGINE,
StatusCode::TOO_MANY_REQUESTS,
&headers(&[("retry-after", "7")]),
"",
);
assert_eq!(err.retry_after, Some(Duration::from_secs(7)));
}
#[test]
fn millisecond_header_wins_over_seconds() {
let err = EngineError::status(
SUBJECT_ENGINE,
StatusCode::TOO_MANY_REQUESTS,
&headers(&[("retry-after", "9"), ("retry-after-ms", "1500")]),
"",
);
assert_eq!(err.retry_after, Some(Duration::from_millis(1500)));
}
#[test]
fn http_date_retry_after_reads_as_no_hint() {
let err = EngineError::status(
SUBJECT_ENGINE,
StatusCode::TOO_MANY_REQUESTS,
&headers(&[("retry-after", "Wed, 12 Aug 2026 07:28:00 GMT")]),
"",
);
assert_eq!(err.retry_after, None);
}
#[test]
fn gemini_retry_info_in_the_body_is_read() {
let body = r#"{"error":{"code":429,"message":"quota","status":"RESOURCE_EXHAUSTED",
"details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure"},
{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"7.5s"}]}}"#;
let err = EngineError::status(
SUBJECT_GEMINI,
StatusCode::TOO_MANY_REQUESTS,
&HeaderMap::new(),
body,
);
assert_eq!(err.retry_after, Some(Duration::from_secs_f64(7.5)));
assert!(err.is_transient());
}
#[test]
fn a_body_without_retry_info_yields_no_hint() {
let err = EngineError::status(
SUBJECT_GEMINI,
StatusCode::BAD_REQUEST,
&HeaderMap::new(),
r#"{"error":{"code":400,"message":"bad","status":"INVALID_ARGUMENT"}}"#,
);
assert_eq!(err.retry_after, None);
assert!(!err.is_transient());
}
#[test]
fn stream_error_names_are_matched_exactly() {
assert!(stream_error_transient("overloaded_error", None));
assert!(stream_error_transient(" API_Error ", None));
assert!(stream_error_transient("UNAVAILABLE", None));
assert!(!stream_error_transient("exceed_context_size_error", None));
assert!(!stream_error_transient("invalid_request_error", None));
assert!(!stream_error_transient("", None));
assert!(!stream_error_transient(
&stream_error_text("overloaded_error", "Overloaded"),
None
));
assert!(stream_error_transient("weird", Some(503)));
assert!(!stream_error_transient("weird", Some(400)));
}
#[test]
fn stream_error_text_always_says_something() {
assert_eq!(
stream_error_text("overloaded_error", ""),
"overloaded_error"
);
assert_eq!(stream_error_text("", "Overloaded"), "Overloaded");
assert_eq!(
stream_error_text("overloaded_error", "Overloaded"),
"overloaded_error: Overloaded"
);
assert_eq!(stream_error_text(" ", " "), "unknown engine error");
}
#[test]
fn chain_text_appends_causes_without_repeating_them() {
#[derive(Debug)]
struct Inner;
impl std::fmt::Display for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "connection refused")
}
}
impl std::error::Error for Inner {}
#[derive(Debug)]
struct Outer(Inner, &'static str);
impl std::fmt::Display for Outer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.1)
}
}
impl std::error::Error for Outer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
assert_eq!(
chain_text(&Outer(Inner, "error sending request")),
"error sending request: connection refused"
);
assert_eq!(
chain_text(&Outer(Inner, "failed: connection refused")),
"failed: connection refused"
);
}
}