Skip to main content

llm_trait/
http_client.rs

1//! HTTP client abstraction trait.
2//!
3//! Decouples `RawAdapter` from `reqwest::Client`, enabling mock testing
4//! and alternative HTTP client implementations.
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use futures_core::Stream;
9use futures_util::StreamExt;
10use std::pin::Pin;
11
12use super::error::LlmError;
13use super::raw_adapter::RawRequest;
14
15/// HTTP client trait for sending requests.
16///
17/// Object-safe: `&dyn HttpClient` works.
18/// GenericProvider owns a concrete implementation and passes `&dyn HttpClient`
19/// to adapters via `execute_stream`.
20#[async_trait]
21pub trait HttpClient: Send + Sync {
22    /// Send an HTTP request and return the response.
23    async fn send(&self, request: &RawRequest) -> Result<HttpResponse, LlmError>;
24}
25
26/// Type-erased byte stream for SSE parsing.
27pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>;
28
29/// HTTP response abstraction.
30///
31/// Minimal wrapper around an HTTP response, exposing only what adapters need.
32/// No reqwest types are exposed in the public API.
33pub struct HttpResponse {
34    status: u16,
35    body_stream: Option<ByteStream>,
36    body_text: Option<String>,
37}
38
39impl HttpResponse {
40    /// Create from parts (used by ReqwestHttpClient).
41    pub fn new(status: u16, body_stream: ByteStream) -> Self {
42        Self {
43            status,
44            body_stream: Some(body_stream),
45            body_text: None,
46        }
47    }
48
49    /// Create from a pre-read text body (for error responses).
50    pub fn from_text(status: u16, body_text: String) -> Self {
51        Self {
52            status,
53            body_stream: None,
54            body_text: Some(body_text),
55        }
56    }
57
58    /// HTTP status code.
59    pub fn status(&self) -> u16 {
60        self.status
61    }
62
63    /// Whether the response is a success (2xx).
64    pub fn is_success(&self) -> bool {
65        self.status >= 200 && self.status < 300
66    }
67
68    /// Read the response body as text.
69    pub async fn text(self) -> String {
70        if let Some(text) = self.body_text {
71            return text;
72        }
73
74        // Read from stream
75        if let Some(mut stream) = self.body_stream {
76            use futures_util::StreamExt;
77            let mut body = String::new();
78            while let Some(chunk) = stream.next().await {
79                match chunk {
80                    Ok(bytes) => body.push_str(&String::from_utf8_lossy(&bytes)),
81                    Err(_) => break,
82                }
83            }
84            body
85        } else {
86            String::new()
87        }
88    }
89
90    /// Get a byte stream for SSE parsing.
91    pub fn bytes_stream(self) -> ByteStream {
92        self.body_stream
93            .unwrap_or_else(|| Box::pin(futures_util::stream::empty()))
94    }
95}
96
97/// Default HTTP client implementation using reqwest.
98pub struct ReqwestHttpClient {
99    client: reqwest::Client,
100}
101
102impl ReqwestHttpClient {
103    pub fn new(client: reqwest::Client) -> Self {
104        Self { client }
105    }
106}
107
108#[async_trait]
109impl HttpClient for ReqwestHttpClient {
110    async fn send(&self, request: &RawRequest) -> Result<HttpResponse, LlmError> {
111        use super::raw_adapter::HttpMethod;
112
113        let mut builder = match request.method {
114            HttpMethod::Post => self.client.post(&request.url),
115            HttpMethod::Get => self.client.get(&request.url),
116            HttpMethod::Put => self.client.put(&request.url),
117            HttpMethod::Delete => self.client.delete(&request.url),
118        };
119
120        for (key, value) in &request.headers {
121            builder = builder.header(key.as_str(), value.as_str());
122        }
123
124        builder = builder
125            .header("Content-Type", "application/json")
126            .json(&request.body);
127
128        let response = builder.send().await.map_err(|e| {
129            tracing::error!(error = %e, url = %request.url, "HTTP request failed");
130            LlmError::llm(format!("HTTP request failed: {e}"))
131        })?;
132
133        let status = response.status().as_u16();
134
135        // Convert reqwest byte stream to our type-erased stream
136        let reqwest_stream = response.bytes_stream();
137        let byte_stream = Box::pin(
138            reqwest_stream
139                .map(|r| r.map_err(|e| LlmError::stream(format!("Stream read error: {e}")))),
140        );
141
142        Ok(HttpResponse::new(status, byte_stream))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use bytes::Bytes;
150    use futures_util::StreamExt;
151
152    fn stream_of(chunks: Vec<Result<Bytes, LlmError>>) -> ByteStream {
153        Box::pin(futures_util::stream::iter(chunks))
154    }
155
156    #[test]
157    fn is_success_covers_only_2xx() {
158        for status in [200u16, 201, 204, 299] {
159            let response = HttpResponse::from_text(status, String::new());
160            assert_eq!(response.status(), status);
161            assert!(response.is_success(), "{status} should be a success");
162        }
163        for status in [100u16, 199, 300, 400, 401, 429, 500, 503] {
164            let response = HttpResponse::from_text(status, String::new());
165            assert!(!response.is_success(), "{status} should not be a success");
166        }
167    }
168
169    #[tokio::test]
170    async fn from_text_body_is_returned_verbatim() {
171        let body = "{\"error\":\"bad request\"}";
172        let response = HttpResponse::from_text(400, body.to_string());
173        assert_eq!(response.text().await, body);
174    }
175
176    #[tokio::test]
177    async fn text_concatenates_stream_chunks() {
178        let response = HttpResponse::new(
179            200,
180            stream_of(vec![
181                Ok(Bytes::from_static(b"data: ")),
182                Ok(Bytes::from_static(b"[DONE]")),
183            ]),
184        );
185        assert_eq!(response.text().await, "data: [DONE]");
186    }
187
188    #[tokio::test]
189    async fn text_of_an_empty_body_is_an_empty_string() {
190        assert_eq!(HttpResponse::from_text(204, String::new()).text().await, "");
191    }
192
193    #[tokio::test]
194    async fn bytes_stream_of_a_text_response_is_empty() {
195        // `from_text` carries no stream, so `bytes_stream` falls back to empty
196        // rather than panicking — error bodies still reach adapters.
197        assert_eq!(
198            HttpResponse::from_text(204, String::new())
199                .bytes_stream()
200                .count()
201                .await,
202            0
203        );
204    }
205
206    #[tokio::test]
207    async fn stream_read_error_yields_partial_body_without_panicking() {
208        // A mid-stream transport failure returns what arrived so far; adapters
209        // turn that into a Stream error, they never see a panic.
210        let response = HttpResponse::new(
211            200,
212            stream_of(vec![
213                Ok(Bytes::from_static(b"head")),
214                Err(LlmError::stream("connection reset")),
215                Ok(Bytes::from_static(b"never-read")),
216            ]),
217        );
218        assert_eq!(response.text().await, "head");
219    }
220
221    #[tokio::test]
222    async fn invalid_utf8_is_decoded_lossily() {
223        // 0xf0 starts a 4-byte sequence but 0x28 is not a continuation byte.
224        let response = HttpResponse::new(
225            200,
226            stream_of(vec![Ok(Bytes::from_static(&[0xf0, 0x28, 0x8c]))]),
227        );
228        let text = response.text().await;
229        assert!(
230            text.contains('\u{fffd}'),
231            "expected replacement char: {text:?}"
232        );
233        assert!(text.contains('('), "valid byte should survive: {text:?}");
234    }
235
236    #[tokio::test]
237    async fn bytes_stream_preserves_chunk_boundaries() {
238        // Unlike `text()`, `bytes_stream()` keeps frames separate — SSE parsers
239        // depend on not having chunk boundaries merged away.
240        let response = HttpResponse::new(
241            200,
242            stream_of(vec![
243                Ok(Bytes::from_static(b"a")),
244                Ok(Bytes::from_static(b"b")),
245                Ok(Bytes::from_static(b"c")),
246            ]),
247        );
248        let sizes: Vec<usize> = response
249            .bytes_stream()
250            .map(|c| c.unwrap().len())
251            .collect()
252            .await;
253        assert_eq!(sizes, vec![1, 1, 1]);
254    }
255}