Skip to main content

litellm_rust/
http.rs

1use crate::error::{LiteLLMError, Result};
2use reqwest::header::HeaderMap;
3use reqwest::StatusCode;
4use std::time::Duration;
5use tokio::time::sleep;
6
7/// Maximum buffer size for SSE streaming (16MB)
8pub const MAX_SSE_BUFFER_SIZE: usize = 16 * 1024 * 1024;
9
10/// Default retry configuration
11pub const DEFAULT_MAX_RETRIES: u32 = 3;
12pub const DEFAULT_INITIAL_BACKOFF_MS: u64 = 1000;
13pub const DEFAULT_MAX_BACKOFF_MS: u64 = 30000;
14pub const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
15
16/// Configuration for retry behavior
17#[derive(Debug, Clone)]
18pub struct RetryConfig {
19    /// Maximum number of retry attempts
20    pub max_retries: u32,
21    /// Initial backoff duration in milliseconds
22    pub initial_backoff_ms: u64,
23    /// Maximum backoff duration in milliseconds
24    pub max_backoff_ms: u64,
25    /// Multiplier for exponential backoff
26    pub backoff_multiplier: f64,
27}
28
29impl Default for RetryConfig {
30    fn default() -> Self {
31        Self {
32            max_retries: DEFAULT_MAX_RETRIES,
33            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
34            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
35            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
36        }
37    }
38}
39
40impl RetryConfig {
41    /// Calculate backoff duration for a given attempt number
42    fn backoff_duration(&self, attempt: u32) -> Duration {
43        let backoff_ms =
44            (self.initial_backoff_ms as f64) * self.backoff_multiplier.powi(attempt as i32);
45        let clamped_ms = backoff_ms.min(self.max_backoff_ms as f64) as u64;
46        Duration::from_millis(clamped_ms)
47    }
48}
49
50/// Determines if a status code is retryable
51fn is_retryable_status(status: StatusCode) -> bool {
52    matches!(
53        status,
54        StatusCode::TOO_MANY_REQUESTS
55            | StatusCode::SERVICE_UNAVAILABLE
56            | StatusCode::GATEWAY_TIMEOUT
57            | StatusCode::BAD_GATEWAY
58            | StatusCode::REQUEST_TIMEOUT
59    )
60}
61
62/// Send a JSON request and parse the response.
63///
64/// This function includes retry logic with exponential backoff for transient failures.
65pub async fn send_json<T: serde::de::DeserializeOwned>(
66    req: reqwest::RequestBuilder,
67) -> Result<(T, HeaderMap)> {
68    send_json_with_retry(req, &RetryConfig::default()).await
69}
70
71/// Send a JSON request with custom retry configuration.
72///
73/// Note: Due to reqwest::RequestBuilder not implementing Clone, the retry config
74/// is currently unused for direct builder calls. Use `with_retry` for retryable requests.
75#[allow(unused_variables)]
76pub async fn send_json_with_retry<T: serde::de::DeserializeOwned>(
77    req: reqwest::RequestBuilder,
78    retry_config: &RetryConfig,
79) -> Result<(T, HeaderMap)> {
80    // We need to clone the request for retries, but RequestBuilder doesn't implement Clone.
81    // Instead, we'll try to build the request and handle retries at a higher level.
82    // For now, we execute once - the retry logic should be applied at the call site
83    // where the builder can be recreated.
84    send_json_once(req).await
85}
86
87/// Execute a request once without retries.
88pub async fn send_json_once<T: serde::de::DeserializeOwned>(
89    req: reqwest::RequestBuilder,
90) -> Result<(T, HeaderMap)> {
91    let resp = req.send().await.map_err(LiteLLMError::from)?;
92
93    let status = resp.status();
94    let headers = resp.headers().clone();
95
96    if !status.is_success() {
97        let text = resp.text().await.map_err(LiteLLMError::from)?;
98        let trimmed = text.lines().take(20).collect::<Vec<_>>().join("\n");
99        return Err(LiteLLMError::http(format!(
100            "http {}: {}",
101            status.as_u16(),
102            trimmed
103        )));
104    }
105
106    let parsed = resp.json().await.map_err(map_json_error)?;
107    Ok((parsed, headers))
108}
109
110/// Helper to execute a request-building closure with retry logic.
111///
112/// The closure should build and return a fresh RequestBuilder for each attempt.
113pub async fn with_retry<T, F, Fut>(
114    retry_config: &RetryConfig,
115    mut build_request: F,
116) -> Result<(T, HeaderMap)>
117where
118    T: serde::de::DeserializeOwned,
119    F: FnMut() -> Fut,
120    Fut: std::future::Future<Output = Result<reqwest::RequestBuilder>>,
121{
122    let mut last_error = None;
123
124    for attempt in 0..=retry_config.max_retries {
125        let req = build_request().await?;
126        let resp = req.send().await;
127
128        match resp {
129            Ok(response) => {
130                let status = response.status();
131                let headers = response.headers().clone();
132
133                if status.is_success() {
134                    let parsed = response.json().await.map_err(map_json_error)?;
135                    return Ok((parsed, headers));
136                }
137
138                let text = response.text().await.map_err(LiteLLMError::from)?;
139
140                // Check if this is a retryable error
141                if is_retryable_status(status) && attempt < retry_config.max_retries {
142                    let backoff = retry_config.backoff_duration(attempt);
143                    sleep(backoff).await;
144                    last_error = Some(LiteLLMError::http(format!(
145                        "http {}: {}",
146                        status.as_u16(),
147                        text.lines().take(5).collect::<Vec<_>>().join("\n")
148                    )));
149                    continue;
150                }
151
152                // Non-retryable error or max retries exceeded
153                let trimmed = text.lines().take(20).collect::<Vec<_>>().join("\n");
154                return Err(LiteLLMError::http(format!(
155                    "http {}: {}",
156                    status.as_u16(),
157                    trimmed
158                )));
159            }
160            Err(e) => {
161                // Network errors are retryable
162                if attempt < retry_config.max_retries {
163                    let backoff = retry_config.backoff_duration(attempt);
164                    sleep(backoff).await;
165                    last_error = Some(LiteLLMError::from(e));
166                    continue;
167                }
168                return Err(LiteLLMError::from(e));
169            }
170        }
171    }
172
173    Err(last_error.unwrap_or_else(|| LiteLLMError::http("max retries exceeded")))
174}
175
176fn map_json_error(err: reqwest::Error) -> LiteLLMError {
177    if err.is_timeout() {
178        let message = if err.is_decode() {
179            "request timed out while reading response body"
180        } else {
181            "request timed out"
182        };
183
184        return LiteLLMError::Http {
185            message: message.into(),
186            source: Some(Box::new(err)),
187        };
188    }
189
190    LiteLLMError::Parse(err.to_string())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde::Deserialize;
197    use tokio::io::{AsyncReadExt, AsyncWriteExt};
198    use tokio::net::TcpListener;
199
200    #[test]
201    fn retry_config_backoff_calculation() {
202        let config = RetryConfig {
203            max_retries: 3,
204            initial_backoff_ms: 1000,
205            max_backoff_ms: 10000,
206            backoff_multiplier: 2.0,
207        };
208
209        assert_eq!(config.backoff_duration(0), Duration::from_millis(1000));
210        assert_eq!(config.backoff_duration(1), Duration::from_millis(2000));
211        assert_eq!(config.backoff_duration(2), Duration::from_millis(4000));
212        assert_eq!(config.backoff_duration(3), Duration::from_millis(8000));
213        // Should be clamped to max
214        assert_eq!(config.backoff_duration(4), Duration::from_millis(10000));
215    }
216
217    #[test]
218    fn retryable_status_codes() {
219        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
220        assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
221        assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT));
222        assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
223        assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT));
224
225        assert!(!is_retryable_status(StatusCode::OK));
226        assert!(!is_retryable_status(StatusCode::BAD_REQUEST));
227        assert!(!is_retryable_status(StatusCode::UNAUTHORIZED));
228        assert!(!is_retryable_status(StatusCode::NOT_FOUND));
229        assert!(!is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR));
230    }
231
232    #[derive(Debug, Deserialize)]
233    struct TestPayload {
234        #[serde(rename = "value")]
235        _value: String,
236    }
237
238    #[tokio::test]
239    async fn send_json_maps_body_read_timeouts_to_http_errors() {
240        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
241        let addr = listener.local_addr().unwrap();
242
243        tokio::spawn(async move {
244            let (mut stream, _) = listener.accept().await.unwrap();
245            let mut request_buf = [0_u8; 1024];
246            let _ = stream.read(&mut request_buf).await;
247
248            stream
249                .write_all(
250                    b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 14\r\n\r\n",
251                )
252                .await
253                .unwrap();
254
255            sleep(Duration::from_millis(100)).await;
256            let _ = stream.write_all(br#"{"value":"ok"}"#).await;
257        });
258
259        let client = reqwest::Client::builder()
260            .timeout(Duration::from_millis(50))
261            .build()
262            .unwrap();
263
264        let err = send_json_once::<TestPayload>(client.get(format!("http://{addr}/")))
265            .await
266            .unwrap_err();
267
268        match err {
269            LiteLLMError::Http { message, source } => {
270                assert!(
271                    message.contains("timed out"),
272                    "unexpected message: {message}"
273                );
274                assert!(
275                    source.is_some(),
276                    "timeout errors should retain the reqwest source"
277                );
278            }
279            other => panic!("expected http timeout error, got {other:?}"),
280        }
281    }
282}