Skip to main content

rustlavel_client/
lib.rs

1//! rustlavel-client: the outbound HTTP client.
2//!
3//! Written on Tokio's TCP the same way the server is, with TLS delegated to
4//! rustls — the framework writes its own protocols but never its own
5//! cryptography. It exists because the AI and MCP packages need to call out,
6//! and because an application often does too.
7//!
8//! ```ignore
9//! let response = Client::new()
10//!     .post("https://api.example.com/v1/things")
11//!     .header("authorization", format!("Bearer {token}"))
12//!     .json(Json::object([("name", "widget".into())]))
13//!     .send()
14//!     .await?;
15//! ```
16
17pub mod fake;
18pub mod stream;
19pub mod url;
20
21use rustlavel_core::events::Event;
22use rustlavel_core::{Error, Json, Result};
23use rustlavel_http::{Headers, Method, Status};
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26use tokio::io::{AsyncReadExt, AsyncWriteExt};
27use tokio::net::TcpStream;
28use url::Url;
29
30pub use fake::{Fake, FakeResponse};
31pub use stream::{Body, ServerSentEvent, SseReader};
32
33/// A response from an outbound request.
34#[derive(Debug, Clone)]
35pub struct ClientResponse {
36    pub status: Status,
37    pub headers: Headers,
38    pub body: Vec<u8>,
39}
40
41impl ClientResponse {
42    pub fn text(&self) -> String {
43        String::from_utf8_lossy(&self.body).into_owned()
44    }
45
46    pub fn json(&self) -> Result<Json> {
47        Json::parse(&self.text())
48    }
49
50    pub fn is_success(&self) -> bool {
51        self.status.is_success()
52    }
53
54    /// Turn a non-2xx response into an error, keeping the body — an API's
55    /// error message is usually the only thing that explains the failure.
56    pub fn error_for_status(self) -> Result<ClientResponse> {
57        if self.is_success() {
58            return Ok(self);
59        }
60        let body = self.text();
61        let excerpt = if body.len() > 500 { format!("{}…", &body[..500]) } else { body };
62        Err(Error::msg(format!("HTTP {}: {excerpt}", self.status)))
63    }
64}
65
66/// Shared settings for outbound requests.
67#[derive(Clone)]
68pub struct Client {
69    timeout: Duration,
70    /// How many times to retry a request that failed to connect or timed out.
71    retries: u32,
72    default_headers: Headers,
73    max_body_bytes: usize,
74    fake: Option<Arc<Fake>>,
75}
76
77impl Default for Client {
78    fn default() -> Self {
79        let mut default_headers = Headers::new();
80        default_headers.set("user-agent", concat!("rustlavel/", env!("CARGO_PKG_VERSION")));
81        default_headers.set("accept", "*/*");
82
83        Client {
84            timeout: Duration::from_secs(30),
85            retries: 0,
86            default_headers,
87            max_body_bytes: 32 * 1024 * 1024,
88            fake: None,
89        }
90    }
91}
92
93impl Client {
94    pub fn new() -> Self {
95        Client::default()
96    }
97
98    pub fn timeout(mut self, timeout: Duration) -> Self {
99        self.timeout = timeout;
100        self
101    }
102
103    /// Retry connection failures and timeouts, with exponential backoff.
104    ///
105    /// Only transport failures are retried; a 500 is not, because the request
106    /// may already have had an effect on the server.
107    pub fn retries(mut self, retries: u32) -> Self {
108        self.retries = retries;
109        self
110    }
111
112    pub fn default_header(mut self, name: &str, value: impl Into<String>) -> Self {
113        self.default_headers.set(name, value);
114        self
115    }
116
117    /// Answer from a script instead of the network, for tests.
118    ///
119    /// This is `Http::fake()` — an application's tests should never depend on
120    /// a third-party API being up.
121    pub fn faking(mut self, fake: Fake) -> Self {
122        self.fake = Some(Arc::new(fake));
123        self
124    }
125
126    pub fn fake(&self) -> Option<&Arc<Fake>> {
127        self.fake.as_ref()
128    }
129
130    pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder {
131        RequestBuilder {
132            client: self.clone(),
133            method,
134            url: url.into(),
135            headers: self.default_headers.clone(),
136            body: Vec::new(),
137        }
138    }
139
140    pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
141        self.request(Method::Get, url)
142    }
143
144    pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
145        self.request(Method::Post, url)
146    }
147
148    pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
149        self.request(Method::Put, url)
150    }
151
152    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
153        self.request(Method::Patch, url)
154    }
155
156    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
157        self.request(Method::Delete, url)
158    }
159}
160
161/// One outbound request being assembled.
162pub struct RequestBuilder {
163    client: Client,
164    method: Method,
165    url: String,
166    headers: Headers,
167    body: Vec<u8>,
168}
169
170impl RequestBuilder {
171    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
172        self.headers.set(name, value);
173        self
174    }
175
176    pub fn bearer(self, token: &str) -> Self {
177        self.header("authorization", format!("Bearer {token}"))
178    }
179
180    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
181        self.body = body.into();
182        self
183    }
184
185    pub fn json(self, value: Json) -> Self {
186        self.header("content-type", "application/json").body(value.to_string())
187    }
188
189    /// Ask for a server-sent event stream.
190    pub fn accept_events(self) -> Self {
191        self.header("accept", "text/event-stream")
192    }
193
194    pub fn method(&self) -> Method {
195        self.method
196    }
197
198    pub fn url(&self) -> &str {
199        &self.url
200    }
201
202    pub fn headers(&self) -> &Headers {
203        &self.headers
204    }
205
206    pub fn body_bytes(&self) -> &[u8] {
207        &self.body
208    }
209
210    /// Send the request and read the whole response.
211    pub async fn send(self) -> Result<ClientResponse> {
212        let started = Instant::now();
213        let method = self.method;
214        let url = self.url.clone();
215
216        // A faked client never opens a socket, so a test cannot accidentally
217        // depend on the network.
218        if let Some(fake) = self.client.fake.clone() {
219            let response = fake.respond(&self)?;
220            record(method, &url, Some(response.status), started);
221            return Ok(response);
222        }
223
224        let mut attempt = 0;
225        loop {
226            match self.send_once().await {
227                Ok(response) => {
228                    record(method, &url, Some(response.status), started);
229                    return Ok(response);
230                }
231                Err(error) if attempt < self.client.retries && is_retryable(&error) => {
232                    let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
233                    rustlavel_core::debug!("retrying {method} {url} after {error}");
234                    tokio::time::sleep(backoff).await;
235                    attempt += 1;
236                }
237                Err(error) => {
238                    record(method, &url, None, started);
239                    return Err(error);
240                }
241            }
242        }
243    }
244
245    /// Send and return the body as a stream, for server-sent events.
246    pub async fn stream(self) -> Result<Body> {
247        if let Some(fake) = self.client.fake.clone() {
248            let response = fake.respond(&self)?;
249            return Ok(Body::from_bytes(response.status, response.headers, response.body));
250        }
251
252        let url = Url::parse(&self.url)?;
253        let stream = connect(&url, self.client.timeout).await?;
254        let request = self.wire(&url);
255
256        stream::open(stream, request, self.client.timeout).await
257    }
258
259    async fn send_once(&self) -> Result<ClientResponse> {
260        let url = Url::parse(&self.url)?;
261        let mut stream = connect(&url, self.client.timeout).await?;
262        let request = self.wire(&url);
263
264        let exchange = async {
265            stream.write_all(&request).await.map_err(Error::Io)?;
266            stream.flush().await.map_err(Error::Io)?;
267            read_response(&mut stream, self.client.max_body_bytes).await
268        };
269
270        tokio::time::timeout(self.client.timeout, exchange)
271            .await
272            .map_err(|_| Error::msg(format!("{} {} timed out", self.method, self.url)))?
273    }
274
275    /// Serialize the request onto the wire.
276    fn wire(&self, url: &Url) -> Vec<u8> {
277        let mut head = format!("{} {} HTTP/1.1\r\n", self.method, url.target);
278        head.push_str(&format!("host: {}\r\n", url.authority()));
279
280        for (name, value) in self.headers.iter() {
281            if name == "host" || name == "content-length" || name == "connection" {
282                continue;
283            }
284            head.push_str(&format!("{name}: {value}\r\n"));
285        }
286
287        // One request per connection: pooling outbound connections is not worth
288        // the complexity until something measures it.
289        head.push_str("connection: close\r\n");
290        if !self.body.is_empty() || self.method.takes_body() {
291            head.push_str(&format!("content-length: {}\r\n", self.body.len()));
292        }
293        head.push_str("\r\n");
294
295        let mut out = head.into_bytes();
296        out.extend_from_slice(&self.body);
297        out
298    }
299}
300
301fn record(method: Method, url: &str, status: Option<Status>, started: Instant) {
302    if !rustlavel_core::events::has_subscribers() {
303        return;
304    }
305    let mut event = Event::new("http.client")
306        .with("method", method.as_str())
307        .with("url", url)
308        .took(started.elapsed());
309    if let Some(status) = status {
310        event = event.with("status", status.code());
311    }
312    event.dispatch();
313}
314
315/// Whether a failure is worth trying again.
316fn is_retryable(error: &Error) -> bool {
317    let text = error.to_string();
318    text.contains("timed out")
319        || text.contains("Connection refused")
320        || text.contains("connection reset")
321        || text.contains("Temporary failure")
322}
323
324/// Either a plain or a TLS-wrapped connection.
325///
326/// An enum rather than a boxed trait object: there are exactly two cases, and
327/// this keeps the read path free of dynamic dispatch.
328pub enum Connection {
329    Plain(TcpStream),
330    Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
331}
332
333impl Connection {
334    pub async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
335        match self {
336            Connection::Plain(stream) => stream.write_all(bytes).await,
337            Connection::Tls(stream) => stream.write_all(bytes).await,
338        }
339    }
340
341    pub async fn flush(&mut self) -> std::io::Result<()> {
342        match self {
343            Connection::Plain(stream) => stream.flush().await,
344            Connection::Tls(stream) => stream.flush().await,
345        }
346    }
347
348    pub async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
349        match self {
350            Connection::Plain(stream) => stream.read(buffer).await,
351            Connection::Tls(stream) => stream.read(buffer).await,
352        }
353    }
354}
355
356/// Open a connection, negotiating TLS when the URL asks for it.
357pub async fn connect(url: &Url, timeout: Duration) -> Result<Connection> {
358    let address = url.socket_address();
359
360    let tcp = tokio::time::timeout(timeout, TcpStream::connect(&address))
361        .await
362        .map_err(|_| Error::msg(format!("connecting to {address} timed out")))?
363        .map_err(|e| Error::msg(format!("cannot connect to {address}: {e}")))?;
364
365    let _ = tcp.set_nodelay(true);
366
367    if !url.secure {
368        return Ok(Connection::Plain(tcp));
369    }
370
371    let connector = tls_connector();
372    let server_name = rustls::pki_types::ServerName::try_from(url.host.clone())
373        .map_err(|_| Error::msg(format!("`{}` is not a valid TLS server name", url.host)))?;
374
375    let tls = connector
376        .connect(server_name, tcp)
377        .await
378        .map_err(|e| Error::msg(format!("TLS handshake with {} failed: {e}", url.host)))?;
379
380    Ok(Connection::Tls(Box::new(tls)))
381}
382
383/// The TLS configuration, built once and shared.
384///
385/// Trust anchors come from webpki-roots rather than the OS store, so behaviour
386/// is identical on a developer's laptop and in a scratch container.
387///
388/// The key exchange groups come from the provider chosen in `Cargo.toml`, and
389/// that choice is the one security decision in this function: with
390/// `prefer-post-quantum`, X25519MLKEM768 leads the list, so its key share goes
391/// out in the first ClientHello rather than costing a HelloRetryRequest. A
392/// server that does not know the group ignores it and picks X25519, so nothing
393/// is lost against one that has not caught up.
394fn tls_connector() -> tokio_rustls::TlsConnector {
395    use std::sync::OnceLock;
396    static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
397
398    CONNECTOR
399        .get_or_init(|| {
400            let roots = rustls::RootCertStore {
401                roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
402            };
403            let config = rustls::ClientConfig::builder()
404                .with_root_certificates(roots)
405                .with_no_client_auth();
406            tokio_rustls::TlsConnector::from(Arc::new(config))
407        })
408        .clone()
409}
410
411/// Read a complete response: status line, headers, then the body.
412async fn read_response(connection: &mut Connection, max_body: usize) -> Result<ClientResponse> {
413    let mut buffer = Vec::with_capacity(8 * 1024);
414
415    let head_end = loop {
416        if let Some(at) = find_head_end(&buffer) {
417            break at;
418        }
419        if !fill(connection, &mut buffer).await? {
420            return Err(Error::Protocol("the server closed before sending headers".into()));
421        }
422        if buffer.len() > 256 * 1024 {
423            return Err(Error::Protocol("response headers are too large".into()));
424        }
425    };
426
427    let (status, headers) = parse_head(&buffer[..head_end])?;
428    let mut body = buffer.split_off(head_end);
429
430    if headers.get("transfer-encoding").is_some_and(|te| te.contains("chunked")) {
431        body = read_chunked(connection, body, max_body).await?;
432    } else if let Some(length) = headers.content_length() {
433        if length > max_body {
434            return Err(Error::Protocol("response body is too large".into()));
435        }
436        while body.len() < length {
437            if !fill_into(connection, &mut body).await? {
438                return Err(Error::Protocol("response body ended early".into()));
439            }
440        }
441        body.truncate(length);
442    } else {
443        // No length and no chunking: the body runs until the connection closes,
444        // which is why every request asks for `connection: close`.
445        while fill_into(connection, &mut body).await? {
446            if body.len() > max_body {
447                return Err(Error::Protocol("response body is too large".into()));
448            }
449        }
450    }
451
452    Ok(ClientResponse { status, headers, body })
453}
454
455pub(crate) fn parse_head(head: &[u8]) -> Result<(Status, Headers)> {
456    let text = std::str::from_utf8(head)
457        .map_err(|_| Error::Protocol("response headers are not UTF-8".into()))?;
458    let mut lines = text.split("\r\n");
459
460    let status_line = lines.next().ok_or_else(|| Error::Protocol("empty response".into()))?;
461    let code = status_line
462        .split(' ')
463        .nth(1)
464        .and_then(|code| code.parse::<u16>().ok())
465        .ok_or_else(|| Error::Protocol(format!("malformed status line: {status_line}")))?;
466
467    let mut headers = Headers::new();
468    for line in lines {
469        if line.is_empty() {
470            continue;
471        }
472        if let Some((name, value)) = line.split_once(':') {
473            headers.append(name.trim(), value.trim());
474        }
475    }
476
477    Ok((Status(code), headers))
478}
479
480async fn read_chunked(
481    connection: &mut Connection,
482    mut buffer: Vec<u8>,
483    max_body: usize,
484) -> Result<Vec<u8>> {
485    let mut body = Vec::new();
486
487    loop {
488        let line_end = loop {
489            if let Some(at) = find_crlf(&buffer) {
490                break at;
491            }
492            if !fill_into(connection, &mut buffer).await? {
493                return Err(Error::Protocol("chunked body ended early".into()));
494            }
495        };
496
497        let header: Vec<u8> = buffer.drain(..line_end + 2).collect();
498        let size_text = String::from_utf8_lossy(&header[..line_end]);
499        let size = usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16)
500            .map_err(|_| Error::Protocol("invalid chunk size".into()))?;
501
502        if size == 0 {
503            return Ok(body);
504        }
505        if body.len() + size > max_body {
506            return Err(Error::Protocol("response body is too large".into()));
507        }
508
509        while buffer.len() < size + 2 {
510            if !fill_into(connection, &mut buffer).await? {
511                return Err(Error::Protocol("chunked body ended early".into()));
512            }
513        }
514        body.extend(buffer.drain(..size));
515        buffer.drain(..2);
516    }
517}
518
519async fn fill(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
520    fill_into(connection, buffer).await
521}
522
523async fn fill_into(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
524    let mut chunk = [0u8; 8192];
525    let read = connection.read(&mut chunk).await.map_err(Error::Io)?;
526    buffer.extend_from_slice(&chunk[..read]);
527    Ok(read > 0)
528}
529
530pub(crate) fn find_head_end(buffer: &[u8]) -> Option<usize> {
531    buffer.windows(4).position(|w| w == b"\r\n\r\n").map(|at| at + 4)
532}
533
534fn find_crlf(buffer: &[u8]) -> Option<usize> {
535    buffer.windows(2).position(|w| w == b"\r\n")
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn builds_a_request_line_and_headers() {
544        let client = Client::new();
545        let builder = client
546            .post("https://example.com/v1/things?x=1")
547            .bearer("secret")
548            .json(Json::object([("name", "widget".into())]));
549
550        let wire = String::from_utf8(builder.wire(&Url::parse(builder.url()).unwrap())).unwrap();
551
552        assert!(wire.starts_with("POST /v1/things?x=1 HTTP/1.1\r\n"));
553        assert!(wire.contains("host: example.com\r\n"));
554        assert!(wire.contains("authorization: Bearer secret\r\n"));
555        assert!(wire.contains("content-type: application/json\r\n"));
556        assert!(wire.contains("content-length: 17\r\n"));
557        assert!(wire.ends_with("\r\n\r\n{\"name\":\"widget\"}"));
558    }
559
560    #[test]
561    fn parses_a_response_head() {
562        let head = b"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n";
563        let (status, headers) = parse_head(head).unwrap();
564
565        assert_eq!(status, Status::CREATED);
566        assert_eq!(headers.content_type(), Some("application/json"));
567        assert_eq!(headers.content_length(), Some(2));
568    }
569
570    #[test]
571    fn a_failed_status_becomes_an_error_carrying_the_body() {
572        let response = ClientResponse {
573            status: Status(429),
574            headers: Headers::new(),
575            body: b"{\"error\":\"rate limited\"}".to_vec(),
576        };
577
578        let error = response.error_for_status().unwrap_err().to_string();
579        assert!(error.contains("429"));
580        assert!(error.contains("rate limited"));
581    }
582
583    #[test]
584    fn only_transport_failures_are_retried() {
585        assert!(is_retryable(&Error::msg("connecting to x timed out")));
586        assert!(is_retryable(&Error::msg("cannot connect to x: Connection refused (os error 61)")));
587        assert!(!is_retryable(&Error::msg("HTTP 500 Internal Server Error: boom")));
588    }
589
590    #[tokio::test]
591    async fn talks_to_a_real_server_over_plain_http() {
592        // The framework's own server answers this, which is the most honest
593        // end-to-end check available without the network.
594        use rustlavel_http::{Request, Response, Router, Server};
595        use rustlavel_core::Context;
596
597        let mut router = Router::new();
598        router.post("/echo", |mut req: Request| async move {
599            Response::json(Json::object([
600                ("saw", Json::from(req.input("name").unwrap_or_default())),
601                ("agent", Json::from(req.header("user-agent").unwrap_or("").to_string())),
602            ]))
603        });
604
605        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
606        let address = listener.local_addr().unwrap();
607        drop(listener);
608
609        let server = Server::new(router, Context::default());
610        tokio::spawn(async move {
611            let _ = server.listen(address.to_string()).await;
612        });
613        // Give the listener a moment to bind before the client dials it.
614        tokio::time::sleep(Duration::from_millis(150)).await;
615
616        let response = Client::new()
617            .post(format!("http://{address}/echo"))
618            .json(Json::object([("name", "ada".into())]))
619            .send()
620            .await
621            .unwrap()
622            .error_for_status()
623            .unwrap();
624
625        let body = response.json().unwrap();
626        assert_eq!(body.get("saw").unwrap().as_str(), Some("ada"));
627        assert!(body.get("agent").unwrap().as_str().unwrap().starts_with("rustlavel/"));
628    }
629
630    #[tokio::test]
631    async fn a_connection_failure_is_reported_clearly() {
632        let error = Client::new()
633            .timeout(Duration::from_millis(500))
634            .get("http://127.0.0.1:1/nope")
635            .send()
636            .await
637            .unwrap_err()
638            .to_string();
639
640        assert!(error.contains("127.0.0.1:1"), "{error}");
641    }
642
643    /// The one property of the TLS setup worth a test.
644    ///
645    /// Only the key exchange is at risk from a quantum computer, and it is at
646    /// risk *today*: an observer can record a handshake now and decrypt it once
647    /// the machine exists. Everything else in TLS — the symmetric cipher, the
648    /// certificate signature — either survives Grover comfortably or matters
649    /// only while the connection is live.
650    ///
651    /// So this asserts the hybrid group is offered, and that it is offered
652    /// first. Position is not cosmetic: rustls sends a key share only for the
653    /// leading groups, and a hybrid group listed last is one the server can
654    /// reach only by asking for a second round trip that most will not bother
655    /// with. Switching the provider back to `ring` silently loses this, which
656    /// is exactly the kind of regression a test should catch.
657    #[test]
658    fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
659        // Built exactly the way `tls_connector` builds it, so this exercises the
660        // real resolution — `builder()` picking a provider from the crate
661        // features — rather than a provider named here.
662        let config = rustls::ClientConfig::builder()
663            .with_root_certificates(rustls::RootCertStore::empty())
664            .with_no_client_auth();
665
666        let offered: Vec<String> = config
667            .crypto_provider()
668            .kx_groups
669            .iter()
670            .map(|group| format!("{:?}", group.name()))
671            .collect();
672
673        assert_eq!(
674            offered.first().map(String::as_str),
675            Some("X25519MLKEM768"),
676            "the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
677        );
678        assert!(
679            offered.iter().any(|name| name == "X25519"),
680            "a classical group must remain, for servers that do not know the hybrid: {offered:?}"
681        );
682    }
683}