Skip to main content

autoagents_llm/
http.rs

1//! Centralized HTTP response handling for LLM provider backends.
2//!
3//! The status-code mapping logic
4//! ([`map_http_status_to_error`][crate::http::map_http_status_to_error] and
5//! [`parse_retry_after_value`][crate::http::parse_retry_after_value]) is target-independent
6//! and is reused by both the native `reqwest` transport and the WASI Preview2 transport.
7//! The `reqwest::Response`-based entry point ([`ensure_success`][crate::http::ensure_success])
8//! and its integration tests are only compiled on non-wasm32 targets.
9
10use std::time::Duration;
11
12use chrono::{DateTime, Utc};
13
14use crate::error::LLMError;
15
16/// Maximum bytes read from a non-success HTTP response body.
17///
18/// Shared by the native `reqwest` transport (`read_bounded_error_body`) and the
19/// WASI Preview2 transport (`crate::wasi_http::read_bounded_error_body`) so both
20/// apply the same cap to error bodies, keeping multi-MB CDN/WAF error pages out
21/// of memory (and, on `wasm32-wasip2`, out of the constrained linear memory).
22pub(crate) const MAX_HTTP_ERROR_BODY_BYTES: usize = 65_536;
23
24/// Parsed provider error payload extracted from a non-success response body.
25#[derive(Debug, Default)]
26struct ProviderErrorDetails {
27    message: Option<String>,
28    provider_code: Option<String>,
29}
30
31#[cfg(not(target_arch = "wasm32"))]
32mod native {
33    use super::*;
34    use futures::StreamExt;
35    use reqwest::Response;
36
37    /// Ensures an HTTP response has a success status, mapping failures to typed errors.
38    ///
39    /// On success, returns the original response unchanged so callers can decode the body.
40    /// On failure, reads the response body once and maps status codes to
41    /// [`AuthError`](LLMError::AuthError), [`RateLimitError`](LLMError::RateLimitError),
42    /// [`HttpStatusError`](LLMError::HttpStatusError), or [`InvalidRequest`](LLMError::InvalidRequest).
43    pub async fn ensure_success(response: Response, provider: &str) -> Result<Response, LLMError> {
44        if response.status().is_success() {
45            return Ok(response);
46        }
47
48        let status_code = response.status().as_u16();
49        let retry_after = parse_retry_after(response.headers());
50        let body = read_bounded_error_body(response).await?;
51
52        Err(map_http_status_to_error(
53            provider,
54            status_code,
55            body,
56            retry_after,
57        ))
58    }
59
60    async fn read_bounded_error_body(response: Response) -> Result<String, LLMError> {
61        // Reads at most `MAX_HTTP_ERROR_BODY_BYTES` from the wire on error responses.
62        //
63        // When `Content-Length` exceeds the cap, the body is not read at all and a placeholder
64        // is returned instead. This avoids pulling multi-megabyte CDN/WAF HTML pages into memory.
65        //
66        // Otherwise the body is streamed and accumulation stops at the cap. The remainder is not
67        // drained, so the connection may not be reused; that tradeoff is intentional on the
68        // error path where bounded memory use matters more than keep-alive reuse.
69        if let Some(content_length) = response.content_length()
70            && content_length > MAX_HTTP_ERROR_BODY_BYTES as u64
71        {
72            return Ok(format!(
73                "... [body omitted, Content-Length: {content_length} bytes]"
74            ));
75        }
76
77        let mut stream = response.bytes_stream();
78        let mut collected = Vec::with_capacity(MAX_HTTP_ERROR_BODY_BYTES.min(8192));
79        let mut total_received = 0usize;
80        let mut at_capacity = false;
81
82        while let Some(chunk_result) = stream.next().await {
83            let chunk = chunk_result?;
84            let chunk_len = chunk.len();
85            total_received += chunk_len;
86
87            if !at_capacity {
88                let remaining = MAX_HTTP_ERROR_BODY_BYTES.saturating_sub(collected.len());
89                if chunk_len <= remaining {
90                    collected.extend_from_slice(&chunk);
91                    if collected.len() == MAX_HTTP_ERROR_BODY_BYTES {
92                        at_capacity = true;
93                    }
94                } else {
95                    collected.extend_from_slice(&chunk[..remaining]);
96                    at_capacity = true;
97                }
98            }
99
100            if at_capacity {
101                break;
102            }
103        }
104
105        if at_capacity {
106            Ok(format_truncated_error_body(&collected, total_received))
107        } else {
108            Ok(String::from_utf8_lossy(&collected).into_owned())
109        }
110    }
111
112    fn format_truncated_error_body(collected: &[u8], bytes_read: usize) -> String {
113        let truncated = String::from_utf8_lossy(collected).into_owned();
114        format!("{truncated}... [truncated after reading {bytes_read} bytes]")
115    }
116}
117
118#[cfg(not(target_arch = "wasm32"))]
119pub(crate) use native::ensure_success;
120
121/// Builds a human-readable default error message for a non-success status code.
122///
123/// Transport-independent (no `reqwest` dependency) so it can be shared by the
124/// native and WASI Preview2 status mappers. Falls back to the numeric status when
125/// the reason phrase is unknown or non-standard.
126fn default_error_message(provider: &str, status_code: u16) -> String {
127    let reason = http_reason_phrase(status_code).map_or_else(
128        || status_code.to_string(),
129        |phrase| format!("{status_code} {phrase}"),
130    );
131    format!("{provider} API returned error status: {reason}")
132}
133
134/// Returns the canonical HTTP reason phrase for well-known status codes.
135///
136/// This mirrors the subset of reason phrases that `reqwest::StatusCode` would
137/// render, without requiring the `reqwest` dependency on WASI targets.
138fn http_reason_phrase(status_code: u16) -> Option<&'static str> {
139    // Sorted by status code; uses a linear scan because the table is small
140    // (41 entries) and the function is called only on error paths.
141    const PHRASES: &[(u16, &str)] = &[
142        (400, "Bad Request"),
143        (401, "Unauthorized"),
144        (402, "Payment Required"),
145        (403, "Forbidden"),
146        (404, "Not Found"),
147        (405, "Method Not Allowed"),
148        (406, "Not Acceptable"),
149        (407, "Proxy Authentication Required"),
150        (408, "Request Timeout"),
151        (409, "Conflict"),
152        (410, "Gone"),
153        (411, "Length Required"),
154        (412, "Precondition Failed"),
155        (413, "Payload Too Large"),
156        (414, "URI Too Long"),
157        (415, "Unsupported Media Type"),
158        (416, "Range Not Satisfiable"),
159        (417, "Expectation Failed"),
160        (418, "I'm a teapot"),
161        (421, "Misdirected Request"),
162        (422, "Unprocessable Entity"),
163        (423, "Locked"),
164        (424, "Failed Dependency"),
165        (425, "Too Early"),
166        (426, "Upgrade Required"),
167        (428, "Precondition Required"),
168        (429, "Too Many Requests"),
169        (431, "Request Header Fields Too Large"),
170        (451, "Unavailable For Legal Reasons"),
171        (500, "Internal Server Error"),
172        (501, "Not Implemented"),
173        (502, "Bad Gateway"),
174        (503, "Service Unavailable"),
175        (504, "Gateway Timeout"),
176        (505, "HTTP Version Not Supported"),
177        (506, "Variant Also Negotiates"),
178        (507, "Insufficient Storage"),
179        (508, "Loop Detected"),
180        (510, "Not Extended"),
181        (511, "Network Authentication Required"),
182        (529, "Site Is Overloaded"),
183    ];
184    PHRASES
185        .iter()
186        .find(|(code, _)| *code == status_code)
187        .map(|(_, phrase)| *phrase)
188}
189
190/// Maps a non-success HTTP status code and response body to a typed
191/// [`LLMError`].
192///
193/// This is the shared, transport-independent status mapper used by both the
194/// native `reqwest` path ([`ensure_success`]) and the WASI Preview2 transport.
195/// It is intended to be called only after the caller has observed a non-2xx
196/// status code, and always returns an [`LLMError`] describing the failure.
197pub(crate) fn map_http_status_to_error(
198    provider: &str,
199    status_code: u16,
200    body: String,
201    retry_after: Option<Duration>,
202) -> LLMError {
203    debug_assert!(
204        !(200..300).contains(&status_code),
205        "map_http_status_to_error called on a success status {status_code}"
206    );
207
208    let details = parse_provider_error_body(&body);
209    let message = details
210        .message
211        .unwrap_or_else(|| default_error_message(provider, status_code));
212    let provider_code = details.provider_code.map(String::into_boxed_str);
213    let response_body = body.into_boxed_str();
214
215    match status_code {
216        401 | 403 => LLMError::AuthError {
217            message,
218            status_code: Some(status_code),
219            response_body: Some(response_body),
220        },
221        429 | 529 => LLMError::RateLimitError {
222            status_code,
223            message,
224            response_body,
225            retry_after,
226            provider_code,
227        },
228        400 | 404 | 413 | 422 => LLMError::InvalidRequest {
229            message,
230            status_code: Some(status_code),
231            response_body: Some(response_body),
232        },
233        _ => LLMError::HttpStatusError {
234            status_code,
235            message,
236            response_body,
237            retry_after,
238            provider_code,
239        },
240    }
241}
242
243/// Parses a `Retry-After` header value into a [`Duration`].
244///
245/// Accepts both the delta-seconds form (e.g. `"30"`) and the HTTP-date /
246/// RFC 3339 form (e.g. `"Wed, 21 Oct 2015 07:28:00 GMT"`). Past dates and
247/// unparseable values return `None`. This is shared by the native and WASI
248/// transports.
249pub(crate) fn parse_retry_after_value(value: &str) -> Option<Duration> {
250    let value = value.trim();
251
252    if let Ok(seconds) = value.parse::<u64>() {
253        return Some(Duration::from_secs(seconds));
254    }
255
256    parse_retry_after_http_date(value)
257}
258
259/// Looks up the first `Retry-After` header value (case-insensitive) in a
260/// `(name, value)` header list and parses it via [`parse_retry_after_value`].
261///
262/// This is the transport-agnostic counterpart of the native
263/// `parse_retry_after(&reqwest::HeaderMap)`. The WASI Preview2 transport
264/// collects response headers into lower-cased `(String, String)` pairs (see
265/// `crate::wasi_http`); this helper scans them. Its only callers live behind
266/// `#[cfg(wasi_http)]`, so it is gated to that target plus `test` (so the
267/// native `cargo test` suite exercises it) — otherwise it would be dead code on
268/// native non-test builds.
269#[cfg(any(test, wasi_http))]
270pub(crate) fn find_retry_after(headers: &[(String, String)]) -> Option<Duration> {
271    headers
272        .iter()
273        .find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
274        .and_then(|(_, value)| parse_retry_after_value(value))
275}
276
277#[cfg(not(target_arch = "wasm32"))]
278fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
279    let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
280    parse_retry_after_value(value)
281}
282
283fn parse_retry_after_http_date(value: &str) -> Option<Duration> {
284    let retry_at = DateTime::parse_from_rfc2822(value)
285        .ok()
286        .map(|dt| dt.with_timezone(&Utc))
287        .or_else(|| {
288            DateTime::parse_from_rfc3339(value)
289                .ok()
290                .map(|dt| dt.with_timezone(&Utc))
291        })?;
292
293    let now = Utc::now();
294    if retry_at > now {
295        retry_at.signed_duration_since(now).to_std().ok()
296    } else {
297        None
298    }
299}
300
301fn parse_provider_error_body(body: &str) -> ProviderErrorDetails {
302    let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
303        return ProviderErrorDetails::default();
304    };
305
306    // OpenAI-compatible: { "error": { "message", "type", "code" } }
307    // Google: { "error": { "message", "code" (numeric), "status" } }
308    if let Some(error) = value.get("error") {
309        return ProviderErrorDetails {
310            message: error
311                .get("message")
312                .and_then(|v| v.as_str())
313                .map(str::to_string),
314            provider_code: error
315                .get("code")
316                .and_then(|v| v.as_str())
317                .or_else(|| error.get("type").and_then(|v| v.as_str()))
318                .or_else(|| error.get("status").and_then(|v| v.as_str()))
319                .map(str::to_string),
320        };
321    }
322
323    ProviderErrorDetails {
324        message: value
325            .get("message")
326            .and_then(|v| v.as_str())
327            .map(str::to_string),
328        provider_code: value
329            .get("type")
330            .and_then(|v| v.as_str())
331            .map(str::to_string),
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn find_retry_after_is_case_insensitive() {
341        let headers = vec![
342            ("Retry-After".to_string(), "30".to_string()),
343            ("x-foo".to_string(), "bar".to_string()),
344        ];
345        assert_eq!(find_retry_after(&headers), Some(Duration::from_secs(30)));
346
347        let lower = vec![("retry-after".to_string(), "12".to_string())];
348        assert_eq!(find_retry_after(&lower), Some(Duration::from_secs(12)));
349    }
350
351    #[test]
352    fn find_retry_after_missing_returns_none() {
353        let headers = vec![("content-type".to_string(), "text/plain".to_string())];
354        assert!(find_retry_after(&headers).is_none());
355    }
356
357    #[test]
358    fn parse_provider_error_body_extracts_google_status() {
359        let details = parse_provider_error_body(
360            r#"{"error":{"code":429,"message":"Resource exhausted","status":"RESOURCE_EXHAUSTED"}}"#,
361        );
362        assert_eq!(details.message.as_deref(), Some("Resource exhausted"));
363        assert_eq!(details.provider_code.as_deref(), Some("RESOURCE_EXHAUSTED"));
364    }
365
366    #[test]
367    fn parse_provider_error_body_reads_top_level_fields() {
368        let details = parse_provider_error_body(r#"{"message":"fail","type":"provider_error"}"#);
369        assert_eq!(details.message.as_deref(), Some("fail"));
370        assert_eq!(details.provider_code.as_deref(), Some("provider_error"));
371    }
372
373    #[test]
374    fn parse_provider_error_body_handles_invalid_json() {
375        let details = parse_provider_error_body("not-json");
376        assert!(details.message.is_none());
377        assert!(details.provider_code.is_none());
378    }
379
380    #[test]
381    fn parse_retry_after_value_accepts_delay_seconds() {
382        assert_eq!(parse_retry_after_value("45"), Some(Duration::from_secs(45)));
383        // Surrounding whitespace is tolerated.
384        assert_eq!(
385            parse_retry_after_value("  12 \n"),
386            Some(Duration::from_secs(12))
387        );
388    }
389
390    #[test]
391    fn parse_retry_after_value_accepts_http_date() {
392        let future = (Utc::now() + chrono::Duration::seconds(60))
393            .format("%a, %d %b %Y %H:%M:%S GMT")
394            .to_string();
395        let parsed = parse_retry_after_value(&future).expect("retry-after should parse");
396        assert!(parsed >= Duration::from_secs(55));
397        assert!(parsed <= Duration::from_secs(65));
398    }
399
400    #[test]
401    fn parse_retry_after_value_accepts_rfc3339_date() {
402        let future = (Utc::now() + chrono::Duration::seconds(90)).to_rfc3339();
403        let parsed = parse_retry_after_value(&future).expect("retry-after should parse");
404        assert!(parsed >= Duration::from_secs(85));
405        assert!(parsed <= Duration::from_secs(95));
406    }
407
408    #[test]
409    fn parse_retry_after_value_past_http_date_returns_none() {
410        let past = (Utc::now() - chrono::Duration::seconds(120))
411            .format("%a, %d %b %Y %H:%M:%S GMT")
412            .to_string();
413        assert_eq!(parse_retry_after_value(&past), None);
414    }
415
416    #[test]
417    fn parse_retry_after_value_rejects_garbage() {
418        assert_eq!(parse_retry_after_value("not a date"), None);
419        assert_eq!(parse_retry_after_value(""), None);
420    }
421
422    #[test]
423    fn map_http_status_to_error_401_maps_to_auth_error() {
424        let err = map_http_status_to_error(
425            "OpenAI",
426            401,
427            r#"{"error":{"message":"invalid key"}}"#.to_string(),
428            None,
429        );
430        match err {
431            LLMError::AuthError {
432                status_code,
433                message,
434                response_body,
435            } => {
436                assert_eq!(status_code, Some(401));
437                assert_eq!(message, "invalid key");
438                assert!(response_body.is_some());
439            }
440            other => panic!("unexpected error: {other:?}"),
441        }
442    }
443
444    #[test]
445    fn map_http_status_to_error_429_maps_to_rate_limit_with_retry_after() {
446        let err = map_http_status_to_error(
447            "OpenAI",
448            429,
449            r#"{"error":{"message":"rate limited","code":"rate_limit_exceeded"}}"#.to_string(),
450            Some(Duration::from_secs(30)),
451        );
452        match err {
453            LLMError::RateLimitError {
454                status_code,
455                message,
456                retry_after,
457                provider_code,
458                ..
459            } => {
460                assert_eq!(status_code, 429);
461                assert_eq!(message, "rate limited");
462                assert_eq!(provider_code.as_deref(), Some("rate_limit_exceeded"));
463                assert_eq!(retry_after, Some(Duration::from_secs(30)));
464            }
465            other => panic!("unexpected error: {other:?}"),
466        }
467    }
468
469    #[test]
470    fn map_http_status_to_error_400_maps_to_invalid_request() {
471        let err = map_http_status_to_error(
472            "OpenAI",
473            400,
474            r#"{"error":{"message":"bad request"}}"#.to_string(),
475            None,
476        );
477        match err {
478            LLMError::InvalidRequest {
479                message,
480                status_code,
481                response_body,
482            } => {
483                assert_eq!(message, "bad request");
484                assert_eq!(status_code, Some(400));
485                assert!(response_body.is_some());
486            }
487            other => panic!("unexpected error: {other:?}"),
488        }
489    }
490
491    #[test]
492    fn map_http_status_to_error_500_maps_to_http_status_error() {
493        let err = map_http_status_to_error("OpenAI", 500, "provider exploded".to_string(), None);
494        match err {
495            LLMError::HttpStatusError {
496                status_code,
497                message,
498                response_body,
499                ..
500            } => {
501                assert_eq!(status_code, 500);
502                assert!(message.contains("500"));
503                assert_eq!(response_body.as_ref(), "provider exploded");
504            }
505            other => panic!("unexpected error: {other:?}"),
506        }
507    }
508}
509
510#[cfg(all(test, not(target_arch = "wasm32")))]
511mod native_tests {
512    use super::*;
513    use reqwest::Client;
514
515    async fn error_for_status(status: u16, body: &str, retry_after: Option<&str>) -> LLMError {
516        let server = httpmock::MockServer::start();
517        let mock = server.mock(|when, then| {
518            when.method(httpmock::Method::GET).path("/error");
519            if let Some(value) = retry_after {
520                then.status(status).body(body).header("Retry-After", value);
521            } else {
522                then.status(status).body(body);
523            }
524        });
525
526        let client = Client::new();
527        let response = client
528            .get(format!("{}/error", server.base_url()))
529            .send()
530            .await
531            .unwrap();
532        mock.assert();
533        ensure_success(response, "TestProvider")
534            .await
535            .expect_err("expected error")
536    }
537
538    #[test]
539    fn parse_retry_after_accepts_delay_seconds() {
540        let mut headers = reqwest::header::HeaderMap::new();
541        headers.insert(reqwest::header::RETRY_AFTER, "45".parse().unwrap());
542        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(45)));
543    }
544
545    #[test]
546    fn parse_retry_after_accepts_http_date() {
547        let mut headers = reqwest::header::HeaderMap::new();
548        let future = (Utc::now() + chrono::Duration::seconds(60))
549            .format("%a, %d %b %Y %H:%M:%S GMT")
550            .to_string();
551        headers.insert(
552            reqwest::header::RETRY_AFTER,
553            future.parse().expect("valid header value"),
554        );
555        let parsed = parse_retry_after(&headers).expect("retry-after should parse");
556        assert!(parsed >= Duration::from_secs(55));
557        assert!(parsed <= Duration::from_secs(65));
558    }
559
560    #[test]
561    fn parse_retry_after_accepts_rfc3339_date() {
562        let mut headers = reqwest::header::HeaderMap::new();
563        let future = (Utc::now() + chrono::Duration::seconds(90)).to_rfc3339();
564        headers.insert(
565            reqwest::header::RETRY_AFTER,
566            future.parse().expect("valid header value"),
567        );
568        let parsed = parse_retry_after(&headers).expect("retry-after should parse");
569        assert!(parsed >= Duration::from_secs(85));
570        assert!(parsed <= Duration::from_secs(95));
571    }
572
573    #[test]
574    fn parse_retry_after_past_http_date_returns_none() {
575        let mut headers = reqwest::header::HeaderMap::new();
576        let past = (Utc::now() - chrono::Duration::seconds(120))
577            .format("%a, %d %b %Y %H:%M:%S GMT")
578            .to_string();
579        headers.insert(
580            reqwest::header::RETRY_AFTER,
581            past.parse().expect("valid header value"),
582        );
583        assert_eq!(parse_retry_after(&headers), None);
584    }
585
586    #[tokio::test]
587    async fn maps_non_standard_status_code_uses_numeric_default_message() {
588        let err = error_for_status(999, "non-standard", None).await;
589        match err {
590            LLMError::HttpStatusError {
591                message,
592                status_code,
593                ..
594            } => {
595                assert_eq!(status_code, 999);
596                assert!(message.contains("999"));
597            }
598            other => panic!("unexpected error: {other:?}"),
599        }
600    }
601
602    #[tokio::test]
603    async fn maps_401_to_auth_error() {
604        let err = error_for_status(401, r#"{"error":{"message":"invalid key"}}"#, None).await;
605        match err {
606            LLMError::AuthError {
607                status_code,
608                message,
609                ..
610            } => {
611                assert_eq!(status_code, Some(401));
612                assert_eq!(message, "invalid key");
613            }
614            other => panic!("unexpected error: {other:?}"),
615        }
616    }
617
618    #[tokio::test]
619    async fn maps_403_to_auth_error() {
620        let err = error_for_status(403, r#"{"error":{"message":"forbidden"}}"#, None).await;
621        match err {
622            LLMError::AuthError {
623                status_code,
624                message,
625                ..
626            } => {
627                assert_eq!(status_code, Some(403));
628                assert_eq!(message, "forbidden");
629            }
630            other => panic!("unexpected error: {other:?}"),
631        }
632    }
633
634    #[tokio::test]
635    async fn maps_429_to_rate_limit_error_with_retry_after() {
636        let err = error_for_status(
637            429,
638            r#"{"error":{"message":"rate limited","code":"rate_limit_exceeded"}}"#,
639            Some("30"),
640        )
641        .await;
642        match err {
643            LLMError::RateLimitError {
644                status_code,
645                message,
646                retry_after,
647                provider_code,
648                ..
649            } => {
650                assert_eq!(status_code, 429);
651                assert_eq!(message, "rate limited");
652                assert_eq!(provider_code.as_deref(), Some("rate_limit_exceeded"));
653                assert_eq!(retry_after, Some(Duration::from_secs(30)));
654            }
655            other => panic!("unexpected error: {other:?}"),
656        }
657    }
658
659    #[tokio::test]
660    async fn maps_529_to_rate_limit_error() {
661        let err = error_for_status(
662            529,
663            r#"{"error":{"message":"overloaded","type":"overloaded_error"}}"#,
664            None,
665        )
666        .await;
667        match err {
668            LLMError::RateLimitError {
669                status_code,
670                message,
671                provider_code,
672                ..
673            } => {
674                assert_eq!(status_code, 529);
675                assert_eq!(message, "overloaded");
676                assert_eq!(provider_code.as_deref(), Some("overloaded_error"));
677            }
678            other => panic!("unexpected error: {other:?}"),
679        }
680    }
681
682    #[tokio::test]
683    async fn maps_408_to_retryable_http_status_error() {
684        let err = error_for_status(408, "request timeout", None).await;
685        match err {
686            LLMError::HttpStatusError { status_code, .. } => assert_eq!(status_code, 408),
687            other => panic!("unexpected error: {other:?}"),
688        }
689        assert!(err.is_retryable());
690    }
691
692    #[tokio::test]
693    async fn maps_500_to_http_status_error() {
694        let err = error_for_status(500, "provider exploded", None).await;
695        match err {
696            LLMError::HttpStatusError {
697                status_code,
698                response_body,
699                ..
700            } => {
701                assert_eq!(status_code, 500);
702                assert_eq!(response_body.as_ref(), "provider exploded");
703            }
704            other => panic!("unexpected error: {other:?}"),
705        }
706    }
707
708    #[tokio::test]
709    async fn maps_400_to_invalid_request_with_body() {
710        let err = error_for_status(400, r#"{"error":{"message":"bad request"}}"#, None).await;
711        match err {
712            LLMError::InvalidRequest {
713                message,
714                status_code,
715                response_body,
716            } => {
717                assert_eq!(message, "bad request");
718                assert_eq!(status_code, Some(400));
719                assert!(response_body.is_some());
720            }
721            other => panic!("unexpected error: {other:?}"),
722        }
723    }
724
725    #[tokio::test]
726    async fn maps_503_to_http_status_error_with_retry_after() {
727        let err = error_for_status(503, "service unavailable", Some("300")).await;
728        match err {
729            LLMError::HttpStatusError {
730                status_code,
731                retry_after,
732                ..
733            } => {
734                assert_eq!(status_code, 503);
735                assert_eq!(retry_after, Some(Duration::from_secs(300)));
736            }
737            other => panic!("unexpected error: {other:?}"),
738        }
739    }
740
741    #[tokio::test]
742    async fn maps_plain_text_503_to_http_status_error() {
743        let err = error_for_status(503, "service unavailable", None).await;
744        match err {
745            LLMError::HttpStatusError {
746                status_code,
747                message,
748                response_body,
749                ..
750            } => {
751                assert_eq!(status_code, 503);
752                assert_eq!(
753                    message,
754                    "TestProvider API returned error status: 503 Service Unavailable"
755                );
756                assert_eq!(response_body.as_ref(), "service unavailable");
757            }
758            other => panic!("unexpected error: {other:?}"),
759        }
760    }
761
762    #[tokio::test]
763    async fn read_bounded_error_body_skips_oversized_content_length() {
764        let server = httpmock::MockServer::start();
765        let oversized_body = "x".repeat(MAX_HTTP_ERROR_BODY_BYTES + 1);
766        let _mock = server.mock(|when, then| {
767            when.method(httpmock::Method::GET).path("/huge");
768            then.status(500).body(&oversized_body);
769        });
770
771        let client = Client::new();
772        let response = client
773            .get(format!("{}/huge", server.base_url()))
774            .send()
775            .await
776            .unwrap();
777        let err = ensure_success(response, "TestProvider")
778            .await
779            .expect_err("expected error");
780
781        match err {
782            LLMError::HttpStatusError { response_body, .. } => {
783                assert!(response_body.contains("body omitted"));
784            }
785            other => panic!("unexpected error: {other:?}"),
786        }
787    }
788
789    #[tokio::test]
790    async fn read_bounded_error_body_truncates_chunked_response() {
791        let server = httpmock::MockServer::start();
792        let overflow = 8_192;
793        let oversized_body = "y".repeat(MAX_HTTP_ERROR_BODY_BYTES + overflow);
794        let _mock = server.mock(|when, then| {
795            when.method(httpmock::Method::GET).path("/chunked");
796            then.status(500)
797                .header("Transfer-Encoding", "chunked")
798                .body(&oversized_body);
799        });
800
801        let client = Client::new();
802        let response = client
803            .get(format!("{}/chunked", server.base_url()))
804            .send()
805            .await
806            .unwrap();
807
808        // Chunked responses must not advertise a Content-Length above the cap; otherwise
809        // the fast-path skip would run instead of the streaming truncation under test.
810        match response.content_length() {
811            None => {}
812            Some(content_length) => assert!(
813                content_length <= MAX_HTTP_ERROR_BODY_BYTES as u64,
814                "unexpected Content-Length for streaming test: {content_length}"
815            ),
816        }
817
818        let err = ensure_success(response, "TestProvider")
819            .await
820            .expect_err("expected error");
821
822        match err {
823            LLMError::HttpStatusError { response_body, .. } => {
824                let expected_prefix = "y".repeat(MAX_HTTP_ERROR_BODY_BYTES);
825                assert!(
826                    response_body.starts_with(&expected_prefix),
827                    "expected first {MAX_HTTP_ERROR_BODY_BYTES} bytes preserved"
828                );
829                assert!(
830                    response_body.contains("truncated after reading"),
831                    "unexpected body: {response_body}"
832                );
833                assert!(
834                    !response_body.contains("body omitted"),
835                    "expected streaming truncation, not Content-Length skip"
836                );
837                assert!(response_body.len() < oversized_body.len());
838            }
839            other => panic!("unexpected error: {other:?}"),
840        }
841    }
842
843    #[tokio::test]
844    async fn read_bounded_error_body_returns_small_body_unchanged() {
845        let err = error_for_status(500, "small error", None).await;
846        match err {
847            LLMError::HttpStatusError { response_body, .. } => {
848                assert_eq!(response_body.as_ref(), "small error");
849            }
850            other => panic!("unexpected error: {other:?}"),
851        }
852    }
853
854    #[tokio::test]
855    async fn success_response_passes_through() {
856        let server = httpmock::MockServer::start();
857        let mock = server.mock(|when, then| {
858            when.method(httpmock::Method::GET).path("/ok");
859            then.status(200).body(r#"{"ok":true}"#);
860        });
861
862        let client = Client::new();
863        let response = client
864            .get(format!("{}/ok", server.base_url()))
865            .send()
866            .await
867            .unwrap();
868        let response = ensure_success(response, "TestProvider").await.unwrap();
869        mock.assert();
870        assert_eq!(response.status(), 200);
871        assert_eq!(response.text().await.unwrap(), r#"{"ok":true}"#);
872    }
873}