返回 CodeWhale
mod.rs
根目录 / crates / tui / src / llm_client / mod.rs
1 //! LLM Client Trait and Retry Logic
2 //!
3 //! This module provides a unified interface for LLM providers with robust retry logic,
4 //! exponential backoff, and proper error classification.
5 //!
6 //! # Architecture
7 //!
8 //! - `LlmClient` trait: Async interface for LLM providers (DeepSeek, `OpenAI`, etc.)
9 //! - `RetryConfig`: Configurable retry behavior with exponential backoff and jitter
10 //! - `LlmError`: Classified errors with retryability information
11
12 //! - `with_retry`: Generic retry wrapper for any async operation
13 //!
14 //! # Example
15 //!
16 //! ```ignore
17 //! use crate::llm_client::{LlmClient, RetryConfig, with_retry};
18 //!
19 //! let config = RetryConfig::default();
20 //! let result = with_retry(&config, || async {
21 //! client.create_message(request).await
22 //! }, None).await;
23 //! ```
24
25 use crate::config::RetryPolicy;
26 use anyhow::Result;
27 use codewhale_models::{MessageRequest, MessageResponse, StreamEvent};
28 use serde_json::Value;
29 use std::future::Future;
30 use std::pin::Pin;
31 use std::time::{Duration, Instant};
32 use uuid::Uuid;
33
34 #[cfg(test)]
35 pub mod mock;
36
37 // === LlmClient Trait ===
38
39 /// Type alias for boxed stream of SSE events
40 pub type StreamEventBox =
41 Pin<Box<dyn futures_util::Stream<Item = Result<StreamEvent>> + Send + 'static>>;
42
43 /// Unified interface for LLM providers.
44 ///
45 /// This trait abstracts over different LLM APIs (DeepSeek, `OpenAI`, etc.)
46 /// allowing the agent to work with any provider that implements this interface.
47 ///
48 /// # Implementation Notes
49 ///
50 /// - All methods are async and require `Send + Sync` for thread safety
51 /// - The `create_message_stream` method returns a pinned boxed stream for SSE
52 /// - Implementations should handle their own authentication and base URL configuration
53 #[allow(async_fn_in_trait, dead_code)] // Trait methods are part of the LLM provider interface
54 pub trait LlmClient: Send + Sync {
55 /// Returns the provider name (e.g., "openai", "deepseek")
56 fn provider_name(&self) -> &'static str;
57
58 /// Returns the model identifier being used
59 fn model(&self) -> &str;
60
61 /// Creates a non-streaming message completion
62 fn create_message(
63 &self,
64 request: MessageRequest,
65 ) -> impl Future<Output = Result<MessageResponse>> + Send;
66
67 /// Dispatch a fresh request. Clients with a local response cache must
68 /// override this; authorization decisions cannot reuse earlier answers.
69 fn create_message_uncached(
70 &self,
71 request: MessageRequest,
72 ) -> impl Future<Output = Result<MessageResponse>> + Send {
73 self.create_message(request)
74 }
75
76 /// Creates a streaming message completion
77 ///
78 /// Returns a stream of SSE events that should be consumed until completion.
79 fn create_message_stream(
80 &self,
81 request: MessageRequest,
82 ) -> impl Future<Output = Result<StreamEventBox>> + Send;
83
84 /// Optional health check to verify API connectivity
85 fn health_check(&self) -> impl Future<Output = Result<bool>> + Send {
86 async { Ok(true) }
87 }
88
89 /// The concrete base URL requests go to, when the implementation knows it.
90 ///
91 /// Background cost accrual uses this for billing provenance only: it is
92 /// reduced to a non-secret surface classification and a SHA-256 fingerprint
93 /// before being recorded, and the URL itself is never persisted or logged
94 /// (#4318). The default is `None` so an implementation that cannot report a
95 /// stable endpoint yields "unknown endpoint" — which fails closed — rather
96 /// than being assumed to be the provider's public API.
97 fn billing_base_url(&self) -> Option<&str> {
98 None
99 }
100
101 /// Non-secret limits frozen with the resolved route, when available.
102 fn route_limits(&self) -> Option<codewhale_config::route::RouteLimits> {
103 None
104 }
105
106 /// Output cap for a request sent through this exact client route.
107 fn effective_max_output_tokens(&self, requested_model: &str) -> u32 {
108 let route = self.effective_route_envelope(requested_model, chrono::Utc::now());
109 crate::route_budget::effective_max_output_tokens_for_route(
110 route.provider,
111 &route.model,
112 self.route_limits(),
113 )
114 }
115
116 /// Freeze the non-secret effective route immediately before a request is
117 /// dispatched. Implementations with richer configured identity/billing
118 /// facts should override this fail-closed default.
119 fn effective_route_envelope(
120 &self,
121 requested_model: &str,
122 dispatched_at: chrono::DateTime<chrono::Utc>,
123 ) -> crate::cost_status::EffectiveRouteEnvelope {
124 let provider = crate::config::ApiProvider::parse(self.provider_name())
125 .unwrap_or(crate::config::ApiProvider::Custom);
126 crate::cost_status::EffectiveRouteEnvelope::capture(
127 None,
128 provider,
129 self.provider_name(),
130 requested_model,
131 self.billing_base_url(),
132 dispatched_at,
133 )
134 }
135 }
136
137 // === Authentication diagnostics ===
138
139 #[derive(Debug, Clone, PartialEq, Eq, Default)]
140 pub struct AuthenticationErrorContext {
141 pub provider: Option<String>,
142 pub base_url_authority: Option<String>,
143 pub model: Option<String>,
144 pub key_source: Option<String>,
145 pub key_fingerprint: Option<String>,
146 pub key_kind: Option<String>,
147 }
148
149 impl AuthenticationErrorContext {
150 #[must_use]
151 pub fn new(
152 provider: &str,
153 base_url: &str,
154 model: &str,
155 key_source: &str,
156 api_key: &str,
157 ) -> Self {
158 Self::from_parts(
159 Some(provider),
160 Some(base_url),
161 Some(model),
162 Some(key_source),
163 Some(api_key),
164 )
165 }
166
167 #[must_use]
168 pub fn from_parts(
169 provider: Option<&str>,
170 base_url: Option<&str>,
171 model: Option<&str>,
172 key_source: Option<&str>,
173 api_key: Option<&str>,
174 ) -> Self {
175 let api_key = api_key.and_then(non_empty_trimmed);
176 Self {
177 provider: provider.and_then(non_empty_trimmed).map(str::to_string),
178 base_url_authority: base_url.and_then(base_url_authority),
179 model: model.and_then(non_empty_trimmed).map(str::to_string),
180 key_source: key_source.and_then(non_empty_trimmed).map(str::to_string),
181 key_fingerprint: api_key.map(redacted_key_fingerprint),
182 key_kind: api_key.map(classify_api_key_prefix).map(str::to_string),
183 }
184 }
185
186 fn is_empty(&self) -> bool {
187 self.provider.is_none()
188 && self.base_url_authority.is_none()
189 && self.model.is_none()
190 && self.key_source.is_none()
191 && self.key_fingerprint.is_none()
192 && self.key_kind.is_none()
193 }
194
195 fn detail_segments(&self) -> Vec<String> {
196 let mut segments = Vec::new();
197 if let Some(provider) = self.provider.as_deref() {
198 segments.push(format!("provider: {provider}"));
199 }
200 if let Some(authority) = self.base_url_authority.as_deref() {
201 segments.push(format!("base URL authority: {authority}"));
202 }
203 if let Some(model) = self.model.as_deref() {
204 segments.push(format!("model: {model}"));
205 }
206 if let Some(source) = self.key_source.as_deref() {
207 segments.push(format!("key source: {source}"));
208 }
209 if let Some(fingerprint) = self.key_fingerprint.as_deref() {
210 segments.push(format!("key fingerprint: {fingerprint}"));
211 }
212 if let Some(kind) = self.key_kind.as_deref() {
213 segments.push(format!("key type: {kind}"));
214 }
215 segments
216 }
217 }
218
219 #[derive(Debug, Clone, PartialEq, Eq)]
220 pub struct AuthenticationErrorDetail {
221 message: String,
222 context: Option<AuthenticationErrorContext>,
223 }
224
225 impl AuthenticationErrorDetail {
226 #[must_use]
227 pub fn new(message: impl Into<String>) -> Self {
228 Self {
229 message: message.into(),
230 context: None,
231 }
232 }
233
234 #[must_use]
235 pub fn with_context(
236 message: impl Into<String>,
237 context: Option<AuthenticationErrorContext>,
238 ) -> Self {
239 let context = context.filter(|context| !context.is_empty());
240 Self {
241 message: message.into(),
242 context,
243 }
244 }
245
246 #[must_use]
247 pub fn message(&self) -> &str {
248 &self.message
249 }
250
251 #[must_use]
252 pub fn to_user_message(&self) -> String {
253 let Some(context) = self.context.as_ref() else {
254 return self.message.clone();
255 };
256 let segments = context.detail_segments();
257 if segments.is_empty() {
258 self.message.clone()
259 } else {
260 format!("{} ({})", self.message, segments.join(", "))
261 }
262 }
263 }
264
265 impl From<String> for AuthenticationErrorDetail {
266 fn from(message: String) -> Self {
267 Self::new(message)
268 }
269 }
270
271 impl From<&str> for AuthenticationErrorDetail {
272 fn from(message: &str) -> Self {
273 Self::new(message)
274 }
275 }
276
277 #[must_use]
278 pub fn classify_api_key_prefix(api_key: &str) -> &'static str {
279 if api_key.starts_with("tp-") {
280 "Xiaomi MiMo Token Plan key"
281 } else {
282 "API key"
283 }
284 }
285
286 fn non_empty_trimmed(value: &str) -> Option<&str> {
287 let value = value.trim();
288 if value.is_empty() { None } else { Some(value) }
289 }
290
291 fn base_url_authority(base_url: &str) -> Option<String> {
292 let base_url = non_empty_trimmed(base_url)?;
293 let without_scheme = base_url
294 .split_once("://")
295 .map_or(base_url, |(_, rest)| rest);
296 let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
297 let authority = authority
298 .rsplit_once('@')
299 .map_or(authority, |(_, authority)| authority);
300 non_empty_trimmed(authority).map(str::to_string)
301 }
302
303 fn redacted_key_fingerprint(api_key: &str) -> String {
304 let api_key = api_key.trim();
305 let len = api_key.chars().count();
306 match public_key_prefix(api_key) {
307 Some(prefix) => format!("{prefix}... (len={len})"),
308 None => format!("unprefixed (len={len})"),
309 }
310 }
311
312 fn public_key_prefix(api_key: &str) -> Option<&str> {
313 ["tp-", "sk-", "hf_", "hf-", "ak-", "rk-"]
314 .into_iter()
315 .find(|prefix| api_key.starts_with(prefix))
316 }
317
318 fn redact_api_key_from_message(message: &str, api_key: Option<&str>) -> String {
319 let Some(api_key) = api_key.and_then(non_empty_trimmed) else {
320 return message.to_string();
321 };
322 message.replace(api_key, "[redacted API key]")
323 }
324
325 // === LlmError - Classified Error Types ===
326
327 /// Evidence captured when an HTTP response explicitly identifies plan quota
328 /// exhaustion. The private field prevents callers outside this parser module
329 /// from manufacturing the durable classification from arbitrary text.
330 #[derive(Debug)]
331 pub struct QuotaExhaustionError {
332 message: String,
333 }
334
335 impl QuotaExhaustionError {
336 fn from_http_message(message: String) -> Self {
337 Self { message }
338 }
339
340 pub(crate) fn into_message(self) -> String {
341 self.message
342 }
343 }
344
345 /// Classified LLM errors with retryability information.
346 ///
347 /// This enum categorizes API errors to enable smart retry decisions.
348 /// Some errors (rate limits, transient server errors) are retryable,
349 /// while others (auth failures, invalid requests) should fail immediately.
350 #[derive(Debug)]
351 pub enum LlmError {
352 /// Rate limit exceeded (HTTP 429)
353 /// Contains optional Retry-After duration from server
354 RateLimited {
355 message: String,
356 retry_after: Option<Duration>,
357 },
358
359 /// The provider explicitly reported that the account's plan quota is exhausted.
360 ///
361 /// Unlike an ordinary 429 rate limit, retrying the same request after a short
362 /// backoff cannot resolve this condition. This variant is constructed only at
363 /// the provider HTTP response boundary from explicit quota evidence.
364 QuotaExhausted(QuotaExhaustionError),
365
366 /// Server error (HTTP 5xx)
367 ServerError { status: u16, message: String },
368
369 /// Network connectivity error
370 NetworkError(String),
371
372 /// Request timed out
373 Timeout(Duration),
374
375 /// Authentication failed (HTTP 401, selected HTTP 403)
376 AuthenticationError(AuthenticationErrorDetail),
377
378 /// Authorization or provider-side blocking failed (HTTP 403)
379 AuthorizationError(String),
380
381 /// Invalid request parameters (HTTP 400)
382 InvalidRequest { status: u16, message: String },
383
384 /// Model-specific error (model not found, etc.)
385 ModelError(String),
386
387 /// Content policy violation (safety filters)
388 ContentPolicyError(String),
389
390 /// Failed to parse API response
391 ParseError(String),
392
393 /// Context length exceeded
394 ContextLengthError(String),
395
396 /// Catch-all for other errors
397 Other(String),
398 }
399
400 impl std::fmt::Display for LlmError {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 match self {
403 LlmError::RateLimited { message, .. } => write!(f, "Rate limit exceeded: {message}"),
404 LlmError::QuotaExhausted(error) => {
405 write!(f, "Provider plan quota exhausted: {}", error.message)
406 }
407 LlmError::ServerError { status, message } => {
408 write!(f, "Server error ({status}): {message}")
409 }
410 LlmError::NetworkError(msg) => write!(f, "Network error: {msg}"),
411 LlmError::Timeout(d) => write!(f, "Request timed out after {d:?}"),
412 LlmError::AuthenticationError(auth) => {
413 write!(f, "Authentication failed: {}", auth.to_user_message())
414 }
415 LlmError::AuthorizationError(msg) => write!(f, "Authorization failed: {msg}"),
416 LlmError::InvalidRequest { status, message } => {
417 write!(f, "Invalid request ({status}): {message}")
418 }
419 LlmError::ModelError(msg) => write!(f, "Model error: {msg}"),
420 LlmError::ContentPolicyError(msg) => write!(f, "Content policy violation: {msg}"),
421 LlmError::ParseError(msg) => write!(f, "Response parsing error: {msg}"),
422 LlmError::ContextLengthError(msg) => write!(f, "Context length exceeded: {msg}"),
423 LlmError::Other(msg) => write!(f, "LLM error: {msg}"),
424 }
425 }
426 }
427
428 impl std::error::Error for LlmError {}
429
430 impl LlmError {
431 /// Determines if this error is potentially transient and worth retrying.
432 ///
433 /// Retryable errors:
434 /// - Rate limits (with backoff)
435 /// - Server errors (5xx)
436 /// - Network errors (connection issues)
437 /// - Timeouts
438 ///
439 /// Non-retryable errors:
440 /// - Provider plan quota exhaustion
441 /// - Authentication failures
442 /// - Invalid requests
443 /// - Content policy violations
444 /// - Context length errors
445 pub fn is_retryable(&self) -> bool {
446 matches!(
447 self,
448 LlmError::RateLimited { .. }
449 | LlmError::ServerError { .. }
450 | LlmError::NetworkError(_)
451 | LlmError::Timeout(_)
452 )
453 }
454
455 /// Returns the server-suggested retry delay if available.
456 ///
457 /// This is typically present for rate limit errors when the server
458 /// provides a Retry-After header.
459 pub fn suggested_retry_delay(&self) -> Option<Duration> {
460 match self {
461 LlmError::RateLimited { retry_after, .. } => *retry_after,
462 _ => None,
463 }
464 }
465
466 /// Constructs an `LlmError` from HTTP status code and response body.
467 ///
468 /// Performs heuristic classification based on:
469 /// - Status code (429 = rate limit, 401/403 = auth, 499/5xx = transient upstream error)
470 /// - Response body keywords (`context_length`, `content_policy`, safety, etc.)
471 pub fn from_http_response(status: u16, body: &str) -> Self {
472 if matches!(status, 400 | 402 | 429) && has_explicit_quota_evidence(body) {
473 return LlmError::QuotaExhausted(QuotaExhaustionError::from_http_message(
474 body.to_string(),
475 ));
476 }
477
478 match status {
479 429 => LlmError::RateLimited {
480 message: body.to_string(),
481 retry_after: None,
482 },
483 401 => Self::authentication_error(body),
484 403 => {
485 if looks_like_authentication_failure(body) {
486 Self::authentication_error(body)
487 } else {
488 LlmError::AuthorizationError(body.to_string())
489 }
490 }
491 400 => {
492 // Classify 400 errors by examining the response body
493 let body_lower = body.to_lowercase();
494 // An "unsupported parameter" 400 names the offending field
495 // (often `max_output_tokens` or another *token* field), which
496 // the generic keyword rules below would misread as a context
497 // window overflow. Parameter shape errors are invalid
498 // requests, not prompt-size errors, so they get their own
499 // branch ahead of the heuristic.
500 if body_lower.contains("unsupported parameter")
501 || body_lower.contains("invalid_request_error")
502 && body_lower.contains("parameter")
503 {
504 LlmError::InvalidRequest {
505 status,
506 message: body.to_string(),
507 }
508 } else if body_lower.contains("context_length")
509 || body_lower.contains("token")
510 || body_lower.contains("too long")
511 || body_lower.contains("maximum")
512 {
513 LlmError::ContextLengthError(body.to_string())
514 } else if body_lower.contains("content_policy")
515 || body_lower.contains("safety")
516 || body_lower.contains("harmful")
517 || body_lower.contains("inappropriate")
518 {
519 LlmError::ContentPolicyError(body.to_string())
520 } else if body_lower.contains("model") && body_lower.contains("not found") {
521 LlmError::ModelError(body.to_string())
522 } else {
523 LlmError::InvalidRequest {
524 status,
525 message: body.to_string(),
526 }
527 }
528 }
529 404 => {
530 if body.to_lowercase().contains("model") {
531 LlmError::ModelError(body.to_string())
532 } else {
533 LlmError::InvalidRequest {
534 status,
535 message: body.to_string(),
536 }
537 }
538 }
539 // Several OpenAI-compatible gateways use nginx's non-standard
540 // 499 for an upstream request that was cancelled before response
541 // streaming began. At this boundary no response body stream has
542 // been exposed, so it is eligible for the same bounded retry
543 // policy as a 5xx gateway failure.
544 499..=599 => LlmError::ServerError {
545 status,
546 message: body.to_string(),
547 },
548 _ => LlmError::Other(format!("HTTP {status}: {body}")),
549 }
550 }
551
552 #[must_use]
553 pub fn authentication_error(message: impl Into<String>) -> Self {
554 LlmError::AuthenticationError(AuthenticationErrorDetail::new(message))
555 }
556
557 #[must_use]
558 pub fn authentication_error_with_context(
559 message: impl Into<String>,
560 context: Option<AuthenticationErrorContext>,
561 ) -> Self {
562 LlmError::AuthenticationError(AuthenticationErrorDetail::with_context(message, context))
563 }
564
565 /// Constructs an `LlmError` from HTTP response data plus request context
566 /// that is safe to display when authentication fails.
567 #[must_use]
568 pub fn from_http_response_with_request_context(
569 status: u16,
570 body: &str,
571 provider: Option<&str>,
572 base_url: Option<&str>,
573 model: Option<&str>,
574 key_source: Option<&str>,
575 api_key: Option<&str>,
576 ) -> Self {
577 let body = redact_api_key_from_message(body, api_key);
578 let context =
579 AuthenticationErrorContext::from_parts(provider, base_url, model, key_source, api_key);
580 Self::from_http_response_with_auth_context(status, &body, Some(context))
581 }
582
583 /// Constructs an `LlmError` from HTTP status code and response body, with
584 /// optional structured details for authentication failures.
585 ///
586 /// The `body` passed here must already be safe for user display. Prefer
587 /// [`Self::from_http_response_with_request_context`] when the raw API key is
588 /// available so the response body can be redacted before rendering.
589 #[must_use]
590 pub fn from_http_response_with_auth_context(
591 status: u16,
592 body: &str,
593 auth_context: Option<AuthenticationErrorContext>,
594 ) -> Self {
595 match status {
596 401 => Self::authentication_error_with_context(body, auth_context),
597 403 => {
598 if looks_like_authentication_failure(body) {
599 Self::authentication_error_with_context(body, auth_context)
600 } else {
601 LlmError::AuthorizationError(body.to_string())
602 }
603 }
604 _ => Self::from_http_response(status, body),
605 }
606 }
607
608 /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header.
609 pub fn from_http_response_with_retry_after(
610 status: u16,
611 body: &str,
612 retry_after: Option<Duration>,
613 ) -> Self {
614 let mut error = Self::from_http_response(status, body);
615 if let LlmError::RateLimited {
616 retry_after: ref mut ra,
617 ..
618 } = error
619 {
620 *ra = retry_after;
621 }
622 error
623 }
624
625 /// Constructs an `LlmError` from a reqwest error.
626 pub fn from_reqwest(err: &reqwest::Error) -> Self {
627 if err.is_timeout() {
628 LlmError::Timeout(Duration::from_secs(0))
629 } else if err.is_connect() {
630 LlmError::NetworkError(format!("Connection failed: {err}"))
631 } else if err.is_request() {
632 LlmError::NetworkError(format!("Request failed: {err}"))
633 } else {
634 LlmError::Other(err.to_string())
635 }
636 }
637 }
638
639 /// Format provider HTTP error bodies before they are surfaced in the TUI.
640 ///
641 /// Providers sometimes return whole HTML error pages for gateway/WAF blocks.
642 /// Passing those pages through raw floods the transcript and can also make a
643 /// provider-side 403 look like a broken API key. Keep the useful details and
644 /// cap everything else.
645 #[must_use]
646 pub(crate) fn sanitize_http_error_body(
647 provider_label: Option<&str>,
648 status: u16,
649 body: &str,
650 ) -> String {
651 let json_message = extract_json_error_message(body);
652 let message = json_message.as_deref().unwrap_or(body);
653 // Gate on Google's actual rejection, not the selected provider or model:
654 // compatible gateways may manage signatures themselves (#6048). This
655 // shared boundary covers both streaming and non-streaming HTTP failures.
656 const SIGNATURE_HINT: &str = "Gemini rejected tool-call replay because a thought signature is missing. \
657 Use the built-in `google` provider with its default endpoint, or a gateway that preserves \
658 Google thought signatures, then start a new session before using tools. \
659 Changing reasoning settings will not restore missing signatures.";
660 if status == 400
661 && !is_probably_html(message)
662 && explicit_quota_code(body).is_none()
663 && !message.contains(SIGNATURE_HINT)
664 {
665 let lower = collapse_whitespace(message).to_ascii_lowercase();
666 if lower.contains("missing a thought_signature")
667 || lower.contains("missing thought_signature")
668 || lower.contains("thought_signature is missing")
669 {
670 let detail = truncate_for_error(&collapse_whitespace(message), 900);
671 return format!("{SIGNATURE_HINT} Provider error: {detail}");
672 }
673 }
674
675 if let Some(message) = json_message {
676 let message = truncate_for_error(&collapse_whitespace(&message), 2_000);
677 if let Some(code) = explicit_quota_code(body) {
678 return format!("{message} (provider error code: {code})");
679 }
680 return message;
681 }
682
683 if is_probably_html(body) {
684 let text = html_to_text(body);
685 let lower = text.to_ascii_lowercase();
686 let provider = provider_label.unwrap_or("Provider");
687
688 // Cloudflare's "Access Denied" interstitial strips the literal word
689 // "cloudflare" once tags are removed (it only survives in `<meta>`
690 // attributes and the `<style>`/`<script>` blocks we discard). Arcee's
691 // 403 page is exactly this shape, so also key off the WAF's stock copy
692 // ("security alert", "contact support") and a Cloudflare error/ray ID.
693 let error_id = extract_cloudflare_error_id(&text);
694 let is_cloudflare = lower.contains("cloudflare");
695 let looks_like_access_denied = lower.contains("access denied")
696 && (is_cloudflare
697 || lower.contains("security alert")
698 || lower.contains("contact support")
699 || lower.contains("contact us")
700 || error_id.is_some());
701 if looks_like_access_denied {
702 let label = if is_cloudflare {
703 "Cloudflare Access Denied"
704 } else {
705 "Access Denied"
706 };
707 let mut message = format!(
708 "{provider} API returned {label} (HTTP {status}). \
709 The request was blocked before it reached the model; retry with a \
710 smaller request or fewer tools, or contact provider support"
711 );
712 if let Some(id) = error_id {
713 message.push_str(&format!(" with ID {id}"));
714 }
715 message.push('.');
716 return message;
717 }
718
719 let text = truncate_for_error(&collapse_whitespace(&text), 900);
720 return format!("{provider} API returned an HTML error page (HTTP {status}): {text}");
721 }
722
723 truncate_for_error(&collapse_whitespace(body), 2_000)
724 }
725
726 fn looks_like_authentication_failure(body: &str) -> bool {
727 let lower = body.to_ascii_lowercase();
728 lower.contains("authentication")
729 || lower.contains("unauthorized")
730 || lower.contains("api key")
731 || lower.contains("invalid key")
732 || lower.contains("invalid token")
733 || lower.contains("bearer token")
734 || lower.contains("missing token")
735 }
736
737 /// Quota exhaustion is a durable account state, not a generic rate-limit
738 /// synonym. Accept only explicit provider evidence at the HTTP/parser boundary;
739 /// callers holding a stringified error must never promote it to this type.
740 fn has_explicit_quota_evidence(body: &str) -> bool {
741 explicit_quota_code(body).is_some()
742 || has_explicit_quota_code_marker(body)
743 || has_explicit_quota_phrase(body)
744 }
745
746 fn explicit_quota_code(body: &str) -> Option<String> {
747 let value: Value = serde_json::from_str(body).ok()?;
748 [
749 "/error/code",
750 "/error/type",
751 "/error/error_code",
752 "/code",
753 "/type",
754 "/error_code",
755 ]
756 .into_iter()
757 .filter_map(|pointer| value.pointer(pointer).and_then(Value::as_str))
758 .find(|code| is_explicit_quota_code(code))
759 .map(ToOwned::to_owned)
760 }
761
762 fn is_explicit_quota_code(code: &str) -> bool {
763 let normalized: String = code
764 .chars()
765 .filter(|ch| ch.is_ascii_alphanumeric())
766 .map(|ch| ch.to_ascii_lowercase())
767 .collect();
768 matches!(
769 normalized.as_str(),
770 "insufficientquota"
771 | "quotaexceeded"
772 | "quotaexhausted"
773 | "billinghardlimitreached"
774 | "billinglimitreached"
775 | "creditbalanceexhausted"
776 )
777 }
778
779 fn has_explicit_quota_code_marker(body: &str) -> bool {
780 let lower = body.to_ascii_lowercase();
781 let Some((_, suffix)) = lower.split_once("provider error code:") else {
782 return false;
783 };
784 let code = suffix
785 .trim_start()
786 .split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')))
787 .next()
788 .unwrap_or_default();
789 is_explicit_quota_code(code)
790 }
791
792 fn has_explicit_quota_phrase(body: &str) -> bool {
793 let lower = body.to_ascii_lowercase();
794 let current_quota_exhausted = lower.contains("exceeded your current quota")
795 || lower.contains("current quota has been exceeded");
796 let plan_and_billing_guidance = lower.contains("plan") && lower.contains("billing");
797 let durable_scope_exhausted = [
798 "billing quota exceeded",
799 "billing quota exhausted",
800 "billing quota is exhausted",
801 "billing quota has been exceeded",
802 "billing quota has been exhausted",
803 "account quota exceeded",
804 "account quota exhausted",
805 "account quota is exhausted",
806 "account quota has been exceeded",
807 "account quota has been exhausted",
808 "plan quota exceeded",
809 "plan quota exhausted",
810 "plan quota is exhausted",
811 "plan quota has been exceeded",
812 "plan quota has been exhausted",
813 ]
814 .into_iter()
815 .any(|phrase| lower.contains(phrase));
816
817 lower.contains("billing hard limit has been reached")
818 || lower.contains("credit balance exhausted")
819 || lower.contains("credit balance is exhausted")
820 || durable_scope_exhausted
821 || (current_quota_exhausted && plan_and_billing_guidance)
822 }
823
824 fn extract_json_error_message(body: &str) -> Option<String> {
825 let value: Value = serde_json::from_str(body).ok()?;
826 // Flat gateway bodies (`{"error":"Bad Request","message":"Invalid model
827 // name: 'x'"}` — Concentrate, among others) keep the class in `error` and
828 // the detail in `message`. Surfacing only the class hid the one line the
829 // person needed, so carry both when both are present and differ.
830 if let (Some(class), Some(detail)) = (
831 value.pointer("/error").and_then(Value::as_str),
832 value.pointer("/message").and_then(Value::as_str),
833 ) && !class.trim().is_empty()
834 && !detail.trim().is_empty()
835 && !class.trim().eq_ignore_ascii_case(detail.trim())
836 {
837 return Some(format!("{}: {}", class.trim(), detail.trim()));
838 }
839 for pointer in [
840 "/error/message",
841 "/error",
842 "/message",
843 "/detail",
844 "/error_description",
845 ] {
846 let Some(value) = value.pointer(pointer) else {
847 continue;
848 };
849 if let Some(message) = value.as_str() {
850 if !message.trim().is_empty() {
851 return Some(message.to_string());
852 }
853 } else if value.is_object() || value.is_array() {
854 return Some(value.to_string());
855 }
856 }
857 None
858 }
859
860 fn is_probably_html(body: &str) -> bool {
861 let prefix = body
862 .chars()
863 .take(512)
864 .collect::<String>()
865 .to_ascii_lowercase();
866 prefix.contains("<!doctype html") || prefix.contains("<html") || prefix.contains("<head")
867 }
868
869 fn html_to_text(html: &str) -> String {
870 let without_scripts = strip_html_block(html, "script");
871 let without_styles = strip_html_block(&without_scripts, "style");
872 let mut text = String::with_capacity(without_styles.len().min(4096));
873 let mut in_tag = false;
874 for ch in without_styles.chars() {
875 match ch {
876 '<' => {
877 in_tag = true;
878 text.push(' ');
879 }
880 '>' => {
881 in_tag = false;
882 text.push(' ');
883 }
884 _ if !in_tag => text.push(ch),
885 _ => {}
886 }
887 }
888 decode_basic_html_entities(&collapse_whitespace(&text))
889 }
890
891 fn strip_html_block(input: &str, tag: &str) -> String {
892 let mut out = String::with_capacity(input.len());
893 let mut cursor = 0usize;
894 let lower = input.to_ascii_lowercase();
895 let start_marker = format!("<{tag}");
896 let end_marker = format!("</{tag}>");
897
898 while let Some(relative_start) = lower[cursor..].find(&start_marker) {
899 let start = cursor + relative_start;
900 out.push_str(&input[cursor..start]);
901 let after_start = start + start_marker.len();
902 let Some(relative_end) = lower[after_start..].find(&end_marker) else {
903 cursor = input.len();
904 break;
905 };
906 cursor = after_start + relative_end + end_marker.len();
907 out.push(' ');
908 }
909 out.push_str(&input[cursor..]);
910 out
911 }
912
913 fn decode_basic_html_entities(input: &str) -> String {
914 input
915 .replace("&nbsp;", " ")
916 .replace("&amp;", "&")
917 .replace("&lt;", "<")
918 .replace("&gt;", ">")
919 .replace("&quot;", "\"")
920 .replace("&#39;", "'")
921 .replace("&apos;", "'")
922 }
923
924 fn collapse_whitespace(input: &str) -> String {
925 input.split_whitespace().collect::<Vec<_>>().join(" ")
926 }
927
928 fn truncate_for_error(input: &str, max_chars: usize) -> String {
929 let mut out = String::with_capacity(input.len().min(max_chars + 32));
930 for (count, ch) in input.chars().enumerate() {
931 if count >= max_chars {
932 out.push_str("...");
933 return out;
934 }
935 out.push(ch);
936 }
937 out
938 }
939
940 fn extract_cloudflare_error_id(text: &str) -> Option<String> {
941 let mut last = None;
942 for token in text.split(|ch: char| !ch.is_ascii_hexdigit()) {
943 if (16..=64).contains(&token.len()) && token.bytes().any(|b| b.is_ascii_alphabetic()) {
944 last = Some(token.to_string());
945 }
946 }
947 last
948 }
949
950 impl From<reqwest::Error> for LlmError {
951 fn from(err: reqwest::Error) -> Self {
952 LlmError::from_reqwest(&err)
953 }
954 }
955
956 impl From<serde_json::Error> for LlmError {
957 fn from(err: serde_json::Error) -> Self {
958 LlmError::ParseError(err.to_string())
959 }
960 }
961
962 // === RetryConfig - Exponential Backoff Configuration ===
963
964 /// Configuration for retry behavior with exponential backoff.
965 ///
966 /// This struct controls how retries are performed:
967 /// - Number of retry attempts
968 /// - Delay calculation (exponential backoff with optional jitter)
969 /// - Which HTTP status codes are retryable
970 /// - Timeout handling
971 ///
972 /// # Default Values
973 ///
974 /// - `enabled`: true
975 /// - `max_retries`: 3
976 /// - `initial_delay`: 1.0 seconds
977 /// - `max_delay`: 60.0 seconds
978 /// - `exponential_base`: 2.0
979 /// - `jitter`: true (adds randomness to prevent thundering herd)
980 /// - `jitter_factor`: 0.1 (10% variation)
981 /// - `retryable_status_codes`: [429, 499, 500, 502, 503, 504]
982 #[derive(Debug, Clone)]
983 pub struct RetryConfig {
984 /// Whether retry logic is enabled
985 pub enabled: bool,
986
987 /// Maximum number of retry attempts (0 = no retries, 3 = up to 4 total attempts)
988 pub max_retries: u32,
989
990 /// Initial delay before first retry (seconds)
991 pub initial_delay: f64,
992
993 /// Maximum delay between retries (seconds)
994 pub max_delay: f64,
995
996 /// Base for exponential backoff (delay = initial * base^attempt)
997 pub exponential_base: f64,
998
999 /// Whether to add random jitter to delays
1000 pub jitter: bool,
1001
1002 /// Jitter factor (0.1 = +/- 10% variation)
1003 pub jitter_factor: f64,
1004
1005 /// Whether to respect server's Retry-After header
1006 pub respect_retry_after: bool,
1007
1008 /// HTTP status codes that should trigger a retry
1009 #[allow(dead_code)] // Used in tests via is_retryable_status()
1010 pub retryable_status_codes: Vec<u16>,
1011
1012 /// Timeout for individual requests (seconds, 0 = no timeout)
1013 #[allow(dead_code)] // Configuration field for retry consumers
1014 pub request_timeout: f64,
1015
1016 /// Total timeout for all retry attempts (seconds, 0 = no total timeout)
1017 pub total_timeout: f64,
1018 }
1019
1020 impl Default for RetryConfig {
1021 fn default() -> Self {
1022 Self {
1023 enabled: true,
1024 max_retries: 3,
1025 initial_delay: 1.0,
1026 max_delay: 60.0,
1027 exponential_base: 2.0,
1028 jitter: true,
1029 jitter_factor: 0.1,
1030 respect_retry_after: true,
1031 retryable_status_codes: vec![429, 499, 500, 502, 503, 504],
1032 request_timeout: 120.0,
1033 total_timeout: 0.0, // No total timeout by default
1034 }
1035 }
1036 }
1037
1038 #[allow(dead_code)] // Public builder API, used in tests
1039 impl RetryConfig {
1040 /// Creates a new `RetryConfig` with default values
1041 pub fn new() -> Self {
1042 Self::default()
1043 }
1044
1045 /// Creates a config with retry disabled
1046 pub fn disabled() -> Self {
1047 Self {
1048 enabled: false,
1049 ..Default::default()
1050 }
1051 }
1052
1053 /// Builder method to set max retries
1054 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
1055 self.max_retries = max_retries;
1056 self
1057 }
1058
1059 /// Builder method to set initial delay
1060 pub fn with_initial_delay(mut self, delay: f64) -> Self {
1061 self.initial_delay = delay;
1062 self
1063 }
1064
1065 /// Builder method to set max delay
1066 pub fn with_max_delay(mut self, delay: f64) -> Self {
1067 self.max_delay = delay;
1068 self
1069 }
1070
1071 /// Builder method to enable/disable jitter
1072 pub fn with_jitter(mut self, enabled: bool) -> Self {
1073 self.jitter = enabled;
1074 self
1075 }
1076
1077 /// Calculates the delay for a given retry attempt.
1078 ///
1079 /// Uses exponential backoff: delay = `initial_delay` * `exponential_base^attempt`
1080 /// The result is capped at `max_delay` and optionally has jitter applied.
1081 ///
1082 /// # Arguments
1083 ///
1084 /// * `attempt` - Zero-based attempt number (0 = first retry)
1085 ///
1086 /// # Returns
1087 ///
1088 /// Duration to wait before the next retry attempt
1089 pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
1090 let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
1091 let base_delay = self.initial_delay * self.exponential_base.powi(exponent);
1092 let capped_delay = base_delay.min(self.max_delay);
1093
1094 let final_delay = if self.jitter {
1095 // Add random jitter to prevent thundering herd problem
1096 let jitter_range = capped_delay * self.jitter_factor;
1097 // Use UUID v4 entropy for jitter randomness.
1098 let bytes = *Uuid::new_v4().as_bytes();
1099 let sample = u16::from_le_bytes([bytes[0], bytes[1]]);
1100 let random_factor = f64::from(sample) / f64::from(u16::MAX); // 0.0 to 1.0
1101 let jitter = jitter_range * (2.0 * random_factor - 1.0); // -range to +range
1102
1103 (capped_delay + jitter).max(0.0)
1104 } else {
1105 capped_delay
1106 };
1107
1108 Duration::from_secs_f64(final_delay)
1109 }
1110
1111 /// Checks if a given HTTP status code should trigger a retry
1112 pub fn is_retryable_status(&self, status: u16) -> bool {
1113 self.retryable_status_codes.contains(&status)
1114 }
1115 }
1116
1117 /// Converts from the existing `RetryPolicy` in config
1118 impl From<RetryPolicy> for RetryConfig {
1119 fn from(policy: RetryPolicy) -> Self {
1120 Self {
1121 enabled: policy.enabled,
1122 max_retries: policy.max_retries,
1123 initial_delay: policy.initial_delay,
1124 max_delay: policy.max_delay,
1125 exponential_base: policy.exponential_base,
1126 ..Default::default()
1127 }
1128 }
1129 }
1130
1131 /// Converts back to `RetryPolicy` for compatibility
1132 impl From<RetryConfig> for RetryPolicy {
1133 fn from(config: RetryConfig) -> Self {
1134 Self {
1135 enabled: config.enabled,
1136 max_retries: config.max_retries,
1137 initial_delay: config.initial_delay,
1138 max_delay: config.max_delay,
1139 exponential_base: config.exponential_base,
1140 }
1141 }
1142 }
1143
1144 // === Retry Error and Result Types ===
1145
1146 /// Error returned when all retry attempts have been exhausted.
1147 #[derive(Debug)]
1148 pub struct RetryError {
1149 /// The last error encountered
1150 pub last_error: LlmError,
1151
1152 /// Total number of attempts made
1153 pub attempts: u32,
1154
1155 /// Total time spent across all attempts
1156 pub total_time: Duration,
1157 }
1158
1159 impl std::fmt::Display for RetryError {
1160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1161 write!(
1162 f,
1163 "Retry exhausted after {} attempts ({:?}): {}",
1164 self.attempts, self.total_time, self.last_error
1165 )
1166 }
1167 }
1168
1169 impl std::error::Error for RetryError {
1170 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1171 Some(&self.last_error)
1172 }
1173 }
1174
1175 /// Result type for retry operations
1176 pub type RetryResult<T> = Result<T, RetryError>;
1177
1178 /// Callback type for retry notifications
1179 ///
1180 /// Called before each retry with:
1181 /// - The error that triggered the retry
1182 /// - The attempt number (0-based)
1183 /// - The delay before the next attempt
1184 pub type RetryCallback = Box<dyn Fn(&LlmError, u32, Duration) + Send + Sync>;
1185
1186 // === with_retry - Generic Retry Wrapper ===
1187
1188 /// Executes an async operation with configurable retry logic.
1189 ///
1190 /// This function wraps any async operation that returns `Result<T, LlmError>`
1191 /// and automatically retries on transient failures using exponential backoff.
1192 ///
1193 /// # Arguments
1194 ///
1195 /// * `config` - Retry configuration (delays, max attempts, etc.)
1196 /// * `operation` - Async closure to execute (will be called multiple times on retry)
1197 /// * `callback` - Optional callback for retry notifications (logging, metrics, etc.)
1198 ///
1199 /// # Returns
1200 ///
1201 /// * `Ok(T)` - The successful result from the operation
1202 /// * `Err(RetryError)` - All retries exhausted or non-retryable error encountered
1203 ///
1204 /// # Example
1205 ///
1206 /// ```ignore
1207 /// let result = with_retry(
1208 /// &config,
1209 /// || async { client.send_request(&req).await },
1210 /// Some(Box::new(|err, attempt, delay| {
1211 /// eprintln!("Retry {} after {:?}: {}", attempt, delay, err);
1212 /// })),
1213 /// ).await;
1214 /// ```
1215 // Keep the structured error inline: this is a public compatibility surface and
1216 // boxing it in a patch release would force every caller to change ownership
1217 // handling for `last_error`.
1218 #[allow(clippy::result_large_err)]
1219 pub async fn with_retry<F, Fut, T>(
1220 config: &RetryConfig,
1221 mut operation: F,
1222 callback: Option<RetryCallback>,
1223 ) -> RetryResult<T>
1224 where
1225 F: FnMut() -> Fut,
1226 Fut: Future<Output = Result<T, LlmError>>,
1227 {
1228 // If retries are disabled, just run once
1229 if !config.enabled {
1230 return operation().await.map_err(|e| RetryError {
1231 last_error: e,
1232 attempts: 1,
1233 total_time: Duration::ZERO,
1234 });
1235 }
1236
1237 let start_time = Instant::now();
1238 let total_timeout = if config.total_timeout > 0.0 {
1239 Some(Duration::from_secs_f64(config.total_timeout))
1240 } else {
1241 None
1242 };
1243
1244 let mut last_error: Option<LlmError> = None;
1245
1246 // Attempt 0 is the first try, then up to max_retries additional attempts
1247 for attempt in 0..=config.max_retries {
1248 // Check total timeout
1249 if let Some(timeout) = total_timeout
1250 && start_time.elapsed() >= timeout
1251 {
1252 return Err(RetryError {
1253 last_error: last_error.unwrap_or(LlmError::Timeout(timeout)),
1254 attempts: attempt,
1255 total_time: start_time.elapsed(),
1256 });
1257 }
1258
1259 match operation().await {
1260 Ok(result) => return Ok(result),
1261 Err(err) => {
1262 // Non-retryable errors fail immediately
1263 if !err.is_retryable() {
1264 return Err(RetryError {
1265 last_error: err,
1266 attempts: attempt + 1,
1267 total_time: start_time.elapsed(),
1268 });
1269 }
1270
1271 // Last attempt - no more retries
1272 if attempt >= config.max_retries {
1273 return Err(RetryError {
1274 last_error: err,
1275 attempts: attempt + 1,
1276 total_time: start_time.elapsed(),
1277 });
1278 }
1279
1280 // Calculate delay
1281 // Use server's Retry-After if available and configured
1282 let base_delay = config.delay_for_attempt(attempt);
1283 let delay = if config.respect_retry_after {
1284 err.suggested_retry_delay().unwrap_or(base_delay)
1285 } else {
1286 base_delay
1287 };
1288
1289 // Notify callback if provided
1290 if let Some(ref cb) = callback {
1291 cb(&err, attempt, delay);
1292 }
1293
1294 last_error = Some(err);
1295
1296 // Wait before retrying
1297 tokio::time::sleep(delay).await;
1298 }
1299 }
1300 }
1301
1302 // Should not reach here, but handle gracefully
1303 Err(RetryError {
1304 last_error: last_error.unwrap_or(LlmError::Other("Unknown retry error".to_string())),
1305 attempts: config.max_retries + 1,
1306 total_time: start_time.elapsed(),
1307 })
1308 }
1309
1310 // === Utility Functions ===
1311
1312 /// The longest a `Retry-After` value is ever believed. A server (or a proxy
1313 /// in front of it) can send an arbitrarily large delay; without a ceiling a
1314 /// single `Retry-After: 86400` would wedge the turn for a day. One hour is
1315 /// well past any legitimate rate-limit window.
1316 const RETRY_AFTER_MAX: Duration = Duration::from_secs(3600);
1317
1318 /// Parses the Retry-After header value into a Duration.
1319 ///
1320 /// Supports both:
1321 /// - Seconds as integer: "120" -> 120 seconds
1322 /// - HTTP-date format: "Wed, 21 Oct 2015 07:28:00 GMT" (not implemented, returns None)
1323 ///
1324 /// The value is server-controlled, so this never panics and never returns an
1325 /// unbounded delay: negative / NaN / infinite / absurd floats are rejected
1326 /// (`Duration::from_secs_f64` panics on a negative — a remote-triggerable
1327 /// crash before this guard), and any result is clamped to [`RETRY_AFTER_MAX`].
1328 pub fn parse_retry_after(value: &str) -> Option<Duration> {
1329 // Try parsing as seconds
1330 if let Ok(seconds) = value.parse::<u64>() {
1331 return Some(Duration::from_secs(seconds).min(RETRY_AFTER_MAX));
1332 }
1333
1334 // Try parsing as float seconds. Only a finite, non-negative value is a
1335 // meaningful delay; everything else (`-5`, `nan`, `inf`) is "no usable
1336 // hint". Clamp to the ceiling BEFORE `from_secs_f64` so an out-of-range
1337 // float can never reach its overflow-panic path, while keeping the
1338 // sub-second precision a legitimate `1.5` carries.
1339 if let Ok(seconds) = value.parse::<f64>()
1340 && seconds.is_finite()
1341 && seconds >= 0.0
1342 {
1343 let clamped = seconds.min(RETRY_AFTER_MAX.as_secs_f64());
1344 return Some(Duration::from_secs_f64(clamped));
1345 }
1346
1347 // HTTP-date format not supported yet
1348 // Could use chrono or httpdate crate if needed
1349 None
1350 }
1351
1352 /// Extracts Retry-After duration from response headers
1353 pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1354 headers
1355 .get(reqwest::header::RETRY_AFTER)
1356 .and_then(|v| v.to_str().ok())
1357 .and_then(parse_retry_after)
1358 }
1359
1360 #[cfg(test)]
1361 #[path = "tests.rs"]
1362 mod quota_tests;
1363
1364 // === Tests ===
1365
1366 #[cfg(test)]
1367 mod tests {
1368 use super::*;
1369
1370 fn assert_f64_eq(actual: f64, expected: f64) {
1371 assert!(
1372 (actual - expected).abs() < f64::EPSILON,
1373 "expected {expected}, got {actual}"
1374 );
1375 }
1376
1377 fn auth_user_message(error: LlmError) -> String {
1378 match error {
1379 LlmError::AuthenticationError(auth) => auth.to_user_message(),
1380 other => panic!("expected authentication error, got {other}"),
1381 }
1382 }
1383
1384 #[test]
1385 fn test_retry_config_defaults() {
1386 let config = RetryConfig::default();
1387 assert!(config.enabled);
1388 assert_eq!(config.max_retries, 3);
1389 assert_f64_eq(config.initial_delay, 1.0);
1390 assert_f64_eq(config.max_delay, 60.0);
1391 assert_f64_eq(config.exponential_base, 2.0);
1392 assert!(config.jitter);
1393 }
1394
1395 #[test]
1396 fn test_retry_config_disabled() {
1397 let config = RetryConfig::disabled();
1398 assert!(!config.enabled);
1399 }
1400
1401 #[test]
1402 fn test_retry_config_builder() {
1403 let config = RetryConfig::new()
1404 .with_max_retries(5)
1405 .with_initial_delay(2.0)
1406 .with_max_delay(120.0)
1407 .with_jitter(false);
1408
1409 assert_eq!(config.max_retries, 5);
1410 assert_f64_eq(config.initial_delay, 2.0);
1411 assert_f64_eq(config.max_delay, 120.0);
1412 assert!(!config.jitter);
1413 }
1414
1415 #[test]
1416 fn test_delay_for_attempt_exponential() {
1417 let config = RetryConfig::new().with_jitter(false);
1418
1419 // delay = initial * base^attempt
1420 // 1.0 * 2^0 = 1.0
1421 let d0 = config.delay_for_attempt(0);
1422 assert_eq!(d0, Duration::from_secs_f64(1.0));
1423
1424 // 1.0 * 2^1 = 2.0
1425 let d1 = config.delay_for_attempt(1);
1426 assert_eq!(d1, Duration::from_secs_f64(2.0));
1427
1428 // 1.0 * 2^2 = 4.0
1429 let d2 = config.delay_for_attempt(2);
1430 assert_eq!(d2, Duration::from_secs_f64(4.0));
1431
1432 // 1.0 * 2^3 = 8.0
1433 let d3 = config.delay_for_attempt(3);
1434 assert_eq!(d3, Duration::from_secs_f64(8.0));
1435 }
1436
1437 #[test]
1438 fn test_delay_for_attempt_capped() {
1439 let config = RetryConfig::new().with_jitter(false).with_max_delay(5.0);
1440
1441 // 1.0 * 2^3 = 8.0, but capped at 5.0
1442 let d3 = config.delay_for_attempt(3);
1443 assert_eq!(d3, Duration::from_secs_f64(5.0));
1444 }
1445
1446 #[test]
1447 fn test_delay_for_attempt_with_jitter() {
1448 let config = RetryConfig::new().with_jitter(true);
1449
1450 // With jitter, delays should vary slightly
1451 let d1 = config.delay_for_attempt(1);
1452 let d2 = config.delay_for_attempt(1);
1453
1454 // Both should be close to 2.0 seconds (within 10% jitter)
1455 let base = 2.0;
1456 let range = base * 0.1;
1457 assert!(d1.as_secs_f64() >= base - range);
1458 assert!(d1.as_secs_f64() <= base + range);
1459 assert!(d2.as_secs_f64() >= base - range);
1460 assert!(d2.as_secs_f64() <= base + range);
1461 }
1462
1463 #[test]
1464 fn test_is_retryable_status() {
1465 let config = RetryConfig::default();
1466
1467 assert!(config.is_retryable_status(429)); // Rate limit
1468 assert!(config.is_retryable_status(499)); // Upstream request cancelled
1469 assert!(config.is_retryable_status(500)); // Internal server error
1470 assert!(config.is_retryable_status(502)); // Bad gateway
1471 assert!(config.is_retryable_status(503)); // Service unavailable
1472 assert!(config.is_retryable_status(504)); // Gateway timeout
1473
1474 assert!(!config.is_retryable_status(400)); // Bad request
1475 assert!(!config.is_retryable_status(401)); // Unauthorized
1476 assert!(!config.is_retryable_status(403)); // Forbidden
1477 assert!(!config.is_retryable_status(404)); // Not found
1478 }
1479
1480 #[test]
1481 fn auth_error_with_context_includes_provider_authority_model_and_key_source() {
1482 let err = LlmError::from_http_response_with_request_context(
1483 401,
1484 "Invalid API Key",
1485 Some("Xiaomi MiMo"),
1486 Some("https://token-plan-sgp.xiaomimimo.com/v1"),
1487 Some("mimo-v2.5"),
1488 Some("env"),
1489 Some("tp-secret-token-plan-value"),
1490 );
1491 let message = auth_user_message(err);
1492
1493 assert!(message.contains("Invalid API Key"));
1494 assert!(message.contains("provider: Xiaomi MiMo"));
1495 assert!(message.contains("base URL authority: token-plan-sgp.xiaomimimo.com"));
1496 assert!(message.contains("model: mimo-v2.5"));
1497 assert!(message.contains("key source: env"));
1498 assert!(message.contains("key fingerprint: tp-... (len=26)"));
1499 }
1500
1501 #[test]
1502 fn auth_error_redacts_full_api_key_from_body_and_context() {
1503 let api_key = "tp-secret-token-plan-value";
1504 let err = LlmError::from_http_response_with_request_context(
1505 401,
1506 &format!("Invalid API Key: {api_key}"),
1507 Some("Xiaomi MiMo"),
1508 Some("https://token-plan-sgp.xiaomimimo.com/v1"),
1509 Some("mimo-v2.5"),
1510 Some("config-file"),
1511 Some(api_key),
1512 );
1513 let message = auth_user_message(err);
1514
1515 assert!(!message.contains(api_key));
1516 assert!(!message.contains("secret-token-plan-value"));
1517 assert!(message.contains("[redacted API key]"));
1518 assert!(message.contains("key fingerprint: tp-... (len=26)"));
1519 }
1520
1521 #[test]
1522 fn auth_error_classifies_xiaomi_token_plan_key_prefix() {
1523 let token_plan = AuthenticationErrorContext::from_parts(
1524 None,
1525 None,
1526 None,
1527 Some("session"),
1528 Some("tp-secret-token-plan-value"),
1529 );
1530 let generic = AuthenticationErrorContext::from_parts(
1531 None,
1532 None,
1533 None,
1534 Some("session"),
1535 Some("sk-other"),
1536 );
1537 let unprefixed = AuthenticationErrorContext::from_parts(
1538 None,
1539 None,
1540 None,
1541 Some("session"),
1542 Some("plainsecretvalue"),
1543 );
1544
1545 assert_eq!(
1546 token_plan.key_kind.as_deref(),
1547 Some("Xiaomi MiMo Token Plan key")
1548 );
1549 assert_eq!(generic.key_kind.as_deref(), Some("API key"));
1550 assert_eq!(unprefixed.key_kind.as_deref(), Some("API key"));
1551 assert_eq!(
1552 unprefixed.key_fingerprint.as_deref(),
1553 Some("unprefixed (len=16)")
1554 );
1555 }
1556
1557 #[test]
1558 fn authorization_403_is_not_reclassified_by_auth_context() {
1559 let err = LlmError::from_http_response_with_request_context(
1560 403,
1561 "forbidden",
1562 Some("Arcee AI"),
1563 Some("https://api.arcee.ai/v1"),
1564 Some("auto"),
1565 Some("env"),
1566 Some("sk-arcee-secret"),
1567 );
1568
1569 assert!(matches!(err, LlmError::AuthorizationError(_)));
1570 }
1571
1572 #[test]
1573 fn auth_error_without_context_preserves_bare_message() {
1574 let err = LlmError::from_http_response_with_auth_context(
1575 401,
1576 "Invalid API Key",
1577 Some(AuthenticationErrorContext::default()),
1578 );
1579
1580 assert_eq!(auth_user_message(err), "Invalid API Key");
1581 }
1582
1583 /// A flat `{"error","message"}` body keeps its detail: the class alone
1584 /// ("Bad Request") told the person nothing about the rejected model.
1585 #[test]
1586 fn flat_error_and_message_body_surfaces_both_halves() {
1587 let body = r#"{"error":"Bad Request","message":"Invalid model name: 'invalid-model-xyz'"}"#;
1588 assert_eq!(
1589 sanitize_http_error_body(Some("Concentrate"), 400, body),
1590 "Bad Request: Invalid model name: 'invalid-model-xyz'"
1591 );
1592 // Identical halves are not doubled, and the nested OpenAI shape is
1593 // untouched.
1594 assert_eq!(
1595 sanitize_http_error_body(
1596 Some("Concentrate"),
1597 401,
1598 r#"{"error":"Unauthorized","message":"Unauthorized"}"#
1599 ),
1600 "Unauthorized"
1601 );
1602 assert_eq!(
1603 sanitize_http_error_body(
1604 Some("fixture"),
1605 400,
1606 r#"{"error":{"message":"nested detail"}}"#
1607 ),
1608 "nested detail"
1609 );
1610 }
1611
1612 #[test]
1613 fn cloudflare_html_error_is_summarized_without_raw_markup() {
1614 let body = r#"<!DOCTYPE html><html><head><title>Access Denied</title><style>
1615 .hidden { display: none; }
1616 </style></head><body>
1617 <h1>Access Denied</h1>
1618 <p>The action you just performed triggered a security alert.</p>
1619 <script>window.noisy = true;</script>
1620 <span>2600:1700:467:d410:f137:b94f:1dd0:d1e4</span>
1621 <span>a059a2873f3fdf82</span>
1622 <div>Cloudflare Error Pages</div>
1623 </body></html>"#;
1624
1625 let message = sanitize_http_error_body(Some("Arcee AI"), 403, body);
1626
1627 assert!(message.contains("Arcee AI API returned Cloudflare Access Denied"));
1628 assert!(message.contains("ID a059a2873f3fdf82"));
1629 assert!(!message.contains("<!DOCTYPE"));
1630 assert!(!message.contains("tailwindcss"));
1631 assert!(message.len() < 300);
1632 }
1633
1634 #[test]
1635 fn cloudflare_access_denied_403_is_authorization_not_authentication() {
1636 let message = sanitize_http_error_body(
1637 Some("Arcee AI"),
1638 403,
1639 r#"<!doctype html><html><body><h1>Access Denied</h1><p>Cloudflare Error Pages</p></body></html>"#,
1640 );
1641 let err = LlmError::from_http_response(403, &message);
1642
1643 assert!(matches!(err, LlmError::AuthorizationError(_)));
1644 }
1645
1646 #[test]
1647 fn arcee_access_denied_without_literal_cloudflare_is_still_summarized() {
1648 // Mirrors api.arcee.ai's real 403 page: "Cloudflare" appears only in a
1649 // `<meta>` attribute and the `<style>` block, both stripped, so the
1650 // visible text never contains it. The summary must still fire from the
1651 // WAF's stock "security alert" / "Contact Support" copy + error ID.
1652 let body = r#"<!DOCTYPE html><html lang="en"><head>
1653 <meta name="description" content="Cloudflare Error Pages">
1654 <title>Access Denied</title>
1655 <style>:root{--accent:cloudflare}</style></head><body>
1656 <h1>Access Denied</h1>
1657 <p>The action you just performed triggered a security alert.</p>
1658 <p>Please contact us if this was a mistake.</p>
1659 <a>Contact Support</a>
1660 <span>2600:1700:467:d410:f137:b94f:1dd0:d1e4</span>
1661 <span>a059c0d4caf1f9cc</span>
1662 </body></html>"#;
1663
1664 let message = sanitize_http_error_body(Some("Arcee AI"), 403, body);
1665
1666 assert!(
1667 message.contains("Arcee AI API returned Access Denied"),
1668 "got: {message}"
1669 );
1670 assert!(message.contains("ID a059c0d4caf1f9cc"), "got: {message}");
1671 assert!(
1672 !message.to_ascii_lowercase().contains("cloudflare"),
1673 "stripped Arcee page has no literal Cloudflare: {message}"
1674 );
1675 assert!(!message.contains('<'), "no raw markup: {message}");
1676 assert!(message.len() < 300, "stays concise: {message}");
1677
1678 // A WAF block is authorization, not a bad API key.
1679 let err = LlmError::from_http_response(403, &message);
1680 assert!(matches!(err, LlmError::AuthorizationError(_)));
1681 }
1682
1683 #[test]
1684 fn test_llm_error_suggested_retry_delay() {
1685 let err = LlmError::RateLimited {
1686 message: "slow down".to_string(),
1687 retry_after: Some(Duration::from_secs(60)),
1688 };
1689 assert_eq!(err.suggested_retry_delay(), Some(Duration::from_secs(60)));
1690
1691 let err = LlmError::ServerError {
1692 status: 500,
1693 message: "error".to_string(),
1694 };
1695 assert_eq!(err.suggested_retry_delay(), None);
1696 }
1697
1698 #[test]
1699 fn test_parse_retry_after() {
1700 // Integer seconds
1701 assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
1702 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1703
1704 // Float seconds keep sub-second precision
1705 assert_eq!(parse_retry_after("1.5"), Some(Duration::from_secs_f64(1.5)));
1706
1707 // Invalid
1708 assert_eq!(parse_retry_after("invalid"), None);
1709 assert_eq!(parse_retry_after(""), None);
1710 }
1711
1712 /// A `Retry-After` value is server-controlled. Malformed floats used to
1713 /// reach `Duration::from_secs_f64`, which panics on a negative — a
1714 /// remote-triggerable crash in the request path (2026-08-04 review).
1715 #[test]
1716 fn parse_retry_after_never_panics_and_is_bounded_on_hostile_input() {
1717 // None of these may panic.
1718 assert_eq!(parse_retry_after("-5"), None, "negative is not a delay");
1719 assert_eq!(parse_retry_after("nan"), None);
1720 assert_eq!(parse_retry_after("inf"), None);
1721 assert_eq!(parse_retry_after("-inf"), None);
1722 // Absurdly large values clamp to the ceiling rather than overflowing
1723 // or wedging the turn for a day.
1724 assert_eq!(parse_retry_after("1e300"), Some(RETRY_AFTER_MAX));
1725 assert_eq!(parse_retry_after("86400"), Some(RETRY_AFTER_MAX));
1726 assert_eq!(
1727 parse_retry_after("999999999999"),
1728 Some(RETRY_AFTER_MAX),
1729 "integer path is clamped too"
1730 );
1731 // A normal value still passes through untouched.
1732 assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
1733 }
1734
1735 #[test]
1736 fn test_retry_policy_conversion() {
1737 let policy = RetryPolicy {
1738 enabled: true,
1739 max_retries: 5,
1740 initial_delay: 2.0,
1741 max_delay: 30.0,
1742 exponential_base: 3.0,
1743 };
1744
1745 let config: RetryConfig = policy.clone().into();
1746 assert_eq!(config.enabled, policy.enabled);
1747 assert_eq!(config.max_retries, policy.max_retries);
1748 assert_f64_eq(config.initial_delay, policy.initial_delay);
1749 assert_f64_eq(config.max_delay, policy.max_delay);
1750 assert_f64_eq(config.exponential_base, policy.exponential_base);
1751
1752 // Convert back
1753 let policy2: RetryPolicy = config.into();
1754 assert_eq!(policy2.enabled, policy.enabled);
1755 assert_eq!(policy2.max_retries, policy.max_retries);
1756 }
1757
1758 #[tokio::test]
1759 async fn test_with_retry_success_first_attempt() {
1760 let config = RetryConfig::default();
1761 let mut call_count = 0;
1762
1763 let result = with_retry(
1764 &config,
1765 || {
1766 call_count += 1;
1767 async { Ok::<_, LlmError>(42) }
1768 },
1769 None,
1770 )
1771 .await;
1772
1773 assert!(result.is_ok());
1774 assert_eq!(result.unwrap(), 42);
1775 assert_eq!(call_count, 1);
1776 }
1777
1778 #[tokio::test]
1779 async fn test_with_retry_disabled() {
1780 let config = RetryConfig::disabled();
1781 let mut call_count = 0;
1782
1783 let result: RetryResult<i32> = with_retry(
1784 &config,
1785 || {
1786 call_count += 1;
1787 async {
1788 Err(LlmError::ServerError {
1789 status: 500,
1790 message: "error".to_string(),
1791 })
1792 }
1793 },
1794 None,
1795 )
1796 .await;
1797
1798 assert!(result.is_err());
1799 assert_eq!(call_count, 1); // No retries when disabled
1800 }
1801
1802 #[tokio::test]
1803 async fn test_with_retry_eventual_success() {
1804 let config = RetryConfig::new()
1805 .with_max_retries(3)
1806 .with_initial_delay(0.01); // Fast for testing
1807
1808 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1809 let cc = call_count.clone();
1810
1811 let result = with_retry(
1812 &config,
1813 || {
1814 let count = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1815 async move {
1816 if count < 2 {
1817 Err(LlmError::ServerError {
1818 status: 500,
1819 message: "temporary error".to_string(),
1820 })
1821 } else {
1822 Ok::<_, LlmError>(42)
1823 }
1824 }
1825 },
1826 None,
1827 )
1828 .await;
1829
1830 assert!(result.is_ok());
1831 assert_eq!(result.unwrap(), 42);
1832 assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); // 2 failures + 1 success
1833 }
1834
1835 #[tokio::test]
1836 async fn test_with_retry_exhausted() {
1837 let config = RetryConfig::new()
1838 .with_max_retries(2)
1839 .with_initial_delay(0.01);
1840
1841 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1842 let cc = call_count.clone();
1843
1844 let result: RetryResult<i32> = with_retry(
1845 &config,
1846 || {
1847 cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1848 async {
1849 Err(LlmError::ServerError {
1850 status: 500,
1851 message: "persistent error".to_string(),
1852 })
1853 }
1854 },
1855 None,
1856 )
1857 .await;
1858
1859 assert!(result.is_err());
1860 let err = result.unwrap_err();
1861 assert_eq!(err.attempts, 3); // 1 initial + 2 retries
1862 assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3);
1863 }
1864
1865 #[tokio::test]
1866 async fn test_with_retry_callback() {
1867 let config = RetryConfig::new()
1868 .with_max_retries(2)
1869 .with_initial_delay(0.01);
1870
1871 let callback_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1872 let cc = callback_count.clone();
1873
1874 let _: RetryResult<i32> = with_retry(
1875 &config,
1876 || async {
1877 Err(LlmError::ServerError {
1878 status: 500,
1879 message: "error".to_string(),
1880 })
1881 },
1882 Some(Box::new(move |_err, _attempt, _delay| {
1883 cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1884 })),
1885 )
1886 .await;
1887
1888 // Callback called once per retry (not for the final failure)
1889 assert_eq!(callback_count.load(std::sync::atomic::Ordering::SeqCst), 2);
1890 }
1891
1892 #[test]
1893 fn test_retry_error_display() {
1894 let err = RetryError {
1895 last_error: LlmError::ServerError {
1896 status: 500,
1897 message: "internal error".to_string(),
1898 },
1899 attempts: 4,
1900 total_time: Duration::from_secs(10),
1901 };
1902
1903 let display = format!("{err}");
1904 assert!(display.contains("4 attempts"));
1905 assert!(display.contains("10"));
1906 assert!(display.contains("Server error"));
1907 }
1908 }
1909
1909 lines RUST