we (web engine): Experimental web browser project to understand the limits of Claude
1//! High-level HTTP client with connection pooling and HTTP/2 support.
2//!
3//! Brings together TCP, TLS 1.3, DNS, URL parsing, HTTP/1.1, and HTTP/2
4//! into a single `HttpClient` that can fetch HTTP and HTTPS URLs.
5//! When connecting over TLS, ALPN negotiation automatically selects HTTP/2
6//! if the server supports it, falling back to HTTP/1.1 otherwise.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::io;
11use std::time::{Duration, Instant};
12
13use we_url::Url;
14
15use crate::cookie::{CookieJar, RequestContext};
16use crate::http::{self, Headers, HttpResponse, Method};
17use crate::http2::connection::Http2Connection;
18use crate::http2::frame::Http2Error;
19use crate::tcp::{self, TcpConnection};
20use crate::tls::handshake::{self, HandshakeError, TlsStream};
21
22// ---------------------------------------------------------------------------
23// Constants
24// ---------------------------------------------------------------------------
25
26const DEFAULT_MAX_REDIRECTS: u32 = 20;
27const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
28const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
29const DEFAULT_MAX_IDLE_TIME: Duration = Duration::from_secs(60);
30const DEFAULT_MAX_PER_HOST: usize = 6;
31const READ_BUF_SIZE: usize = 8192;
32
33// ---------------------------------------------------------------------------
34// Error type
35// ---------------------------------------------------------------------------
36
37/// Errors that can occur during an HTTP client operation.
38#[derive(Debug)]
39pub enum ClientError {
40 /// URL is invalid or missing required components.
41 InvalidUrl(String),
42 /// Unsupported URL scheme.
43 UnsupportedScheme(String),
44 /// TCP connection error.
45 Tcp(tcp::NetError),
46 /// TLS handshake error.
47 Tls(HandshakeError),
48 /// HTTP parsing error.
49 Http(http::HttpError),
50 /// Too many redirects.
51 TooManyRedirects,
52 /// Redirect loop detected.
53 RedirectLoop,
54 /// HTTP/2 protocol error.
55 Http2(Http2Error),
56 /// Connection was closed unexpectedly.
57 ConnectionClosed,
58 /// I/O error.
59 Io(io::Error),
60}
61
62impl fmt::Display for ClientError {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::InvalidUrl(s) => write!(f, "invalid URL: {s}"),
66 Self::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"),
67 Self::Tcp(e) => write!(f, "TCP error: {e}"),
68 Self::Tls(e) => write!(f, "TLS error: {e}"),
69 Self::Http(e) => write!(f, "HTTP error: {e}"),
70 Self::TooManyRedirects => write!(f, "too many redirects"),
71 Self::RedirectLoop => write!(f, "redirect loop detected"),
72 Self::Http2(e) => write!(f, "HTTP/2 error: {e}"),
73 Self::ConnectionClosed => write!(f, "connection closed"),
74 Self::Io(e) => write!(f, "I/O error: {e}"),
75 }
76 }
77}
78
79impl From<tcp::NetError> for ClientError {
80 fn from(e: tcp::NetError) -> Self {
81 Self::Tcp(e)
82 }
83}
84
85impl From<HandshakeError> for ClientError {
86 fn from(e: HandshakeError) -> Self {
87 Self::Tls(e)
88 }
89}
90
91impl From<http::HttpError> for ClientError {
92 fn from(e: http::HttpError) -> Self {
93 Self::Http(e)
94 }
95}
96
97impl From<io::Error> for ClientError {
98 fn from(e: io::Error) -> Self {
99 Self::Io(e)
100 }
101}
102
103impl From<Http2Error> for ClientError {
104 fn from(e: Http2Error) -> Self {
105 Self::Http2(e)
106 }
107}
108
109pub type Result<T> = std::result::Result<T, ClientError>;
110
111// ---------------------------------------------------------------------------
112// Connection abstraction
113// ---------------------------------------------------------------------------
114
115/// A connection that can be either plain TCP or TLS-encrypted.
116enum Connection {
117 Plain(TcpConnection),
118 Tls(TlsStream<TcpConnection>),
119}
120
121impl Connection {
122 fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
123 match self {
124 Self::Plain(tcp) => tcp.read(buf).map_err(ClientError::Tcp),
125 Self::Tls(tls) => tls.read(buf).map_err(ClientError::Tls),
126 }
127 }
128
129 fn write_all(&mut self, data: &[u8]) -> Result<()> {
130 match self {
131 Self::Plain(tcp) => tcp.write_all(data).map_err(ClientError::Tcp),
132 Self::Tls(tls) => tls.write_all(data).map_err(ClientError::Tls),
133 }
134 }
135
136 fn flush(&mut self) -> Result<()> {
137 match self {
138 Self::Plain(tcp) => tcp.flush().map_err(ClientError::Tcp),
139 Self::Tls(_) => Ok(()), // TLS writes are flushed per record
140 }
141 }
142
143 fn set_read_timeout(&self, duration: Option<Duration>) -> Result<()> {
144 match self {
145 Self::Plain(tcp) => tcp.set_read_timeout(duration).map_err(ClientError::Tcp),
146 Self::Tls(tls) => tls
147 .stream()
148 .set_read_timeout(duration)
149 .map_err(ClientError::Tcp),
150 }
151 }
152}
153
154// ---------------------------------------------------------------------------
155// Connection pool
156// ---------------------------------------------------------------------------
157
158/// Key for pooling connections by origin.
159#[derive(Hash, Eq, PartialEq, Clone, Debug)]
160struct ConnectionKey {
161 host: String,
162 port: u16,
163 is_tls: bool,
164}
165
166/// A pooled connection with its idle timestamp.
167struct PooledConnection {
168 conn: Connection,
169 idle_since: Instant,
170}
171
172/// Pool of idle HTTP connections for reuse.
173struct ConnectionPool {
174 connections: HashMap<ConnectionKey, Vec<PooledConnection>>,
175 max_idle_time: Duration,
176 max_per_host: usize,
177}
178
179impl ConnectionPool {
180 fn new(max_idle_time: Duration, max_per_host: usize) -> Self {
181 Self {
182 connections: HashMap::new(),
183 max_idle_time,
184 max_per_host,
185 }
186 }
187
188 /// Take an idle connection for the given key, if one is available.
189 fn take(&mut self, key: &ConnectionKey) -> Option<Connection> {
190 let entries = self.connections.get_mut(key)?;
191 let now = Instant::now();
192
193 // Remove expired connections
194 entries.retain(|pc| now.duration_since(pc.idle_since) < self.max_idle_time);
195
196 // Take the most recently idled connection
197 entries.pop().map(|pc| pc.conn)
198 }
199
200 /// Return a connection to the pool.
201 fn put(&mut self, key: ConnectionKey, conn: Connection) {
202 let entries = self.connections.entry(key).or_default();
203
204 // Evict oldest if at capacity
205 if entries.len() >= self.max_per_host {
206 entries.remove(0);
207 }
208
209 entries.push(PooledConnection {
210 conn,
211 idle_since: Instant::now(),
212 });
213 }
214}
215
216// ---------------------------------------------------------------------------
217// HttpClient
218// ---------------------------------------------------------------------------
219
220/// Key for HTTP/2 connection pooling (one multiplexed connection per origin).
221#[derive(Hash, Eq, PartialEq, Clone, Debug)]
222struct H2ConnectionKey {
223 host: String,
224 port: u16,
225}
226
227/// High-level HTTP client with connection pooling, HTTP/2 support, redirect following, and cookie jar.
228pub struct HttpClient {
229 pool: ConnectionPool,
230 h2_connections: HashMap<H2ConnectionKey, Http2Connection<TlsStream<TcpConnection>>>,
231 max_redirects: u32,
232 connect_timeout: Duration,
233 read_timeout: Duration,
234 cookie_jar: CookieJar,
235}
236
237impl HttpClient {
238 /// Create a new HTTP client with default settings.
239 pub fn new() -> Self {
240 Self {
241 pool: ConnectionPool::new(DEFAULT_MAX_IDLE_TIME, DEFAULT_MAX_PER_HOST),
242 h2_connections: HashMap::new(),
243 max_redirects: DEFAULT_MAX_REDIRECTS,
244 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
245 read_timeout: DEFAULT_READ_TIMEOUT,
246 cookie_jar: CookieJar::new(),
247 }
248 }
249
250 /// Estimated heap bytes used by the HTTP client's connection pool and buffers.
251 pub fn memory_usage(&self) -> usize {
252 use std::mem::size_of;
253 let pool_conns: usize = self
254 .pool
255 .connections
256 .iter()
257 .map(|(k, v)| k.host.capacity() + v.capacity() * size_of::<PooledConnection>())
258 .sum();
259 let pool_overhead = self.pool.connections.capacity()
260 * (size_of::<ConnectionKey>() + size_of::<Vec<PooledConnection>>() + 8);
261 let h2_conns = self.h2_connections.capacity() * (size_of::<H2ConnectionKey>() + 256); // estimate for Http2Connection
262 let h2_keys: usize = self.h2_connections.keys().map(|k| k.host.capacity()).sum();
263 pool_conns + pool_overhead + h2_conns + h2_keys
264 }
265
266 /// Number of pooled idle HTTP/1.1 connections.
267 pub fn pooled_connection_count(&self) -> usize {
268 self.pool.connections.values().map(|v| v.len()).sum()
269 }
270
271 /// Number of active HTTP/2 connections.
272 pub fn h2_connection_count(&self) -> usize {
273 self.h2_connections.len()
274 }
275
276 /// Get a reference to the cookie jar.
277 pub fn cookie_jar(&self) -> &CookieJar {
278 &self.cookie_jar
279 }
280
281 /// Get a mutable reference to the cookie jar.
282 pub fn cookie_jar_mut(&mut self) -> &mut CookieJar {
283 &mut self.cookie_jar
284 }
285
286 /// Set the maximum number of redirects to follow.
287 pub fn set_max_redirects(&mut self, max: u32) {
288 self.max_redirects = max;
289 }
290
291 /// Set the connection timeout.
292 pub fn set_connect_timeout(&mut self, timeout: Duration) {
293 self.connect_timeout = timeout;
294 }
295
296 /// Set the read timeout.
297 pub fn set_read_timeout(&mut self, timeout: Duration) {
298 self.read_timeout = timeout;
299 }
300
301 /// Perform an HTTP GET request.
302 pub fn get(&mut self, url: &Url) -> Result<HttpResponse> {
303 self.request(Method::Get, url, &Headers::new(), None)
304 }
305
306 /// Perform an HTTP POST request.
307 pub fn post(&mut self, url: &Url, body: &[u8], content_type: &str) -> Result<HttpResponse> {
308 let mut headers = Headers::new();
309 headers.add("Content-Type", content_type);
310 self.request(Method::Post, url, &headers, Some(body))
311 }
312
313 /// Perform an HTTP request with full control over method, headers, and body.
314 ///
315 /// Follows redirects (301, 302, 303, 307, 308) up to `max_redirects`.
316 ///
317 /// - 301/302/303: method changes to GET, body is dropped
318 /// - 307/308: original method and body are preserved
319 /// - Cross-origin redirects strip the `Authorization` header
320 /// - Redirect loops are detected and produce an error
321 pub fn request(
322 &mut self,
323 method: Method,
324 url: &Url,
325 headers: &Headers,
326 body: Option<&[u8]>,
327 ) -> Result<HttpResponse> {
328 let mut current_url = url.clone();
329 let mut current_method = method;
330 let mut current_body = body.map(|b| b.to_vec());
331 let mut current_headers = headers.clone();
332 let mut redirects = 0u32;
333 let original_origin = url.origin();
334 let mut visited_urls = vec![current_url.serialize()];
335
336 loop {
337 let resp = self.execute_request(
338 current_method,
339 ¤t_url,
340 ¤t_headers,
341 current_body.as_deref(),
342 )?;
343
344 // Check for redirects
345 if is_redirect_status(resp.status_code) {
346 redirects += 1;
347 if redirects > self.max_redirects {
348 return Err(ClientError::TooManyRedirects);
349 }
350
351 if let Some(location) = resp.headers.get("Location") {
352 // Resolve relative URLs against current URL
353 let next_url = Url::parse_with_base(location, ¤t_url)
354 .or_else(|_| Url::parse(location))
355 .map_err(|_| {
356 ClientError::InvalidUrl(format!(
357 "invalid redirect location: {location}"
358 ))
359 })?;
360
361 // Detect redirect loops
362 let next_serialized = next_url.serialize();
363 if visited_urls.contains(&next_serialized) {
364 return Err(ClientError::RedirectLoop);
365 }
366 visited_urls.push(next_serialized);
367
368 // Handle method changes per HTTP spec
369 current_method = redirect_method(resp.status_code, current_method);
370 if !redirect_preserves_body(resp.status_code) {
371 current_body = None;
372 }
373
374 // Strip Authorization header on cross-origin redirects
375 if !next_url.origin().same_origin(&original_origin) {
376 current_headers.remove("Authorization");
377 }
378
379 current_url = next_url;
380 continue;
381 }
382 }
383
384 // Attach redirect metadata to the response
385 let mut resp = resp;
386 if redirects > 0 {
387 resp.redirected = true;
388 resp.final_url = Some(current_url.serialize());
389 }
390
391 return Ok(resp);
392 }
393 }
394
395 /// Execute a single HTTP request (no redirect following).
396 fn execute_request(
397 &mut self,
398 method: Method,
399 url: &Url,
400 headers: &Headers,
401 body: Option<&[u8]>,
402 ) -> Result<HttpResponse> {
403 let scheme = url.scheme();
404 let is_tls = match scheme {
405 "https" => true,
406 "http" => false,
407 other => return Err(ClientError::UnsupportedScheme(other.to_string())),
408 };
409
410 let host = url
411 .host_str()
412 .ok_or_else(|| ClientError::InvalidUrl("missing host".to_string()))?;
413
414 let port = url
415 .port_or_default()
416 .ok_or_else(|| ClientError::InvalidUrl("cannot determine port".to_string()))?;
417
418 let path = request_path(url);
419
420 // Build request headers, attaching cookies from the jar.
421 let mut merged_headers = Headers::new();
422 for (name, value) in headers.iter() {
423 merged_headers.add(name, value);
424 }
425 if !merged_headers.contains("Cookie") {
426 if let Some(cookie_val) = self
427 .cookie_jar
428 .cookie_header_value(url, RequestContext::SameSite)
429 {
430 merged_headers.add("Cookie", &cookie_val);
431 }
432 }
433
434 // Try HTTP/2 for TLS connections
435 if is_tls {
436 let h2_key = H2ConnectionKey {
437 host: host.clone(),
438 port,
439 };
440
441 // Check if we have an existing HTTP/2 connection for this origin
442 if self.h2_connections.contains_key(&h2_key) {
443 let response = self.execute_h2_request(
444 &h2_key,
445 method,
446 &path,
447 &host,
448 &merged_headers,
449 body,
450 url,
451 );
452
453 match response {
454 Ok(resp) => return Ok(resp),
455 Err(ClientError::Http2(_)) => {
456 // HTTP/2 connection failed — remove it and fall through to new connection
457 self.h2_connections.remove(&h2_key);
458 }
459 Err(e) => return Err(e),
460 }
461 }
462
463 // Try to establish a new connection with ALPN
464 let tcp = TcpConnection::connect_timeout(&host, port, self.connect_timeout)?;
465 let (tls, alpn) = handshake::connect_with_alpn(tcp, &host, &["h2", "http/1.1"])?;
466
467 if alpn.as_deref() == Some("h2") {
468 // HTTP/2 negotiated — create HTTP/2 connection
469 let h2_conn = Http2Connection::new(tls)?;
470 self.h2_connections.insert(h2_key.clone(), h2_conn);
471
472 return self.execute_h2_request(
473 &h2_key,
474 method,
475 &path,
476 &host,
477 &merged_headers,
478 body,
479 url,
480 );
481 }
482
483 // HTTP/1.1 over TLS — use normal path
484 let conn = Connection::Tls(tls);
485 return self.execute_h1_request(conn, method, &path, &host, &merged_headers, body, url);
486 }
487
488 // Plain HTTP (no TLS) — always HTTP/1.1
489 let key = ConnectionKey {
490 host: host.clone(),
491 port,
492 is_tls: false,
493 };
494
495 let conn = match self.pool.take(&key) {
496 Some(conn) => conn,
497 None => {
498 let tcp = TcpConnection::connect_timeout(&host, port, self.connect_timeout)?;
499 Connection::Plain(tcp)
500 }
501 };
502
503 self.execute_h1_request(conn, method, &path, &host, &merged_headers, body, url)
504 }
505
506 /// Execute an HTTP/1.1 request over the given connection.
507 #[allow(clippy::too_many_arguments)]
508 fn execute_h1_request(
509 &mut self,
510 mut conn: Connection,
511 method: Method,
512 path: &str,
513 host: &str,
514 headers: &Headers,
515 body: Option<&[u8]>,
516 url: &Url,
517 ) -> Result<HttpResponse> {
518 conn.set_read_timeout(Some(self.read_timeout))?;
519
520 let request_bytes = http::serialize_request(method, path, host, headers, body);
521 conn.write_all(&request_bytes)?;
522 conn.flush()?;
523
524 let response = read_response(&mut conn)?;
525
526 // Store Set-Cookie headers
527 let set_cookies: Vec<String> = response
528 .headers
529 .get_all("Set-Cookie")
530 .into_iter()
531 .map(|s| s.to_string())
532 .collect();
533 for header in &set_cookies {
534 self.cookie_jar.store_from_header(header, url);
535 }
536
537 // Return connection to pool if keep-alive
538 if !response.connection_close() {
539 // Determine the connection key for pooling
540 let is_tls = matches!(conn, Connection::Tls(_));
541 let port = url
542 .port_or_default()
543 .unwrap_or(if is_tls { 443 } else { 80 });
544 let key = ConnectionKey {
545 host: host.to_string(),
546 port,
547 is_tls,
548 };
549 self.pool.put(key, conn);
550 }
551
552 Ok(response)
553 }
554
555 /// Execute a request over an existing HTTP/2 connection.
556 #[allow(clippy::too_many_arguments)]
557 fn execute_h2_request(
558 &mut self,
559 h2_key: &H2ConnectionKey,
560 method: Method,
561 path: &str,
562 authority: &str,
563 headers: &Headers,
564 body: Option<&[u8]>,
565 url: &Url,
566 ) -> Result<HttpResponse> {
567 let extra_headers: Vec<(String, String)> = headers
568 .iter()
569 .filter(|(name, _)| {
570 // Skip pseudo-headers and host (authority is used instead)
571 let lower = name.to_ascii_lowercase();
572 lower != "host" && lower != "connection" && lower != "transfer-encoding"
573 })
574 .map(|(name, value)| (name.to_ascii_lowercase(), value.to_string()))
575 .collect();
576
577 let h2_conn = self.h2_connections.get_mut(h2_key).unwrap();
578
579 let stream_id =
580 h2_conn.send_request(method.as_str(), path, authority, &extra_headers, body)?;
581
582 let (resp_headers, resp_body, status_code) = h2_conn.read_response(stream_id)?;
583
584 // Convert HTTP/2 response to HttpResponse
585 let mut response_headers = Headers::new();
586 for (name, value) in &resp_headers {
587 let name_str = String::from_utf8_lossy(name);
588 let value_str = String::from_utf8_lossy(value);
589 if !name_str.starts_with(':') {
590 response_headers.add(&name_str, &value_str);
591 }
592 }
593
594 // Store Set-Cookie headers
595 let set_cookies: Vec<String> = response_headers
596 .get_all("Set-Cookie")
597 .into_iter()
598 .map(|s| s.to_string())
599 .collect();
600 for header in &set_cookies {
601 self.cookie_jar.store_from_header(header, url);
602 }
603
604 Ok(HttpResponse {
605 version: "HTTP/2".to_string(),
606 status_code,
607 reason: reason_phrase(status_code).to_string(),
608 headers: response_headers,
609 body: resp_body,
610 redirected: false,
611 final_url: None,
612 })
613 }
614}
615
616impl Default for HttpClient {
617 fn default() -> Self {
618 Self::new()
619 }
620}
621
622// ---------------------------------------------------------------------------
623// Helpers
624// ---------------------------------------------------------------------------
625
626/// Build the request path from a URL (path + query).
627fn request_path(url: &Url) -> String {
628 let path = url.path();
629 let path = if path.is_empty() { "/" } else { &path };
630 match url.query() {
631 Some(q) => format!("{path}?{q}"),
632 None => path.to_string(),
633 }
634}
635
636/// Read a complete HTTP response from a connection.
637///
638/// Reads the header section first, then determines the body length from
639/// headers and reads the appropriate amount of body data.
640fn read_response(conn: &mut Connection) -> Result<HttpResponse> {
641 let mut buf = Vec::with_capacity(READ_BUF_SIZE);
642 let mut temp = [0u8; READ_BUF_SIZE];
643
644 // Phase 1: Read until we have the complete header section (\r\n\r\n)
645 let header_end = loop {
646 let n = conn.read(&mut temp)?;
647 if n == 0 {
648 if buf.is_empty() {
649 return Err(ClientError::ConnectionClosed);
650 }
651 break find_header_end(&buf);
652 }
653 buf.extend_from_slice(&temp[..n]);
654 if let Some(pos) = find_header_end(&buf) {
655 break Some(pos);
656 }
657 };
658
659 let header_end = header_end.ok_or(ClientError::Http(http::HttpError::Incomplete))?;
660 let body_start = header_end + 4; // skip \r\n\r\n
661
662 // Quick-parse headers to determine body strategy
663 let header_str = std::str::from_utf8(&buf[..header_end]).map_err(|_| {
664 ClientError::Http(http::HttpError::Parse(
665 "invalid UTF-8 in headers".to_string(),
666 ))
667 })?;
668
669 let status_code = parse_status_code(header_str)?;
670 let body_strategy = determine_body_strategy(header_str, status_code);
671
672 // Phase 2: Read body according to strategy
673 match body_strategy {
674 BodyStrategy::NoBody => {
675 // Truncate buffer to just headers + \r\n\r\n
676 buf.truncate(body_start);
677 }
678 BodyStrategy::ContentLength(len) => {
679 let total_needed = body_start + len;
680 while buf.len() < total_needed {
681 let n = conn.read(&mut temp)?;
682 if n == 0 {
683 break;
684 }
685 buf.extend_from_slice(&temp[..n]);
686 }
687 }
688 BodyStrategy::Chunked => {
689 // Read until we find the terminating 0-length chunk
690 while !has_chunked_terminator(&buf[body_start..]) {
691 let n = conn.read(&mut temp)?;
692 if n == 0 {
693 break;
694 }
695 buf.extend_from_slice(&temp[..n]);
696 }
697 }
698 BodyStrategy::ReadUntilClose => {
699 // Read until EOF
700 loop {
701 let n = conn.read(&mut temp)?;
702 if n == 0 {
703 break;
704 }
705 buf.extend_from_slice(&temp[..n]);
706 }
707 }
708 }
709
710 // Parse the complete response
711 http::parse_response(&buf).map_err(ClientError::Http)
712}
713
714/// Find the end of the HTTP header section (\r\n\r\n).
715/// Returns the position of the first \r in the \r\n\r\n sequence.
716fn find_header_end(data: &[u8]) -> Option<usize> {
717 data.windows(4).position(|w| w == b"\r\n\r\n")
718}
719
720/// Extract status code from the first line of headers.
721fn parse_status_code(headers: &str) -> Result<u16> {
722 let first_line = headers.lines().next().unwrap_or("");
723 let mut parts = first_line.splitn(3, ' ');
724 let _version = parts.next();
725 let code_str = parts.next().unwrap_or("");
726 code_str.parse().map_err(|_| {
727 ClientError::Http(http::HttpError::MalformedStatusLine(first_line.to_string()))
728 })
729}
730
731/// Strategy for reading the response body.
732enum BodyStrategy {
733 NoBody,
734 ContentLength(usize),
735 Chunked,
736 ReadUntilClose,
737}
738
739/// Extract the value for a header name (case-insensitive match).
740fn header_value<'a>(line: &'a str, name: &str) -> Option<&'a str> {
741 let colon = line.find(':')?;
742 if line[..colon].eq_ignore_ascii_case(name) {
743 Some(line[colon + 1..].trim())
744 } else {
745 None
746 }
747}
748
749/// Determine how to read the body from headers.
750fn determine_body_strategy(headers: &str, status_code: u16) -> BodyStrategy {
751 // 1xx, 204, 304 have no body
752 if status_code < 200 || status_code == 204 || status_code == 304 {
753 return BodyStrategy::NoBody;
754 }
755
756 // Check for Transfer-Encoding: chunked
757 for line in headers.split("\r\n").skip(1) {
758 if let Some(val) = header_value(line, "transfer-encoding") {
759 if val.eq_ignore_ascii_case("chunked") {
760 return BodyStrategy::Chunked;
761 }
762 }
763 }
764
765 // Check for Content-Length
766 for line in headers.split("\r\n").skip(1) {
767 if let Some(val) = header_value(line, "content-length") {
768 if let Ok(len) = val.parse::<usize>() {
769 return BodyStrategy::ContentLength(len);
770 }
771 }
772 }
773
774 BodyStrategy::ReadUntilClose
775}
776
777/// Standard HTTP reason phrase for a status code.
778fn reason_phrase(status: u16) -> &'static str {
779 match status {
780 200 => "OK",
781 201 => "Created",
782 204 => "No Content",
783 301 => "Moved Permanently",
784 302 => "Found",
785 303 => "See Other",
786 304 => "Not Modified",
787 307 => "Temporary Redirect",
788 308 => "Permanent Redirect",
789 400 => "Bad Request",
790 401 => "Unauthorized",
791 403 => "Forbidden",
792 404 => "Not Found",
793 405 => "Method Not Allowed",
794 500 => "Internal Server Error",
795 502 => "Bad Gateway",
796 503 => "Service Unavailable",
797 _ => "",
798 }
799}
800
801/// Determine whether a status code is a redirect that should be followed.
802fn is_redirect_status(status: u16) -> bool {
803 matches!(status, 301 | 302 | 303 | 307 | 308)
804}
805
806/// Determine the method for the next request after a redirect.
807///
808/// Per HTTP spec:
809/// - 301/302/303: change to GET (drop body)
810/// - 307/308: preserve original method
811fn redirect_method(status: u16, original_method: Method) -> Method {
812 match status {
813 301..=303 => Method::Get,
814 _ => original_method,
815 }
816}
817
818/// Determine whether body should be preserved after a redirect.
819///
820/// Body is dropped for 301/302/303, preserved for 307/308.
821fn redirect_preserves_body(status: u16) -> bool {
822 matches!(status, 307 | 308)
823}
824
825/// Check if chunked body data contains the terminating `0\r\n\r\n`.
826fn has_chunked_terminator(data: &[u8]) -> bool {
827 // Look for \r\n0\r\n\r\n (the final chunk after some data) or 0\r\n\r\n at start
828 data.windows(5).any(|w| w == b"0\r\n\r\n")
829}
830
831// ---------------------------------------------------------------------------
832// Tests
833// ---------------------------------------------------------------------------
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 // -- ClientError Display tests --
840
841 #[test]
842 fn error_display_invalid_url() {
843 let e = ClientError::InvalidUrl("bad".to_string());
844 assert_eq!(e.to_string(), "invalid URL: bad");
845 }
846
847 #[test]
848 fn error_display_unsupported_scheme() {
849 let e = ClientError::UnsupportedScheme("ftp".to_string());
850 assert_eq!(e.to_string(), "unsupported scheme: ftp");
851 }
852
853 #[test]
854 fn error_display_too_many_redirects() {
855 let e = ClientError::TooManyRedirects;
856 assert_eq!(e.to_string(), "too many redirects");
857 }
858
859 #[test]
860 fn error_display_connection_closed() {
861 let e = ClientError::ConnectionClosed;
862 assert_eq!(e.to_string(), "connection closed");
863 }
864
865 // -- HttpClient configuration tests --
866
867 #[test]
868 fn client_default() {
869 let client = HttpClient::default();
870 assert_eq!(client.max_redirects, DEFAULT_MAX_REDIRECTS);
871 assert_eq!(client.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
872 assert_eq!(client.read_timeout, DEFAULT_READ_TIMEOUT);
873 }
874
875 #[test]
876 fn client_set_max_redirects() {
877 let mut client = HttpClient::new();
878 client.set_max_redirects(5);
879 assert_eq!(client.max_redirects, 5);
880 }
881
882 #[test]
883 fn client_set_connect_timeout() {
884 let mut client = HttpClient::new();
885 client.set_connect_timeout(Duration::from_secs(10));
886 assert_eq!(client.connect_timeout, Duration::from_secs(10));
887 }
888
889 #[test]
890 fn client_set_read_timeout() {
891 let mut client = HttpClient::new();
892 client.set_read_timeout(Duration::from_secs(5));
893 assert_eq!(client.read_timeout, Duration::from_secs(5));
894 }
895
896 // -- ConnectionPool tests --
897
898 #[test]
899 fn pool_take_empty() {
900 let mut pool = ConnectionPool::new(Duration::from_secs(60), 6);
901 let key = ConnectionKey {
902 host: "example.com".to_string(),
903 port: 80,
904 is_tls: false,
905 };
906 assert!(pool.take(&key).is_none());
907 }
908
909 #[test]
910 fn pool_connections_map_starts_empty() {
911 let pool = ConnectionPool::new(Duration::from_secs(60), 6);
912 assert!(pool.connections.is_empty());
913 }
914
915 // -- request_path tests --
916
917 #[test]
918 fn request_path_simple() {
919 let url = Url::parse("http://example.com/path").unwrap();
920 assert_eq!(request_path(&url), "/path");
921 }
922
923 #[test]
924 fn request_path_with_query() {
925 let url = Url::parse("http://example.com/path?key=value").unwrap();
926 assert_eq!(request_path(&url), "/path?key=value");
927 }
928
929 #[test]
930 fn request_path_root() {
931 let url = Url::parse("http://example.com").unwrap();
932 assert_eq!(request_path(&url), "/");
933 }
934
935 #[test]
936 fn request_path_deep() {
937 let url = Url::parse("http://example.com/a/b/c").unwrap();
938 assert_eq!(request_path(&url), "/a/b/c");
939 }
940
941 // -- find_header_end tests --
942
943 #[test]
944 fn find_header_end_found() {
945 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n\r\nbody";
946 assert_eq!(find_header_end(data), Some(24));
947 }
948
949 #[test]
950 fn find_header_end_not_found() {
951 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n";
952 assert_eq!(find_header_end(data), None);
953 }
954
955 #[test]
956 fn find_header_end_empty() {
957 assert_eq!(find_header_end(b""), None);
958 }
959
960 #[test]
961 fn find_header_end_minimal() {
962 let data = b"\r\n\r\n";
963 assert_eq!(find_header_end(data), Some(0));
964 }
965
966 // -- parse_status_code tests --
967
968 #[test]
969 fn parse_status_code_200() {
970 assert_eq!(parse_status_code("HTTP/1.1 200 OK").unwrap(), 200);
971 }
972
973 #[test]
974 fn parse_status_code_404() {
975 assert_eq!(parse_status_code("HTTP/1.1 404 Not Found").unwrap(), 404);
976 }
977
978 #[test]
979 fn parse_status_code_301() {
980 assert_eq!(
981 parse_status_code("HTTP/1.1 301 Moved Permanently").unwrap(),
982 301
983 );
984 }
985
986 #[test]
987 fn parse_status_code_invalid() {
988 assert!(parse_status_code("INVALID").is_err());
989 }
990
991 // -- determine_body_strategy tests --
992
993 #[test]
994 fn strategy_no_body_204() {
995 let headers = "HTTP/1.1 204 No Content\r\nConnection: keep-alive";
996 assert!(matches!(
997 determine_body_strategy(headers, 204),
998 BodyStrategy::NoBody
999 ));
1000 }
1001
1002 #[test]
1003 fn strategy_no_body_304() {
1004 let headers = "HTTP/1.1 304 Not Modified\r\nETag: \"abc\"";
1005 assert!(matches!(
1006 determine_body_strategy(headers, 304),
1007 BodyStrategy::NoBody
1008 ));
1009 }
1010
1011 #[test]
1012 fn strategy_no_body_1xx() {
1013 let headers = "HTTP/1.1 100 Continue";
1014 assert!(matches!(
1015 determine_body_strategy(headers, 100),
1016 BodyStrategy::NoBody
1017 ));
1018 }
1019
1020 #[test]
1021 fn strategy_content_length() {
1022 let headers = "HTTP/1.1 200 OK\r\nContent-Length: 42";
1023 match determine_body_strategy(headers, 200) {
1024 BodyStrategy::ContentLength(42) => {}
1025 _ => panic!("expected ContentLength(42)"),
1026 }
1027 }
1028
1029 #[test]
1030 fn strategy_chunked() {
1031 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked";
1032 assert!(matches!(
1033 determine_body_strategy(headers, 200),
1034 BodyStrategy::Chunked
1035 ));
1036 }
1037
1038 #[test]
1039 fn strategy_read_until_close() {
1040 let headers = "HTTP/1.1 200 OK\r\nConnection: close";
1041 assert!(matches!(
1042 determine_body_strategy(headers, 200),
1043 BodyStrategy::ReadUntilClose
1044 ));
1045 }
1046
1047 // -- has_chunked_terminator tests --
1048
1049 #[test]
1050 fn chunked_terminator_present() {
1051 assert!(has_chunked_terminator(b"5\r\nHello\r\n0\r\n\r\n"));
1052 }
1053
1054 #[test]
1055 fn chunked_terminator_at_start() {
1056 assert!(has_chunked_terminator(b"0\r\n\r\n"));
1057 }
1058
1059 #[test]
1060 fn chunked_terminator_missing() {
1061 assert!(!has_chunked_terminator(b"5\r\nHello\r\n"));
1062 }
1063
1064 #[test]
1065 fn chunked_terminator_empty() {
1066 assert!(!has_chunked_terminator(b""));
1067 }
1068
1069 // -- ConnectionKey equality tests --
1070
1071 #[test]
1072 fn connection_key_equal() {
1073 let a = ConnectionKey {
1074 host: "example.com".to_string(),
1075 port: 443,
1076 is_tls: true,
1077 };
1078 let b = ConnectionKey {
1079 host: "example.com".to_string(),
1080 port: 443,
1081 is_tls: true,
1082 };
1083 assert_eq!(a, b);
1084 }
1085
1086 #[test]
1087 fn connection_key_different_host() {
1088 let a = ConnectionKey {
1089 host: "a.com".to_string(),
1090 port: 443,
1091 is_tls: true,
1092 };
1093 let b = ConnectionKey {
1094 host: "b.com".to_string(),
1095 port: 443,
1096 is_tls: true,
1097 };
1098 assert_ne!(a, b);
1099 }
1100
1101 #[test]
1102 fn connection_key_different_port() {
1103 let a = ConnectionKey {
1104 host: "example.com".to_string(),
1105 port: 80,
1106 is_tls: false,
1107 };
1108 let b = ConnectionKey {
1109 host: "example.com".to_string(),
1110 port: 8080,
1111 is_tls: false,
1112 };
1113 assert_ne!(a, b);
1114 }
1115
1116 #[test]
1117 fn connection_key_different_tls() {
1118 let a = ConnectionKey {
1119 host: "example.com".to_string(),
1120 port: 443,
1121 is_tls: true,
1122 };
1123 let b = ConnectionKey {
1124 host: "example.com".to_string(),
1125 port: 443,
1126 is_tls: false,
1127 };
1128 assert_ne!(a, b);
1129 }
1130
1131 // -- Header parsing strategy with case variations --
1132
1133 #[test]
1134 fn strategy_content_length_lowercase() {
1135 let headers = "HTTP/1.1 200 OK\r\ncontent-length: 10";
1136 match determine_body_strategy(headers, 200) {
1137 BodyStrategy::ContentLength(10) => {}
1138 _ => panic!("expected ContentLength(10)"),
1139 }
1140 }
1141
1142 #[test]
1143 fn strategy_chunked_lowercase() {
1144 let headers = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked";
1145 assert!(matches!(
1146 determine_body_strategy(headers, 200),
1147 BodyStrategy::Chunked
1148 ));
1149 }
1150
1151 #[test]
1152 fn strategy_chunked_uppercase_value() {
1153 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: CHUNKED";
1154 assert!(matches!(
1155 determine_body_strategy(headers, 200),
1156 BodyStrategy::Chunked
1157 ));
1158 }
1159
1160 #[test]
1161 fn strategy_content_length_mixed_case() {
1162 let headers = "HTTP/1.1 200 OK\r\nCONTENT-LENGTH: 99";
1163 match determine_body_strategy(headers, 200) {
1164 BodyStrategy::ContentLength(99) => {}
1165 _ => panic!("expected ContentLength(99)"),
1166 }
1167 }
1168
1169 #[test]
1170 fn strategy_chunked_mixed_case_name() {
1171 let headers = "HTTP/1.1 200 OK\r\nTRANSFER-ENCODING: chunked";
1172 assert!(matches!(
1173 determine_body_strategy(headers, 200),
1174 BodyStrategy::Chunked
1175 ));
1176 }
1177
1178 // -- URL scheme handling --
1179
1180 #[test]
1181 fn unsupported_scheme_error() {
1182 let mut client = HttpClient::new();
1183 let url = Url::parse("ftp://example.com/file").unwrap();
1184 let result = client.get(&url);
1185 assert!(matches!(result, Err(ClientError::UnsupportedScheme(_))));
1186 }
1187
1188 // -- Redirect-related tests --
1189
1190 #[test]
1191 fn default_max_redirects_is_20() {
1192 let client = HttpClient::new();
1193 assert_eq!(client.max_redirects, 20);
1194 }
1195
1196 #[test]
1197 fn error_display_redirect_loop() {
1198 let e = ClientError::RedirectLoop;
1199 assert_eq!(e.to_string(), "redirect loop detected");
1200 }
1201
1202 #[test]
1203 fn reason_phrase_303() {
1204 assert_eq!(reason_phrase(303), "See Other");
1205 }
1206
1207 #[test]
1208 fn reason_phrase_301() {
1209 assert_eq!(reason_phrase(301), "Moved Permanently");
1210 }
1211
1212 #[test]
1213 fn reason_phrase_302() {
1214 assert_eq!(reason_phrase(302), "Found");
1215 }
1216
1217 #[test]
1218 fn reason_phrase_307() {
1219 assert_eq!(reason_phrase(307), "Temporary Redirect");
1220 }
1221
1222 #[test]
1223 fn reason_phrase_308() {
1224 assert_eq!(reason_phrase(308), "Permanent Redirect");
1225 }
1226
1227 // -- is_redirect_status tests --
1228
1229 #[test]
1230 fn redirect_status_301() {
1231 assert!(is_redirect_status(301));
1232 }
1233
1234 #[test]
1235 fn redirect_status_302() {
1236 assert!(is_redirect_status(302));
1237 }
1238
1239 #[test]
1240 fn redirect_status_303() {
1241 assert!(is_redirect_status(303));
1242 }
1243
1244 #[test]
1245 fn redirect_status_307() {
1246 assert!(is_redirect_status(307));
1247 }
1248
1249 #[test]
1250 fn redirect_status_308() {
1251 assert!(is_redirect_status(308));
1252 }
1253
1254 #[test]
1255 fn redirect_status_200_is_not_redirect() {
1256 assert!(!is_redirect_status(200));
1257 }
1258
1259 #[test]
1260 fn redirect_status_404_is_not_redirect() {
1261 assert!(!is_redirect_status(404));
1262 }
1263
1264 #[test]
1265 fn redirect_status_304_is_not_redirect() {
1266 assert!(!is_redirect_status(304));
1267 }
1268
1269 // -- redirect_method tests --
1270
1271 #[test]
1272 fn redirect_301_changes_post_to_get() {
1273 assert_eq!(redirect_method(301, Method::Post), Method::Get);
1274 }
1275
1276 #[test]
1277 fn redirect_302_changes_post_to_get() {
1278 assert_eq!(redirect_method(302, Method::Post), Method::Get);
1279 }
1280
1281 #[test]
1282 fn redirect_303_changes_post_to_get() {
1283 assert_eq!(redirect_method(303, Method::Post), Method::Get);
1284 }
1285
1286 #[test]
1287 fn redirect_303_changes_put_to_get() {
1288 assert_eq!(redirect_method(303, Method::Put), Method::Get);
1289 }
1290
1291 #[test]
1292 fn redirect_303_changes_delete_to_get() {
1293 assert_eq!(redirect_method(303, Method::Delete), Method::Get);
1294 }
1295
1296 #[test]
1297 fn redirect_307_preserves_post() {
1298 assert_eq!(redirect_method(307, Method::Post), Method::Post);
1299 }
1300
1301 #[test]
1302 fn redirect_307_preserves_put() {
1303 assert_eq!(redirect_method(307, Method::Put), Method::Put);
1304 }
1305
1306 #[test]
1307 fn redirect_308_preserves_post() {
1308 assert_eq!(redirect_method(308, Method::Post), Method::Post);
1309 }
1310
1311 #[test]
1312 fn redirect_308_preserves_delete() {
1313 assert_eq!(redirect_method(308, Method::Delete), Method::Delete);
1314 }
1315
1316 #[test]
1317 fn redirect_301_get_stays_get() {
1318 assert_eq!(redirect_method(301, Method::Get), Method::Get);
1319 }
1320
1321 // -- redirect_preserves_body tests --
1322
1323 #[test]
1324 fn redirect_301_drops_body() {
1325 assert!(!redirect_preserves_body(301));
1326 }
1327
1328 #[test]
1329 fn redirect_302_drops_body() {
1330 assert!(!redirect_preserves_body(302));
1331 }
1332
1333 #[test]
1334 fn redirect_303_drops_body() {
1335 assert!(!redirect_preserves_body(303));
1336 }
1337
1338 #[test]
1339 fn redirect_307_preserves_body() {
1340 assert!(redirect_preserves_body(307));
1341 }
1342
1343 #[test]
1344 fn redirect_308_preserves_body() {
1345 assert!(redirect_preserves_body(308));
1346 }
1347
1348 // -- Cross-origin header stripping --
1349
1350 #[test]
1351 fn cross_origin_strips_authorization() {
1352 let url_a = Url::parse("https://example.com/page").unwrap();
1353 let url_b = Url::parse("https://other.com/page").unwrap();
1354 assert!(!url_b.origin().same_origin(&url_a.origin()));
1355
1356 let mut headers = Headers::new();
1357 headers.add("Authorization", "Bearer token123");
1358 headers.add("Accept", "text/html");
1359
1360 // Simulate cross-origin redirect stripping
1361 if !url_b.origin().same_origin(&url_a.origin()) {
1362 headers.remove("Authorization");
1363 }
1364
1365 assert!(headers.get("Authorization").is_none());
1366 assert_eq!(headers.get("Accept"), Some("text/html"));
1367 }
1368
1369 #[test]
1370 fn same_origin_preserves_authorization() {
1371 let url_a = Url::parse("https://example.com/page1").unwrap();
1372 let url_b = Url::parse("https://example.com/page2").unwrap();
1373 assert!(url_b.origin().same_origin(&url_a.origin()));
1374
1375 let mut headers = Headers::new();
1376 headers.add("Authorization", "Bearer token123");
1377
1378 if !url_b.origin().same_origin(&url_a.origin()) {
1379 headers.remove("Authorization");
1380 }
1381
1382 assert_eq!(headers.get("Authorization"), Some("Bearer token123"));
1383 }
1384
1385 // -- Redirect loop detection --
1386
1387 #[test]
1388 fn redirect_loop_detected_in_visited_urls() {
1389 let mut visited = vec!["https://example.com/a".to_string()];
1390 let next = "https://example.com/a".to_string();
1391 assert!(visited.contains(&next));
1392
1393 // Unique URL is not a loop
1394 let next2 = "https://example.com/b".to_string();
1395 assert!(!visited.contains(&next2));
1396 visited.push(next2);
1397 assert_eq!(visited.len(), 2);
1398 }
1399}