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 breaker;
18pub mod fake;
19pub mod stream;
20pub mod url;
21
22use rustlavel_core::events::Event;
23use rustlavel_core::{Error, Json, Result};
24use rustlavel_http::{Headers, Method, Status};
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28use tokio::net::TcpStream;
29use url::Url;
30
31pub use fake::{Fake, FakeResponse};
32pub use breaker::{CircuitBreaker, Permit, State as CircuitState};
33pub use stream::{Body, ServerSentEvent, SseReader};
34
35/// A response from an outbound request.
36#[derive(Debug, Clone)]
37pub struct ClientResponse {
38    pub status: Status,
39    pub headers: Headers,
40    pub body: Vec<u8>,
41}
42
43impl ClientResponse {
44    pub fn text(&self) -> String {
45        String::from_utf8_lossy(&self.body).into_owned()
46    }
47
48    pub fn json(&self) -> Result<Json> {
49        Json::parse(&self.text())
50    }
51
52    pub fn is_success(&self) -> bool {
53        self.status.is_success()
54    }
55
56    /// Turn a non-2xx response into an error, keeping the body — an API's
57    /// error message is usually the only thing that explains the failure.
58    pub fn error_for_status(self) -> Result<ClientResponse> {
59        if self.is_success() {
60            return Ok(self);
61        }
62        let body = self.text();
63        let excerpt = if body.len() > 500 { format!("{}…", &body[..500]) } else { body };
64        Err(Error::msg(format!("HTTP {}: {excerpt}", self.status)))
65    }
66}
67
68/// Shared settings for outbound requests.
69#[derive(Clone)]
70pub struct Client {
71    timeout: Duration,
72    /// How many times to retry a request that failed to connect or timed out.
73    retries: u32,
74    default_headers: Headers,
75    max_body_bytes: usize,
76    breaker: Option<crate::breaker::CircuitBreaker>,
77    fake: Option<Arc<Fake>>,
78}
79
80impl Default for Client {
81    fn default() -> Self {
82        let mut default_headers = Headers::new();
83        default_headers.set("user-agent", concat!("rustlavel/", env!("CARGO_PKG_VERSION")));
84        default_headers.set("accept", "*/*");
85        // Compressed responses are decoded before the caller sees them, so
86        // asking for them costs nothing and saves most of the bytes of any
87        // JSON API this client talks to.
88        default_headers.set("accept-encoding", "gzip, deflate");
89
90        Client {
91            timeout: Duration::from_secs(30),
92            retries: 0,
93            default_headers,
94            max_body_bytes: 32 * 1024 * 1024,
95            breaker: None,
96            fake: None,
97        }
98    }
99}
100
101impl Client {
102    pub fn new() -> Self {
103        Client::default()
104    }
105
106    pub fn timeout(mut self, timeout: Duration) -> Self {
107        self.timeout = timeout;
108        self
109    }
110
111    /// Retry connection failures and timeouts, with exponential backoff.
112    ///
113    /// Only transport failures are retried; a 500 is not, because the request
114    /// may already have had an effect on the server.
115    pub fn retries(mut self, retries: u32) -> Self {
116        self.retries = retries;
117        self
118    }
119
120    /// Stop calling a host that is failing, and probe it before resuming.
121    ///
122    /// Pass one breaker to every client that shares an upstream, so what one
123    /// of them learns the others act on. See [`crate::breaker`].
124    pub fn breaker(mut self, breaker: crate::breaker::CircuitBreaker) -> Self {
125        self.breaker = Some(breaker);
126        self
127    }
128
129    /// The breaker this client is using, to ask about a host's state.
130    pub fn circuit(&self) -> Option<&crate::breaker::CircuitBreaker> {
131        self.breaker.as_ref()
132    }
133
134    pub fn default_header(mut self, name: &str, value: impl Into<String>) -> Self {
135        self.default_headers.set(name, value);
136        self
137    }
138
139    /// Answer from a script instead of the network, for tests.
140    ///
141    /// This is `Http::fake()` — an application's tests should never depend on
142    /// a third-party API being up.
143    pub fn faking(mut self, fake: Fake) -> Self {
144        self.fake = Some(Arc::new(fake));
145        self
146    }
147
148    pub fn fake(&self) -> Option<&Arc<Fake>> {
149        self.fake.as_ref()
150    }
151
152    pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder {
153        RequestBuilder {
154            client: self.clone(),
155            method,
156            url: url.into(),
157            headers: self.default_headers.clone(),
158            body: Vec::new(),
159        }
160    }
161
162    pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
163        self.request(Method::Get, url)
164    }
165
166    pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
167        self.request(Method::Post, url)
168    }
169
170    pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
171        self.request(Method::Put, url)
172    }
173
174    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
175        self.request(Method::Patch, url)
176    }
177
178    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
179        self.request(Method::Delete, url)
180    }
181}
182
183/// One outbound request being assembled.
184pub struct RequestBuilder {
185    client: Client,
186    method: Method,
187    url: String,
188    headers: Headers,
189    body: Vec<u8>,
190}
191
192impl RequestBuilder {
193    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
194        self.headers.set(name, value);
195        self
196    }
197
198    pub fn bearer(self, token: &str) -> Self {
199        self.header("authorization", format!("Bearer {token}"))
200    }
201
202    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
203        self.body = body.into();
204        self
205    }
206
207    pub fn json(self, value: Json) -> Self {
208        self.header("content-type", "application/json").body(value.to_string())
209    }
210
211    /// Ask for a server-sent event stream.
212    pub fn accept_events(self) -> Self {
213        self.header("accept", "text/event-stream")
214    }
215
216    pub fn method(&self) -> Method {
217        self.method
218    }
219
220    pub fn url(&self) -> &str {
221        &self.url
222    }
223
224    pub fn headers(&self) -> &Headers {
225        &self.headers
226    }
227
228    pub fn body_bytes(&self) -> &[u8] {
229        &self.body
230    }
231
232    /// Send the request and read the whole response.
233    pub async fn send(self) -> Result<ClientResponse> {
234        let started = Instant::now();
235        let method = self.method;
236        let url = self.url.clone();
237
238        // A faked client never opens a socket, so a test cannot accidentally
239        // depend on the network.
240        if let Some(fake) = self.client.fake.clone() {
241            let response = fake.respond(&self)?;
242            record(method, &url, Some(response.status), started);
243            return Ok(response);
244        }
245
246        // The breaker wraps the whole retry loop, not each attempt. Asking it
247        // per attempt would let one call spend every retry on a host already
248        // known to be down, which is the cost the breaker exists to avoid; and
249        // the retries of a single call are one verdict about the upstream, not
250        // three.
251        let permit = match (&self.client.breaker, Url::parse(&self.url)) {
252            (Some(breaker), Ok(parsed)) => Some(breaker.acquire(&parsed.authority())?),
253            _ => None,
254        };
255
256        let mut attempt = 0;
257        loop {
258            match self.send_once().await {
259                Ok(response) => {
260                    // A 5xx is the upstream failing even though the exchange
261                    // succeeded, so the breaker is told about the status
262                    // rather than about the transport.
263                    if let Some(permit) = permit {
264                        permit.record_status(response.status);
265                    }
266                    record(method, &url, Some(response.status), started);
267                    return Ok(response);
268                }
269                Err(error) if attempt < self.client.retries && is_retryable(&error) => {
270                    let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
271                    rustlavel_core::debug!("retrying {method} {url} after {error}");
272                    tokio::time::sleep(backoff).await;
273                    attempt += 1;
274                }
275                Err(error) => {
276                    // A transport failure — refused, reset, timed out — is the
277                    // clearest evidence there is that a host is unreachable.
278                    if let Some(permit) = permit {
279                        permit.failure();
280                    }
281                    record(method, &url, None, started);
282                    return Err(error);
283                }
284            }
285        }
286    }
287
288    /// Send and return the body as a stream, for server-sent events.
289    pub async fn stream(self) -> Result<Body> {
290        if let Some(fake) = self.client.fake.clone() {
291            let response = fake.respond(&self)?;
292            return Ok(Body::from_bytes(response.status, response.headers, response.body));
293        }
294
295        let url = Url::parse(&self.url)?;
296        let stream = connect(&url, self.client.timeout).await?;
297        let request = self.wire(&url);
298
299        stream::open(stream, request, self.client.timeout).await
300    }
301
302    async fn send_once(&self) -> Result<ClientResponse> {
303        let url = Url::parse(&self.url)?;
304        let mut stream = connect(&url, self.client.timeout).await?;
305        let request = self.wire(&url);
306
307        let exchange = async {
308            stream.write_all(&request).await.map_err(Error::Io)?;
309            stream.flush().await.map_err(Error::Io)?;
310            let response = read_response(&mut stream, self.method, self.client.max_body_bytes).await?;
311            decode_body(response, self.client.max_body_bytes)
312        };
313
314        tokio::time::timeout(self.client.timeout, exchange)
315            .await
316            .map_err(|_| Error::msg(format!("{} {} timed out", self.method, self.url)))?
317    }
318
319    /// Serialize the request onto the wire.
320    fn wire(&self, url: &Url) -> Vec<u8> {
321        let mut head = format!("{} {} HTTP/1.1\r\n", self.method, url.target);
322        head.push_str(&format!("host: {}\r\n", url.authority()));
323
324        for (name, value) in self.headers.iter() {
325            if name == "host" || name == "content-length" || name == "connection" {
326                continue;
327            }
328            head.push_str(&format!("{name}: {value}\r\n"));
329        }
330
331        // One request per connection: pooling outbound connections is not worth
332        // the complexity until something measures it.
333        head.push_str("connection: close\r\n");
334        if !self.body.is_empty() || self.method.takes_body() {
335            head.push_str(&format!("content-length: {}\r\n", self.body.len()));
336        }
337        head.push_str("\r\n");
338
339        let mut out = head.into_bytes();
340        out.extend_from_slice(&self.body);
341        out
342    }
343}
344
345fn record(method: Method, url: &str, status: Option<Status>, started: Instant) {
346    if !rustlavel_core::events::has_subscribers() {
347        return;
348    }
349    let mut event = Event::new("http.client")
350        .with("method", method.as_str())
351        .with("url", url)
352        .took(started.elapsed());
353    if let Some(status) = status {
354        event = event.with("status", status.code());
355    }
356    event.dispatch();
357}
358
359/// Whether a failure is worth trying again.
360fn is_retryable(error: &Error) -> bool {
361    let text = error.to_string();
362    text.contains("timed out")
363        || text.contains("Connection refused")
364        || text.contains("connection reset")
365        || text.contains("Temporary failure")
366}
367
368/// Either a plain or a TLS-wrapped connection.
369///
370/// An enum rather than a boxed trait object: there are exactly two cases, and
371/// this keeps the read path free of dynamic dispatch.
372pub enum Connection {
373    Plain(TcpStream),
374    Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
375}
376
377impl Connection {
378    pub async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
379        match self {
380            Connection::Plain(stream) => stream.write_all(bytes).await,
381            Connection::Tls(stream) => stream.write_all(bytes).await,
382        }
383    }
384
385    pub async fn flush(&mut self) -> std::io::Result<()> {
386        match self {
387            Connection::Plain(stream) => stream.flush().await,
388            Connection::Tls(stream) => stream.flush().await,
389        }
390    }
391
392    pub async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
393        match self {
394            Connection::Plain(stream) => stream.read(buffer).await,
395            Connection::Tls(stream) => stream.read(buffer).await,
396        }
397    }
398}
399
400/// Open a connection, negotiating TLS when the URL asks for it.
401pub async fn connect(url: &Url, timeout: Duration) -> Result<Connection> {
402    let address = url.socket_address();
403
404    let tcp = tokio::time::timeout(timeout, TcpStream::connect(&address))
405        .await
406        .map_err(|_| Error::msg(format!("connecting to {address} timed out")))?
407        .map_err(|e| Error::msg(format!("cannot connect to {address}: {e}")))?;
408
409    let _ = tcp.set_nodelay(true);
410
411    if !url.secure {
412        return Ok(Connection::Plain(tcp));
413    }
414
415    let connector = tls_connector();
416    let server_name = rustls::pki_types::ServerName::try_from(url.host.clone())
417        .map_err(|_| Error::msg(format!("`{}` is not a valid TLS server name", url.host)))?;
418
419    let tls = connector
420        .connect(server_name, tcp)
421        .await
422        .map_err(|e| Error::msg(format!("TLS handshake with {} failed: {e}", url.host)))?;
423
424    Ok(Connection::Tls(Box::new(tls)))
425}
426
427/// The TLS configuration, built once and shared.
428///
429/// Trust anchors come from webpki-roots rather than the OS store, so behaviour
430/// is identical on a developer's laptop and in a scratch container.
431///
432/// The key exchange groups come from the provider chosen in `Cargo.toml`, and
433/// that choice is the one security decision in this function: with
434/// `prefer-post-quantum`, X25519MLKEM768 leads the list, so its key share goes
435/// out in the first ClientHello rather than costing a HelloRetryRequest. A
436/// server that does not know the group ignores it and picks X25519, so nothing
437/// is lost against one that has not caught up.
438fn tls_connector() -> tokio_rustls::TlsConnector {
439    use std::sync::OnceLock;
440    static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
441
442    CONNECTOR
443        .get_or_init(|| {
444            let roots = rustls::RootCertStore {
445                roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
446            };
447            let config = rustls::ClientConfig::builder()
448                .with_root_certificates(roots)
449                .with_no_client_auth();
450            tokio_rustls::TlsConnector::from(Arc::new(config))
451        })
452        .clone()
453}
454
455/// Read a complete response: status line, headers, then the body.
456/// Whether a response to `method` with this status may carry a body at all.
457///
458/// RFC 9110 is explicit, and it matters more than it sounds: a `HEAD` response
459/// carries the `Content-Length` or `Transfer-Encoding` the *`GET`* would have
460/// had, while sending no body. A reader that believes those headers waits for
461/// bytes that are never coming.
462///
463/// Elasticsearch is where this surfaced — it answers `HEAD` with
464/// `Transfer-Encoding: chunked` and then writes nothing, not even the
465/// terminating zero-length chunk, so an existence check hung until it timed out
466/// and then reported a chunked-body error that named the wrong thing entirely.
467fn body_is_possible(method: Method, status: Status) -> bool {
468    // 204 and 304 are the other two the specification rules out, and a 1xx is
469    // informational rather than a response at all.
470    method != Method::Head
471        && status != Status::NO_CONTENT
472        && status != Status::NOT_MODIFIED
473        && status.code() >= 200
474}
475
476async fn read_response(
477    connection: &mut Connection,
478    method: Method,
479    max_body: usize,
480) -> Result<ClientResponse> {
481    let mut buffer = Vec::with_capacity(8 * 1024);
482
483    let head_end = loop {
484        if let Some(at) = find_head_end(&buffer) {
485            break at;
486        }
487        if !fill(connection, &mut buffer).await? {
488            return Err(Error::Protocol("the server closed before sending headers".into()));
489        }
490        if buffer.len() > 256 * 1024 {
491            return Err(Error::Protocol("response headers are too large".into()));
492        }
493    };
494
495    let (status, headers) = parse_head(&buffer[..head_end])?;
496    let mut body = buffer.split_off(head_end);
497
498    if !body_is_possible(method, status) {
499        // The headers may describe a body; the specification says there is not
500        // one. Believing the headers here is a hang, not a wrong answer.
501        return Ok(ClientResponse { status, headers, body: Vec::new() });
502    }
503
504    if headers.get("transfer-encoding").is_some_and(|te| te.contains("chunked")) {
505        body = read_chunked(connection, body, max_body).await?;
506    } else if let Some(length) = headers.content_length() {
507        if length > max_body {
508            return Err(Error::Protocol("response body is too large".into()));
509        }
510        while body.len() < length {
511            if !fill_into(connection, &mut body).await? {
512                return Err(Error::Protocol("response body ended early".into()));
513            }
514        }
515        body.truncate(length);
516    } else {
517        // No length and no chunking: the body runs until the connection closes,
518        // which is why every request asks for `connection: close`.
519        while fill_into(connection, &mut body).await? {
520            if body.len() > max_body {
521                return Err(Error::Protocol("response body is too large".into()));
522            }
523        }
524    }
525
526    Ok(ClientResponse { status, headers, body })
527}
528
529/// Undo a `Content-Encoding` the caller never asked to see.
530///
531/// The decoded body replaces the wire body and the encoding headers come off,
532/// so `response.body` is always the representation the server meant. An
533/// encoding this client did not ask for — `br`, say — is left as it came, and
534/// the header stays, so the caller can tell.
535fn decode_body(mut response: ClientResponse, max_body: usize) -> Result<ClientResponse> {
536    use rustlavel_http::compression::gzip;
537
538    let encoding = response.headers.get("content-encoding").map(|e| e.trim().to_ascii_lowercase());
539    let decoded = match encoding.as_deref() {
540        Some("gzip" | "x-gzip") => gzip::decompress_with_limit(&response.body, max_body),
541        Some("deflate") => gzip::zlib_decompress_with_limit(&response.body, max_body),
542        _ => return Ok(response),
543    };
544
545    response.body = decoded.map_err(|e| {
546        Error::Protocol(format!("the response body could not be decompressed: {e}"))
547    })?;
548    response.headers.remove("content-encoding");
549    response.headers.remove("content-length");
550    Ok(response)
551}
552
553pub(crate) fn parse_head(head: &[u8]) -> Result<(Status, Headers)> {
554    let text = std::str::from_utf8(head)
555        .map_err(|_| Error::Protocol("response headers are not UTF-8".into()))?;
556    let mut lines = text.split("\r\n");
557
558    let status_line = lines.next().ok_or_else(|| Error::Protocol("empty response".into()))?;
559    let code = status_line
560        .split(' ')
561        .nth(1)
562        .and_then(|code| code.parse::<u16>().ok())
563        .ok_or_else(|| Error::Protocol(format!("malformed status line: {status_line}")))?;
564
565    let mut headers = Headers::new();
566    for line in lines {
567        if line.is_empty() {
568            continue;
569        }
570        if let Some((name, value)) = line.split_once(':') {
571            headers.append(name.trim(), value.trim());
572        }
573    }
574
575    Ok((Status(code), headers))
576}
577
578async fn read_chunked(
579    connection: &mut Connection,
580    mut buffer: Vec<u8>,
581    max_body: usize,
582) -> Result<Vec<u8>> {
583    let mut body = Vec::new();
584
585    loop {
586        let line_end = loop {
587            if let Some(at) = find_crlf(&buffer) {
588                break at;
589            }
590            if !fill_into(connection, &mut buffer).await? {
591                return Err(Error::Protocol("chunked body ended early".into()));
592            }
593        };
594
595        let header: Vec<u8> = buffer.drain(..line_end + 2).collect();
596        let size_text = String::from_utf8_lossy(&header[..line_end]);
597        let size = usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16)
598            .map_err(|_| Error::Protocol("invalid chunk size".into()))?;
599
600        if size == 0 {
601            return Ok(body);
602        }
603        if body.len() + size > max_body {
604            return Err(Error::Protocol("response body is too large".into()));
605        }
606
607        while buffer.len() < size + 2 {
608            if !fill_into(connection, &mut buffer).await? {
609                return Err(Error::Protocol("chunked body ended early".into()));
610            }
611        }
612        body.extend(buffer.drain(..size));
613        buffer.drain(..2);
614    }
615}
616
617async fn fill(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
618    fill_into(connection, buffer).await
619}
620
621async fn fill_into(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
622    let mut chunk = [0u8; 8192];
623    let read = connection.read(&mut chunk).await.map_err(Error::Io)?;
624    buffer.extend_from_slice(&chunk[..read]);
625    Ok(read > 0)
626}
627
628pub(crate) fn find_head_end(buffer: &[u8]) -> Option<usize> {
629    buffer.windows(4).position(|w| w == b"\r\n\r\n").map(|at| at + 4)
630}
631
632fn find_crlf(buffer: &[u8]) -> Option<usize> {
633    buffer.windows(2).position(|w| w == b"\r\n")
634}
635
636#[cfg(test)]
637mod tests {
638    #[test]
639    fn a_head_response_never_has_a_body_whatever_its_headers_claim() {
640        use super::body_is_possible;
641        use rustlavel_http::{Method, Status};
642
643        // The headers on a HEAD response describe the body the GET would have
644        // returned. Reading them as a promise is a hang: Elasticsearch answers
645        // HEAD with `Transfer-Encoding: chunked` and then writes nothing at
646        // all, not even the terminating zero-length chunk.
647        assert!(!body_is_possible(Method::Head, Status::OK));
648        assert!(!body_is_possible(Method::Head, Status::NOT_FOUND));
649
650        assert!(body_is_possible(Method::Get, Status::OK));
651        assert!(body_is_possible(Method::Post, Status::CREATED));
652    }
653
654    #[test]
655    fn the_two_statuses_that_forbid_a_body_are_honoured() {
656        use super::body_is_possible;
657        use rustlavel_http::{Method, Status};
658
659        assert!(!body_is_possible(Method::Get, Status::NO_CONTENT));
660        assert!(!body_is_possible(Method::Get, Status::NOT_MODIFIED));
661        // A 304 in particular arrives with the cached response's
662        // Content-Length, which is exactly the trap above in another costume.
663    }
664
665    use super::*;
666
667    #[test]
668    fn builds_a_request_line_and_headers() {
669        let client = Client::new();
670        let builder = client
671            .post("https://example.com/v1/things?x=1")
672            .bearer("secret")
673            .json(Json::object([("name", "widget".into())]));
674
675        let wire = String::from_utf8(builder.wire(&Url::parse(builder.url()).unwrap())).unwrap();
676
677        assert!(wire.starts_with("POST /v1/things?x=1 HTTP/1.1\r\n"));
678        assert!(wire.contains("host: example.com\r\n"));
679        assert!(wire.contains("authorization: Bearer secret\r\n"));
680        assert!(wire.contains("content-type: application/json\r\n"));
681        assert!(wire.contains("content-length: 17\r\n"));
682        assert!(wire.ends_with("\r\n\r\n{\"name\":\"widget\"}"));
683    }
684
685    #[test]
686    fn parses_a_response_head() {
687        let head = b"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n";
688        let (status, headers) = parse_head(head).unwrap();
689
690        assert_eq!(status, Status::CREATED);
691        assert_eq!(headers.content_type(), Some("application/json"));
692        assert_eq!(headers.content_length(), Some(2));
693    }
694
695    #[test]
696    fn a_failed_status_becomes_an_error_carrying_the_body() {
697        let response = ClientResponse {
698            status: Status(429),
699            headers: Headers::new(),
700            body: b"{\"error\":\"rate limited\"}".to_vec(),
701        };
702
703        let error = response.error_for_status().unwrap_err().to_string();
704        assert!(error.contains("429"));
705        assert!(error.contains("rate limited"));
706    }
707
708    #[test]
709    fn only_transport_failures_are_retried() {
710        assert!(is_retryable(&Error::msg("connecting to x timed out")));
711        assert!(is_retryable(&Error::msg("cannot connect to x: Connection refused (os error 61)")));
712        assert!(!is_retryable(&Error::msg("HTTP 500 Internal Server Error: boom")));
713    }
714
715    #[tokio::test]
716    async fn talks_to_a_real_server_over_plain_http() {
717        // The framework's own server answers this, which is the most honest
718        // end-to-end check available without the network.
719        use rustlavel_http::{Request, Response, Router, Server};
720        use rustlavel_core::Context;
721
722        let mut router = Router::new();
723        router.post("/echo", |mut req: Request| async move {
724            Response::json(Json::object([
725                ("saw", Json::from(req.input("name").unwrap_or_default())),
726                ("agent", Json::from(req.header("user-agent").unwrap_or("").to_string())),
727            ]))
728        });
729
730        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
731        let address = listener.local_addr().unwrap();
732        drop(listener);
733
734        let server = Server::new(router, Context::default());
735        tokio::spawn(async move {
736            let _ = server.listen(address.to_string()).await;
737        });
738        // Give the listener a moment to bind before the client dials it.
739        tokio::time::sleep(Duration::from_millis(150)).await;
740
741        let response = Client::new()
742            .post(format!("http://{address}/echo"))
743            .json(Json::object([("name", "ada".into())]))
744            .send()
745            .await
746            .unwrap()
747            .error_for_status()
748            .unwrap();
749
750        let body = response.json().unwrap();
751        assert_eq!(body.get("saw").unwrap().as_str(), Some("ada"));
752        assert!(body.get("agent").unwrap().as_str().unwrap().starts_with("rustlavel/"));
753    }
754
755    #[tokio::test]
756    async fn a_connection_failure_is_reported_clearly() {
757        let error = Client::new()
758            .timeout(Duration::from_millis(500))
759            .get("http://127.0.0.1:1/nope")
760            .send()
761            .await
762            .unwrap_err()
763            .to_string();
764
765        assert!(error.contains("127.0.0.1:1"), "{error}");
766    }
767
768    /// The one property of the TLS setup worth a test.
769    ///
770    /// Only the key exchange is at risk from a quantum computer, and it is at
771    /// risk *today*: an observer can record a handshake now and decrypt it once
772    /// the machine exists. Everything else in TLS — the symmetric cipher, the
773    /// certificate signature — either survives Grover comfortably or matters
774    /// only while the connection is live.
775    ///
776    /// So this asserts the hybrid group is offered, and that it is offered
777    /// first. Position is not cosmetic: rustls sends a key share only for the
778    /// leading groups, and a hybrid group listed last is one the server can
779    /// reach only by asking for a second round trip that most will not bother
780    /// with. Switching the provider back to `ring` silently loses this, which
781    /// is exactly the kind of regression a test should catch.
782    #[test]
783    fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
784        // Built exactly the way `tls_connector` builds it, so this exercises the
785        // real resolution — `builder()` picking a provider from the crate
786        // features — rather than a provider named here.
787        let config = rustls::ClientConfig::builder()
788            .with_root_certificates(rustls::RootCertStore::empty())
789            .with_no_client_auth();
790
791        let offered: Vec<String> = config
792            .crypto_provider()
793            .kx_groups
794            .iter()
795            .map(|group| format!("{:?}", group.name()))
796            .collect();
797
798        assert_eq!(
799            offered.first().map(String::as_str),
800            Some("X25519MLKEM768"),
801            "the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
802        );
803        assert!(
804            offered.iter().any(|name| name == "X25519"),
805            "a classical group must remain, for servers that do not know the hybrid: {offered:?}"
806        );
807    }
808}
809
810#[cfg(test)]
811mod compression_tests {
812    use super::*;
813    use rustlavel_http::compression::gzip;
814    use tokio::net::TcpListener;
815
816    /// Serve exactly one response and close, returning the address to hit.
817    async fn one_shot(head: &'static str, body: Vec<u8>) -> String {
818        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
819        let address = listener.local_addr().unwrap();
820        tokio::spawn(async move {
821            let (mut socket, _) = listener.accept().await.unwrap();
822            let mut request = vec![0u8; 8192];
823            let _ = socket.read(&mut request).await;
824            let mut wire = format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\n{head}\r\n", body.len()).into_bytes();
825            wire.extend_from_slice(&body);
826            socket.write_all(&wire).await.unwrap();
827            let _ = socket.shutdown().await;
828        });
829        format!("http://{address}/")
830    }
831
832    #[tokio::test]
833    async fn gzip_and_deflate_bodies_are_decoded_before_the_caller_sees_them() {
834        let text = "{\"users\":[".to_string() + &"{\"name\":\"same\"},".repeat(200) + "{}]}";
835
836        let url = one_shot("content-encoding: gzip\r\ncontent-type: application/json\r\n", gzip::compress(text.as_bytes())).await;
837        let response = Client::new().get(url).send().await.unwrap();
838        assert_eq!(response.text(), text);
839        assert_eq!(response.headers.get("content-encoding"), None, "the encoding is gone with the bytes it described");
840        assert_eq!(response.headers.get("content-type"), Some("application/json"));
841
842        let url = one_shot("content-encoding: deflate\r\n", gzip::zlib_compress(text.as_bytes())).await;
843        assert_eq!(Client::new().get(url).send().await.unwrap().text(), text);
844    }
845
846    #[tokio::test]
847    async fn an_unknown_encoding_is_left_as_it_came() {
848        let url = one_shot("content-encoding: br\r\n", b"not really brotli".to_vec()).await;
849        let response = Client::new().get(url).send().await.unwrap();
850        assert_eq!(response.headers.get("content-encoding"), Some("br"));
851        assert_eq!(response.body, b"not really brotli");
852    }
853
854    #[tokio::test]
855    async fn a_corrupt_gzip_body_is_an_error_not_garbage() {
856        let url = one_shot("content-encoding: gzip\r\n", b"\x1f\x8b\x08definitely not deflate".to_vec()).await;
857        let error = Client::new().get(url).send().await.expect_err("an error").to_string();
858        assert!(error.contains("decompressed"), "{error}");
859    }
860
861    #[tokio::test]
862    async fn every_request_asks_for_compression_by_default() {
863        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
864        let address = listener.local_addr().unwrap();
865        let seen = tokio::spawn(async move {
866            let (mut socket, _) = listener.accept().await.unwrap();
867            let mut request = vec![0u8; 8192];
868            let n = socket.read(&mut request).await.unwrap();
869            socket.write_all(b"HTTP/1.1 204 No Content\r\n\r\n").await.unwrap();
870            String::from_utf8_lossy(&request[..n]).to_ascii_lowercase()
871        });
872        Client::new().get(format!("http://{address}/")).send().await.unwrap();
873        assert!(seen.await.unwrap().contains("accept-encoding: gzip, deflate"));
874    }
875}
876
877#[cfg(test)]
878mod breaker_integration_tests {
879    use super::*;
880    use crate::breaker::{CircuitBreaker, State};
881    use tokio::net::TcpListener;
882
883    /// A server that answers `status` to everything, and counts the requests
884    /// it was actually asked to serve.
885    async fn counting_server(status: &'static str) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
886        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
887        let address = listener.local_addr().unwrap();
888        let served = Arc::new(std::sync::atomic::AtomicUsize::new(0));
889        let counter = served.clone();
890
891        tokio::spawn(async move {
892            loop {
893                let Ok((mut socket, _)) = listener.accept().await else { return };
894                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
895                tokio::spawn(async move {
896                    let mut request = vec![0u8; 4096];
897                    let _ = socket.read(&mut request).await;
898                    let _ = socket
899                        .write_all(format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\n\r\n").as_bytes())
900                        .await;
901                    let _ = socket.shutdown().await;
902                });
903            }
904        });
905        (format!("http://{address}/"), served)
906    }
907
908    #[tokio::test]
909    async fn a_failing_upstream_stops_being_called_at_all() {
910        let (url, served) = counting_server("500 Internal Server Error").await;
911        let breaker = CircuitBreaker::new().minimum_calls(4).failure_rate(0.5);
912        let http = Client::new().breaker(breaker.clone());
913
914        // Four 500s: each is a real request, and the fourth opens the circuit.
915        for _ in 0..4 {
916            let response = http.get(&url).send().await.expect("the exchange succeeded");
917            assert_eq!(response.status.code(), 500);
918        }
919        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 4);
920
921        // The next twenty never reach the socket.
922        for _ in 0..20 {
923            let error = http.get(&url).send().await.expect_err("refused by the breaker");
924            assert!(matches!(error, Error::Unavailable(_)), "{error:?}");
925        }
926        assert_eq!(
927            served.load(std::sync::atomic::Ordering::SeqCst),
928            4,
929            "the server was not touched again"
930        );
931    }
932
933    #[tokio::test]
934    async fn a_healthy_upstream_is_never_interrupted() {
935        let (url, served) = counting_server("200 OK").await;
936        let http = Client::new().breaker(CircuitBreaker::new().minimum_calls(4));
937
938        for _ in 0..30 {
939            http.get(&url).send().await.expect("fine").status.code();
940        }
941        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 30);
942    }
943
944    #[tokio::test]
945    async fn a_4xx_never_opens_the_circuit() {
946        let (url, _) = counting_server("404 Not Found").await;
947        let http = Client::new().breaker(CircuitBreaker::new().minimum_calls(4));
948
949        for _ in 0..30 {
950            assert_eq!(http.get(&url).send().await.unwrap().status.code(), 404);
951        }
952        let host = Url::parse(&url).unwrap().authority();
953        assert_eq!(http.circuit().unwrap().state(&host), State::Closed);
954    }
955
956    #[tokio::test]
957    async fn an_unreachable_host_opens_the_circuit_and_retries_do_not_multiply_the_verdict() {
958        // Nothing is listening on this port.
959        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
960        let address = listener.local_addr().unwrap();
961        drop(listener);
962        let url = format!("http://{address}/");
963
964        let breaker = CircuitBreaker::new().minimum_calls(3).failure_rate(0.5);
965        let http = Client::new().retries(2).breaker(breaker.clone());
966
967        // Three calls, each retrying twice. Nine attempts, but three verdicts:
968        // one call is one opinion about the host, not three.
969        for _ in 0..3 {
970            http.get(&url).send().await.expect_err("nothing is listening");
971        }
972        assert_eq!(breaker.state(&address.to_string()), State::Open);
973    }
974
975    #[tokio::test]
976    async fn one_host_failing_does_not_stop_calls_to_another() {
977        let (broken, _) = counting_server("503 Service Unavailable").await;
978        let (healthy, served) = counting_server("200 OK").await;
979        let breaker = CircuitBreaker::new().minimum_calls(4).failure_rate(0.5);
980        let http = Client::new().breaker(breaker);
981
982        for _ in 0..6 {
983            let _ = http.get(&broken).send().await;
984        }
985        http.get(&broken).send().await.expect_err("that one is out");
986
987        for _ in 0..5 {
988            http.get(&healthy).send().await.expect("this one is fine");
989        }
990        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 5);
991    }
992
993    #[tokio::test]
994    async fn it_recovers_once_the_upstream_does() {
995        let (url, _) = counting_server("500 Internal Server Error").await;
996        let breaker = CircuitBreaker::new()
997            .minimum_calls(4)
998            .failure_rate(0.5)
999            .reset_after(Duration::from_millis(60))
1000            .probes(1);
1001        let http = Client::new().breaker(breaker.clone());
1002        let host = Url::parse(&url).unwrap().authority();
1003
1004        for _ in 0..4 {
1005            let _ = http.get(&url).send().await;
1006        }
1007        assert_eq!(breaker.state(&host), State::Open);
1008
1009        // The upstream comes back; a probe finds it and the circuit closes.
1010        tokio::time::sleep(Duration::from_millis(80)).await;
1011        let (healthy, _) = counting_server("200 OK").await;
1012        let healthy_host = Url::parse(&healthy).unwrap().authority();
1013        // Same breaker, and the probe succeeds, so that host stays closed.
1014        http.get(&healthy).send().await.expect("healthy");
1015        assert_eq!(breaker.state(&healthy_host), State::Closed);
1016        assert_eq!(breaker.state(&host), State::HalfOpen, "the broken one is still probing");
1017    }
1018
1019    #[tokio::test]
1020    async fn without_a_breaker_nothing_changes() {
1021        let (url, served) = counting_server("500 Internal Server Error").await;
1022        let http = Client::new();
1023        for _ in 0..25 {
1024            assert_eq!(http.get(&url).send().await.unwrap().status.code(), 500);
1025        }
1026        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 25, "every one was sent");
1027    }
1028}