use std::time::Duration;
const MAX_HINT: Duration = Duration::from_hours(24);
#[cfg(any(feature = "gemini", feature = "vertex"))]
pub fn google_retry_delay(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()
.filter(|detail| {
detail
.get("@type")
.and_then(serde_json::Value::as_str)
.is_some_and(|type_url| type_url.ends_with("google.rpc.RetryInfo"))
})
.find_map(|detail| {
let delay = detail.get("retryDelay")?.as_str()?;
parse_proto_duration(delay)
})
}
#[cfg(any(feature = "openai", feature = "openai-codex"))]
pub fn openai_retry_delay(body: &str) -> Option<Duration> {
const MARKER: &str = "try again in ";
let message = match serde_json::from_str::<serde_json::Value>(body) {
Ok(value) => Some(value.get("error")?.get("message")?.as_str()?.to_owned()),
Err(_) => None,
};
let haystack = message.as_deref().unwrap_or(body);
let start = haystack.to_ascii_lowercase().find(MARKER)? + MARKER.len();
let mut words = haystack[start..].split_whitespace();
let value = trim_trailing_punctuation(words.next()?);
if let Some(delay) = parse_unit_duration(value) {
return Some(delay);
}
let unit = trim_trailing_punctuation(words.next()?).to_ascii_lowercase();
let delay = duration_from_secs(parse_non_negative(value)? * unit_word_scale(&unit)?)?;
(delay > Duration::ZERO).then_some(delay)
}
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn trim_trailing_punctuation(token: &str) -> &str {
token.trim_end_matches(['.', ',', ';', ':', ')', '"', '\''])
}
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn unit_word_scale(unit: &str) -> Option<f64> {
match unit {
"ms" | "msec" | "msecs" | "millisecond" | "milliseconds" => Some(0.001),
"s" | "sec" | "secs" | "second" | "seconds" => Some(1.0),
"m" | "min" | "mins" | "minute" | "minutes" => Some(60.0),
"h" | "hr" | "hrs" | "hour" | "hours" => Some(3600.0),
_ => None,
}
}
#[cfg(any(feature = "gemini", feature = "vertex"))]
fn parse_proto_duration(value: &str) -> Option<Duration> {
let seconds = value.trim().strip_suffix('s')?;
let delay = duration_from_secs(parse_non_negative(seconds)?)?;
(delay > Duration::ZERO).then_some(delay)
}
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn parse_unit_duration(token: &str) -> Option<Duration> {
let mut rest = token;
let mut total = Duration::ZERO;
while !rest.is_empty() {
let unit_start = rest.find(|c: char| !c.is_ascii_digit() && c != '.')?;
let (number, tail) = rest.split_at(unit_start);
let value = parse_non_negative(number)?;
let (unit_len, seconds_per_unit) = if tail.starts_with("ms") {
(2, 0.001)
} else if tail.starts_with('s') {
(1, 1.0)
} else if tail.starts_with('m') {
(1, 60.0)
} else if tail.starts_with('h') {
(1, 3600.0)
} else {
return None;
};
total = total.checked_add(duration_from_secs(value * seconds_per_unit)?)?;
rest = &tail[unit_len..];
}
(total > Duration::ZERO).then_some(total.min(MAX_HINT))
}
fn parse_non_negative(value: &str) -> Option<f64> {
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
return None;
}
let parsed: f64 = value.parse().ok()?;
parsed.is_finite().then_some(parsed)
}
fn duration_from_secs(seconds: f64) -> Option<Duration> {
Duration::try_from_secs_f64(seconds)
.ok()
.map(|delay| delay.min(MAX_HINT))
}
#[cfg(feature = "openai-codex")]
pub fn bounded_delay(seconds: f64) -> Option<Duration> {
if !seconds.is_finite() || seconds <= 0.0 {
return None;
}
duration_from_secs(seconds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(any(feature = "gemini", feature = "vertex"))]
fn google_retry_info_whole_and_fractional_seconds() {
let body = r#"{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[
{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaValue":"60"}]},
{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"30s"}
]}}"#;
assert_eq!(google_retry_delay(body), Some(Duration::from_secs(30)));
let fractional = r#"{"error":{"details":[
{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"3.5s"}
]}}"#;
assert_eq!(
google_retry_delay(fractional),
Some(Duration::from_millis(3500))
);
}
#[test]
#[cfg(any(feature = "gemini", feature = "vertex"))]
fn google_retry_info_absent_or_malformed_is_none() {
assert_eq!(
google_retry_delay(r#"{"error":{"code":429,"message":"quota exceeded"}}"#),
None
);
assert_eq!(
google_retry_delay(
r#"{"error":{"details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaValue":"60"}]}]}}"#
),
None
);
assert_eq!(
google_retry_delay(
r#"{"error":{"details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"soon"}]}}"#
),
None
);
assert_eq!(
google_retry_delay(
r#"{"error":{"details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"30"}]}}"#
),
None
);
assert_eq!(
google_retry_delay(
r#"{"error":{"details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"1e18s"}]}}"#
),
None
);
assert_eq!(google_retry_delay("429 Too Many Requests"), None);
assert_eq!(google_retry_delay(""), None);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn openai_message_seconds_millis_and_fractions() {
let body = r#"{"error":{"message":"Rate limit reached for gpt-5 in organization org-4bqm on requests per min (RPM): Limit 3, Used 3. Please try again in 20s.","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#;
assert_eq!(openai_retry_delay(body), Some(Duration::from_secs(20)));
let fractional = r#"{"error":{"message":"Please try again in 1.5s"}}"#;
assert_eq!(
openai_retry_delay(fractional),
Some(Duration::from_millis(1500))
);
let millis = r#"{"error":{"message":"Please try again in 250ms."}}"#;
assert_eq!(openai_retry_delay(millis), Some(Duration::from_millis(250)));
let composite = r#"{"error":{"message":"Please try again in 6m0s."}}"#;
assert_eq!(openai_retry_delay(composite), Some(Duration::from_mins(6)));
assert_eq!(
openai_retry_delay("Rate limited. Please try again in 2m30s"),
Some(Duration::from_secs(150))
);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn openai_message_accepts_a_separated_unit_word() {
let azure = r#"{"error":{"code":"429","message":"Requests to the ChatCompletions_Create Operation under Azure OpenAI API version 2024-02-01 have exceeded token rate limit. Try again in 35 seconds."}}"#;
assert_eq!(openai_retry_delay(azure), Some(Duration::from_secs(35)));
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"try again in 250 ms"}}"#),
Some(Duration::from_millis(250))
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Try again in 1 second."}}"#),
Some(Duration::from_secs(1))
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Try again in 2 minutes."}}"#),
Some(Duration::from_mins(2))
);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn openai_valid_json_without_an_error_message_is_none() {
assert_eq!(
openai_retry_delay(
r#"{"error":{"code":"rate_limit_exceeded","metadata":{"raw":"upstream said: try again in 6h"}}}"#
),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":{"text":"try again in 6h"}}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"detail":"please try again in 6h"}"#),
None
);
assert_eq!(
openai_retry_delay("Rate limited. Please try again in 6s."),
Some(Duration::from_secs(6))
);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn openai_message_with_a_unitless_value_is_none() {
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"try again in 35"}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Try again in 35 requests from now."}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Try again in 0 seconds."}}"#),
None
);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn openai_message_without_a_hint_is_none() {
assert_eq!(
openai_retry_delay(
r#"{"error":{"message":"Rate limit reached for gpt-5: Limit 3, Used 3."}}"#
),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Please try again in 20"}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Please try again in a while."}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Please try again in 5d."}}"#),
None
);
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Please try again in 0s."}}"#),
None
);
assert_eq!(openai_retry_delay(""), None);
}
#[test]
#[cfg(any(feature = "gemini", feature = "vertex"))]
fn absurd_google_hints_are_bounded() {
assert_eq!(
google_retry_delay(
r#"{"error":{"details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"999999999999s"}]}}"#
),
Some(MAX_HINT)
);
}
#[test]
#[cfg(any(feature = "openai", feature = "openai-codex"))]
fn absurd_openai_hints_are_bounded() {
assert_eq!(
openai_retry_delay(r#"{"error":{"message":"Please try again in 999999h."}}"#),
Some(MAX_HINT)
);
}
}