1use std::time::Duration;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ProviderError {
28 RateLimit { retry_after: Option<Duration> },
31 Overloaded,
33 ServerError,
35 Auth,
37 Billing,
39 ContextOverflow,
43 Invalid(String),
45 Transport,
48}
49
50impl std::fmt::Display for ProviderError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 ProviderError::RateLimit {
54 retry_after: Some(d),
55 } => {
56 write!(f, "rate limited (retry after {}s)", d.as_secs())
57 }
58 ProviderError::RateLimit { retry_after: None } => write!(f, "rate limited"),
59 ProviderError::Overloaded => write!(f, "provider overloaded"),
60 ProviderError::ServerError => write!(f, "provider server error"),
61 ProviderError::Auth => write!(f, "authentication failed"),
62 ProviderError::Billing => write!(f, "billing/credit failure"),
63 ProviderError::ContextOverflow => write!(f, "prompt exceeds the context window"),
64 ProviderError::Invalid(detail) => write!(f, "invalid request: {detail}"),
65 ProviderError::Transport => write!(f, "transport failure"),
66 }
67 }
68}
69
70impl std::error::Error for ProviderError {}
71
72impl ProviderError {
73 pub fn transient(&self) -> bool {
78 matches!(
79 self,
80 ProviderError::RateLimit { .. }
81 | ProviderError::Overloaded
82 | ProviderError::ServerError
83 | ProviderError::Transport
84 )
85 }
86}
87
88pub fn overflow_text(text: &str) -> bool {
93 let t = text.to_ascii_lowercase();
94 t.contains("exceed_context_size")
95 || t.contains("context_length_exceeded")
96 || t.contains("context length")
97 || t.contains("context size")
98 || t.contains("prompt is too long")
99 || t.contains("too many tokens")
100 || t.contains("maximum context")
101}
102
103pub fn classify_http(status: u16, body: &str, retry_after: Option<Duration>) -> ProviderError {
105 let lower = body.to_ascii_lowercase();
106 match status {
107 401 | 403 => ProviderError::Auth,
108 402 => ProviderError::Billing,
109 429 => ProviderError::RateLimit { retry_after },
110 529 => ProviderError::Overloaded,
111 503 if lower.contains("overload") => ProviderError::Overloaded,
112 _ if overflow_text(body) => ProviderError::ContextOverflow,
117 s if s >= 500 => ProviderError::ServerError,
118 _ if lower.contains("credit balance") || lower.contains("billing") => {
119 ProviderError::Billing
120 }
121 _ => ProviderError::Invalid(body.chars().take(200).collect()),
122 }
123}
124
125#[derive(Debug, Clone)]
127pub struct RetryPolicy {
128 pub max_retries: u32,
130 pub retry_after_cap: Duration,
133 pub base_delay: Duration,
135}
136
137impl Default for RetryPolicy {
138 fn default() -> Self {
139 RetryPolicy {
140 max_retries: 3,
141 retry_after_cap: Duration::from_secs(60),
142 base_delay: Duration::from_millis(2_500),
143 }
144 }
145}
146
147impl RetryPolicy {
148 pub const MAX_DELAY: Duration = Duration::from_secs(30);
149
150 pub fn from_config(cfg: &crate::config::ProviderConfig) -> Self {
151 let d = RetryPolicy::default();
152 RetryPolicy {
153 max_retries: cfg.max_retries.unwrap_or(d.max_retries),
154 retry_after_cap: cfg
155 .retry_after_cap_secs
156 .map(Duration::from_secs)
157 .unwrap_or(d.retry_after_cap),
158 base_delay: d.base_delay,
159 }
160 }
161
162 pub fn delay_for(&self, error: &ProviderError, attempt: u32) -> Option<Duration> {
166 if attempt > self.max_retries || !error.transient() {
167 return None;
168 }
169 match error {
170 ProviderError::RateLimit {
171 retry_after: Some(after),
172 } => {
173 (*after <= self.retry_after_cap).then_some(*after)
176 }
177 _ => {
178 let exp = self
179 .base_delay
180 .saturating_mul(1u32 << (attempt - 1).min(16));
181 Some(exp.min(Self::MAX_DELAY))
182 }
183 }
184 }
185}
186
187#[derive(Debug)]
194pub struct RequestFailure {
195 pub class: ProviderError,
196 pub status: Option<u16>,
198 pub detail: String,
200}
201
202pub async fn send_with_retry(
207 make_request: impl Fn() -> reqwest::RequestBuilder,
208 policy: &RetryPolicy,
209) -> Result<reqwest::Response, RequestFailure> {
210 let mut attempt = 0u32;
211 loop {
212 let failure = match make_request().send().await {
213 Ok(resp) if resp.status().is_success() => return Ok(resp),
214 Ok(resp) => {
215 let status = resp.status().as_u16();
216 let retry_after = resp
217 .headers()
218 .get(reqwest::header::RETRY_AFTER)
219 .and_then(|v| v.to_str().ok())
220 .and_then(|s| s.trim().parse::<u64>().ok())
221 .map(Duration::from_secs);
222 let body = resp.text().await.unwrap_or_default();
223 RequestFailure {
224 class: classify_http(status, &body, retry_after),
225 status: Some(status),
226 detail: body,
227 }
228 }
229 Err(e) => RequestFailure {
230 class: ProviderError::Transport,
231 status: None,
232 detail: e.to_string(),
233 },
234 };
235
236 attempt += 1;
237 match policy.delay_for(&failure.class, attempt) {
238 Some(delay) => {
239 tracing::warn!(
240 error = %failure.class,
241 attempt,
242 delay_ms = delay.as_millis() as u64,
243 "provider request failed; retrying"
244 );
245 tokio::time::sleep(delay).await;
246 }
247 None => return Err(failure),
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn each_class_gets_its_policy() {
258 let p = RetryPolicy {
259 base_delay: Duration::from_millis(10),
260 ..Default::default()
261 };
262
263 for err in [
265 ProviderError::Overloaded,
266 ProviderError::ServerError,
267 ProviderError::Transport,
268 ] {
269 assert_eq!(p.delay_for(&err, 1), Some(Duration::from_millis(10)));
270 assert_eq!(p.delay_for(&err, 2), Some(Duration::from_millis(20)));
271 assert_eq!(p.delay_for(&err, 4), None, "exhausted past max_retries");
272 }
273
274 for err in [
277 ProviderError::Auth,
278 ProviderError::Billing,
279 ProviderError::Invalid("x".into()),
280 ProviderError::ContextOverflow,
281 ] {
282 assert_eq!(p.delay_for(&err, 1), None);
283 }
284 }
285
286 #[test]
287 fn retry_after_is_honoured_when_sane_and_a_failure_when_hostile() {
288 let p = RetryPolicy::default();
289 let soon = ProviderError::RateLimit {
290 retry_after: Some(Duration::from_secs(3)),
291 };
292 assert_eq!(p.delay_for(&soon, 1), Some(Duration::from_secs(3)));
293
294 let hostile = ProviderError::RateLimit {
297 retry_after: Some(Duration::from_secs(3_600)),
298 };
299 assert_eq!(p.delay_for(&hostile, 1), None);
300
301 let unstated = ProviderError::RateLimit { retry_after: None };
302 assert_eq!(p.delay_for(&unstated, 1), Some(p.base_delay));
303 }
304
305 #[test]
306 fn zero_max_retries_disables_retrying() {
307 let p = RetryPolicy {
308 max_retries: 0,
309 ..Default::default()
310 };
311 assert_eq!(p.delay_for(&ProviderError::Transport, 1), None);
312 }
313
314 #[test]
315 fn the_backoff_never_exceeds_the_ceiling() {
316 let p = RetryPolicy {
317 max_retries: 40,
318 ..Default::default()
319 };
320 assert_eq!(
321 p.delay_for(&ProviderError::Transport, 39),
322 Some(RetryPolicy::MAX_DELAY)
323 );
324 }
325
326 #[test]
327 fn classification_reads_status_and_text() {
328 use ProviderError::*;
329 assert_eq!(classify_http(401, "", None), Auth);
330 assert_eq!(classify_http(403, "", None), Auth);
331 assert_eq!(
332 classify_http(429, "", Some(Duration::from_secs(2))),
333 RateLimit {
334 retry_after: Some(Duration::from_secs(2))
335 }
336 );
337 assert_eq!(classify_http(529, "", None), Overloaded);
338 assert_eq!(
339 classify_http(503, "The server is overloaded", None),
340 Overloaded
341 );
342 assert_eq!(classify_http(500, "", None), ServerError);
343 assert_eq!(classify_http(503, "", None), ServerError);
344
345 assert_eq!(
347 classify_http(400, r#"{"type":"exceed_context_size_error"}"#, None),
348 ContextOverflow
349 );
350 assert_eq!(
354 classify_http(500, "Context size has been exceeded.", None),
355 ContextOverflow
356 );
357 assert_eq!(
358 classify_http(400, "Your credit balance is too low", None),
359 Billing
360 );
361 assert!(matches!(classify_http(400, "bad json", None), Invalid(_)));
362 }
363}