1use std::time::Duration;
11
12use chrono::{DateTime, Utc};
13
14use crate::error::LLMError;
15
16pub(crate) const MAX_HTTP_ERROR_BODY_BYTES: usize = 65_536;
23
24#[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 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 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
121fn 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
134fn http_reason_phrase(status_code: u16) -> Option<&'static str> {
139 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
190pub(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
243pub(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#[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 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 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 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}