Skip to main content

qubit_http/request/
http_request.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! Immutable HTTP request object.
11
12use std::fmt;
13use std::future::Future;
14use std::sync::RwLock;
15use std::time::Duration;
16
17use bytes::Bytes;
18use futures_util::stream as futures_stream;
19use http::{
20    HeaderMap,
21    HeaderName,
22    HeaderValue,
23    Method,
24};
25use qubit_function::MutatingFunction;
26use reqwest::Response;
27use tokio_util::sync::CancellationToken;
28use url::Host;
29use url::Url;
30
31use crate::error::{
32    backend_error_mapper::map_reqwest_error,
33    ReqwestErrorPhase,
34};
35use crate::{
36    AsyncHttpHeaderInjector,
37    HttpError,
38    HttpErrorKind,
39    HttpHeaderInjector,
40    HttpLogger,
41    HttpRequestStreamingBody,
42    HttpResult,
43    LogSanitizePolicy,
44    LogSanitizer,
45};
46
47use super::http_request_body::HttpRequestBody;
48use super::http_request_builder::HttpRequestBuilder;
49use super::http_request_retry_override::HttpRequestRetryOverride;
50use super::parse_header;
51use super::validate_positive_timeout;
52
53/// Request execution options (timeouts, cancellation, and retry override).
54#[derive(Debug, Clone)]
55struct HttpRequestExecutionOptions {
56    /// Overrides client-wide request timeout when set; otherwise client default applies.
57    request_timeout: Option<Duration>,
58    /// Per-request write timeout used during request sending.
59    write_timeout: Duration,
60    /// Per-request read timeout used during response body reads.
61    read_timeout: Duration,
62    /// Optional cancellation token checked before send and during I/O phases.
63    cancellation_token: Option<CancellationToken>,
64    /// Per-request retry override (enable/disable/method-policy/Retry-After behavior).
65    retry_override: HttpRequestRetryOverride,
66}
67
68/// Request context captured from the originating client.
69#[derive(Debug, Clone)]
70struct HttpRequestContext {
71    /// Base URL copied from client options, used to resolve relative `path`.
72    base_url: Option<Url>,
73    /// Whether resolved URLs must avoid IPv6 literal hosts.
74    ipv4_only: bool,
75    /// Client default headers snapshot captured when this request builder was created.
76    default_headers: HeaderMap,
77    /// Client sync header injectors snapshot captured when this request builder was created.
78    injectors: Vec<HttpHeaderInjector>,
79    /// Client async header injectors snapshot captured when this request builder was created.
80    async_injectors: Vec<AsyncHttpHeaderInjector>,
81    /// Log sanitization policy snapshot captured when this request builder was created.
82    log_sanitize_policy: LogSanitizePolicy,
83}
84
85/// Immutable snapshot of a single HTTP call produced by
86/// [`crate::HttpRequestBuilder`].
87pub struct HttpRequest {
88    /// HTTP method (GET, POST, …).
89    method: Method,
90    /// Absolute URL string, or path joined with client `base_url` when not
91    /// parseable as URL.
92    path: String,
93    /// Query string parameters as `(name, value)` pairs.
94    query: Vec<(String, String)>,
95    /// Headers added on top of client defaults and injector output.
96    headers: HeaderMap,
97    /// Serialized body variant.
98    body: HttpRequestBody,
99    /// Deferred per-attempt streaming body factory.
100    streaming_body: Option<HttpRequestStreamingBody>,
101    /// Lazily maintained cache for the currently resolved URL.
102    resolved_url: RwLock<Option<Url>>,
103    /// Attempt-scoped cache of merged outbound headers after applying
104    /// defaults/injectors/request-local headers.
105    effective_headers: Option<HeaderMap>,
106    /// Request execution options and runtime controls.
107    execution_options: HttpRequestExecutionOptions,
108    /// Client-derived context for URL and header resolution.
109    context: HttpRequestContext,
110}
111
112impl fmt::Debug for HttpRequest {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        let sanitizer = LogSanitizer::for_debug(&self.context.log_sanitize_policy);
115        let url = self
116            .resolved_url()
117            .ok()
118            .map(|url| sanitizer.sanitize_url(&url));
119        let base_url = self
120            .context
121            .base_url
122            .as_ref()
123            .map(|url| sanitizer.sanitize_url(url));
124        formatter
125            .debug_struct("HttpRequest")
126            .field("method", &self.method)
127            .field("url", &url)
128            .field("headers", &sanitizer.sanitize_header_map(&self.headers))
129            .field("body", &self.body)
130            .field(
131                "streaming_body",
132                &self.streaming_body.as_ref().map(|_| "present"),
133            )
134            .field("request_timeout", &self.execution_options.request_timeout)
135            .field("write_timeout", &self.execution_options.write_timeout)
136            .field("read_timeout", &self.execution_options.read_timeout)
137            .field(
138                "cancellation_token_present",
139                &self.execution_options.cancellation_token.is_some(),
140            )
141            .field("retry_override", &self.execution_options.retry_override)
142            .field("base_url", &base_url)
143            .field("ipv4_only", &self.context.ipv4_only)
144            .field("injector_count", &self.context.injectors.len())
145            .field("async_injector_count", &self.context.async_injectors.len())
146            .finish()
147    }
148}
149
150impl HttpRequest {
151    /// Consumes a finished [`HttpRequestBuilder`] and freezes its fields into
152    /// an [`HttpRequest`].
153    ///
154    /// # Parameters
155    /// - `builder`: Populated builder produced by the HTTP client pipeline.
156    ///
157    /// # Returns
158    /// Snapshot ready for URL resolution, header assembly, and sending.
159    pub(super) fn new(builder: HttpRequestBuilder) -> Self {
160        let mut request = Self {
161            method: builder.method,
162            path: builder.path,
163            query: builder.query,
164            headers: builder.headers,
165            body: builder.body,
166            streaming_body: builder.streaming_body,
167            resolved_url: RwLock::new(None),
168            effective_headers: None,
169            execution_options: HttpRequestExecutionOptions {
170                request_timeout: builder.request_timeout,
171                write_timeout: builder.write_timeout,
172                read_timeout: builder.read_timeout,
173                cancellation_token: builder.cancellation_token,
174                retry_override: builder.retry_override,
175            },
176            context: HttpRequestContext {
177                base_url: builder.base_url,
178                ipv4_only: builder.ipv4_only,
179                default_headers: builder.default_headers,
180                injectors: builder.injectors,
181                async_injectors: builder.async_injectors,
182                log_sanitize_policy: builder.log_sanitize_policy,
183            },
184        };
185        request.refresh_resolved_url_cache();
186        request
187    }
188
189    /// Returns the HTTP verb for this snapshot.
190    ///
191    /// # Returns
192    /// Borrowed [`Method`] (for example GET or POST).
193    pub fn method(&self) -> &Method {
194        &self.method
195    }
196
197    /// Replaces the HTTP verb.
198    ///
199    /// # Parameters
200    /// - `method`: New [`Method`].
201    ///
202    /// # Returns
203    /// `self` for method chaining.
204    pub fn set_method(&mut self, method: Method) -> &mut Self {
205        self.method = method;
206        self
207    }
208
209    /// Returns the path segment or absolute URL string stored on this request.
210    ///
211    /// # Returns
212    /// The raw path/URL before query string assembly; may be relative if a base
213    /// URL is set.
214    pub fn path(&self) -> &str {
215        &self.path
216    }
217
218    /// Replaces the path or absolute URL string.
219    ///
220    /// # Parameters
221    /// - `path`: New path or URL string (query string is managed separately via
222    ///   [`Self::add_query_param`]).
223    ///
224    /// # Returns
225    /// `self` for method chaining.
226    pub fn set_path(&mut self, path: &str) -> &mut Self {
227        self.path = path.to_string();
228        self.refresh_resolved_url_cache();
229        self
230    }
231
232    /// Returns ordered `(name, value)` query pairs that will be appended to the
233    /// resolved URL.
234    ///
235    /// # Returns
236    /// Slice view of accumulated query parameters.
237    pub fn query(&self) -> &[(String, String)] {
238        &self.query
239    }
240
241    /// Appends a single query pair preserving insertion order.
242    ///
243    /// # Parameters
244    /// - `key`: Parameter name.
245    /// - `value`: Parameter value.
246    ///
247    /// # Returns
248    /// `self` for method chaining.
249    pub fn add_query_param(&mut self, key: &str, value: &str) -> &mut Self {
250        self.query.push((key.to_string(), value.to_string()));
251        self
252    }
253
254    /// Removes every query pair from this snapshot.
255    ///
256    /// # Returns
257    /// `self` for method chaining.
258    pub fn clear_query_params(&mut self) -> &mut Self {
259        self.query.clear();
260        self
261    }
262
263    /// Returns request-local headers layered on top of client defaults and
264    /// injector output at send time.
265    ///
266    /// # Returns
267    /// Borrowed [`HeaderMap`] owned by this request only (not merged defaults).
268    pub fn headers(&self) -> &HeaderMap {
269        &self.headers
270    }
271
272    /// Parses and inserts one header from string name/value pairs.
273    ///
274    /// # Parameters
275    /// - `name`: Header field name.
276    /// - `value`: Header field value.
277    ///
278    /// # Returns
279    /// `Ok(self)` on success.
280    ///
281    /// # Errors
282    /// Returns [`HttpError`] when name or value cannot be converted into valid
283    /// HTTP tokens.
284    pub fn set_header(&mut self, name: &str, value: &str) -> Result<&mut Self, HttpError> {
285        let (header_name, header_value) = parse_header(name, value)?;
286        self.headers.insert(header_name, header_value);
287        self.invalidate_effective_headers_cache();
288        Ok(self)
289    }
290
291    /// Inserts one header using pre-validated [`HeaderName`] / [`HeaderValue`]
292    /// types.
293    ///
294    /// # Parameters
295    /// - `name`: Typed header name.
296    /// - `value`: Typed header value.
297    ///
298    /// # Returns
299    /// `self` for method chaining.
300    pub fn set_typed_header(&mut self, name: HeaderName, value: HeaderValue) -> &mut Self {
301        self.headers.insert(name, value);
302        self.invalidate_effective_headers_cache();
303        self
304    }
305
306    /// Removes all values for a header field by typed name.
307    ///
308    /// # Parameters
309    /// - `name`: Header name to strip from the request-local map.
310    ///
311    /// # Returns
312    /// `self` for method chaining.
313    pub fn remove_header(&mut self, name: &HeaderName) -> &mut Self {
314        self.headers.remove(name);
315        self.invalidate_effective_headers_cache();
316        self
317    }
318
319    /// Clears all request-local headers (defaults and injectors are unaffected
320    /// until send).
321    ///
322    /// # Returns
323    /// `self` for method chaining.
324    pub fn clear_headers(&mut self) -> &mut Self {
325        self.headers.clear();
326        self.invalidate_effective_headers_cache();
327        self
328    }
329
330    /// Returns the serialized body variant for this snapshot.
331    ///
332    /// # Returns
333    /// Borrowed [`HttpRequestBody`].
334    pub fn body(&self) -> &HttpRequestBody {
335        &self.body
336    }
337
338    /// Returns whether this request has a deferred streaming upload body.
339    ///
340    /// # Returns
341    /// `true` when the builder or [`Self::set_streaming_body`] installed a
342    /// per-attempt stream factory.
343    pub(crate) fn has_streaming_body(&self) -> bool {
344        self.streaming_body.is_some()
345    }
346
347    /// Replaces the entire body payload.
348    ///
349    /// # Parameters
350    /// - `body`: New [`HttpRequestBody`] variant.
351    ///
352    /// # Returns
353    /// `self` for method chaining.
354    pub fn set_body(&mut self, body: HttpRequestBody) -> &mut Self {
355        self.body = body;
356        self.streaming_body = None;
357        self
358    }
359
360    /// Sets deferred streaming upload body factory for this request.
361    ///
362    /// # Parameters
363    /// - `streaming_body`: Deferred body stream factory reused across retries.
364    ///
365    /// # Returns
366    /// `self` for method chaining.
367    pub fn set_streaming_body(&mut self, streaming_body: HttpRequestStreamingBody) -> &mut Self {
368        self.streaming_body = Some(streaming_body);
369        self.body = HttpRequestBody::Empty;
370        self
371    }
372
373    /// Returns the per-request total timeout, if any.
374    ///
375    /// # Returns
376    /// `Some(duration)` when a request-specific timeout overrides the client
377    /// default; otherwise `None`.
378    pub fn request_timeout(&self) -> Option<Duration> {
379        self.execution_options.request_timeout
380    }
381
382    /// Sets a per-request total timeout that overrides the client default for
383    /// this send.
384    ///
385    /// # Parameters
386    /// - `timeout`: Upper bound for the entire request lifecycle handled by
387    ///   reqwest.
388    ///
389    /// # Returns
390    /// `Ok(self)` for method chaining.
391    ///
392    /// # Errors
393    /// Returns [`HttpError`] when `timeout` is zero.
394    pub fn set_request_timeout(&mut self, timeout: Duration) -> HttpResult<&mut Self> {
395        validate_positive_timeout("request_timeout", timeout)?;
396        self.execution_options.request_timeout = Some(timeout);
397        Ok(self)
398    }
399
400    /// Drops the per-request timeout so the client-wide default applies again.
401    ///
402    /// # Returns
403    /// `self` for method chaining.
404    pub fn clear_request_timeout(&mut self) -> &mut Self {
405        self.execution_options.request_timeout = None;
406        self
407    }
408
409    /// Returns the write-phase timeout used while sending the request.
410    pub fn write_timeout(&self) -> Duration {
411        self.execution_options.write_timeout
412    }
413
414    /// Sets the write-phase timeout used while sending the request.
415    ///
416    /// # Errors
417    /// Returns [`HttpError`] when `timeout` is zero.
418    pub fn set_write_timeout(&mut self, timeout: Duration) -> HttpResult<&mut Self> {
419        validate_positive_timeout("write_timeout", timeout)?;
420        self.execution_options.write_timeout = timeout;
421        Ok(self)
422    }
423
424    /// Returns the read-phase timeout used while reading response body bytes.
425    pub fn read_timeout(&self) -> Duration {
426        self.execution_options.read_timeout
427    }
428
429    /// Sets the read-phase timeout used while reading response body bytes.
430    ///
431    /// # Errors
432    /// Returns [`HttpError`] when `timeout` is zero.
433    pub fn set_read_timeout(&mut self, timeout: Duration) -> HttpResult<&mut Self> {
434        validate_positive_timeout("read_timeout", timeout)?;
435        self.execution_options.read_timeout = timeout;
436        Ok(self)
437    }
438
439    /// Returns the optional base URL used to resolve relative [`Self::path`]
440    /// values.
441    ///
442    /// # Returns
443    /// `Some` when a base is configured; `None` when only absolute URLs in
444    /// `path` are valid.
445    pub fn base_url(&self) -> Option<&Url> {
446        self.context.base_url.as_ref()
447    }
448
449    /// Sets the base URL used for internal URL resolution when `path` is not
450    /// absolute.
451    ///
452    /// # Parameters
453    /// - `base_url`: Root URL to join against relative paths.
454    ///
455    /// # Returns
456    /// `self` for method chaining.
457    pub fn set_base_url(&mut self, base_url: Url) -> &mut Self {
458        self.context.base_url = Some(base_url);
459        self.refresh_resolved_url_cache();
460        self
461    }
462
463    /// Removes the configured base URL so relative paths can no longer be
464    /// resolved without resetting it.
465    ///
466    /// # Returns
467    /// `self` for method chaining.
468    pub fn clear_base_url(&mut self) -> &mut Self {
469        self.context.base_url = None;
470        self.refresh_resolved_url_cache();
471        self
472    }
473
474    /// Returns whether IPv6 literal hosts are rejected after URL resolution.
475    ///
476    /// # Returns
477    /// `true` when a resolved URL whose host is an IPv6 literal must be
478    /// rejected with [`HttpError::invalid_url`].
479    pub fn ipv4_only(&self) -> bool {
480        self.context.ipv4_only
481    }
482
483    /// Enables or disables IPv6 literal host rejection for resolved URLs.
484    ///
485    /// # Parameters
486    /// - `enabled`: When `true`, resolved URLs whose host is an IPv6 literal
487    ///   are errors.
488    ///
489    /// # Returns
490    /// `self` for method chaining.
491    pub fn set_ipv4_only(&mut self, enabled: bool) -> &mut Self {
492        self.context.ipv4_only = enabled;
493        self.refresh_resolved_url_cache();
494        self
495    }
496
497    /// Returns the cooperative cancellation handle, if configured.
498    ///
499    /// # Returns
500    /// `Some` token checked before send and during I/O; `None` when
501    /// cancellation is not wired.
502    pub fn cancellation_token(&self) -> Option<&CancellationToken> {
503        self.execution_options.cancellation_token.as_ref()
504    }
505
506    /// Attaches a [`CancellationToken`] that can abort this request
507    /// cooperatively.
508    ///
509    /// # Parameters
510    /// - `token`: Shared cancellation source.
511    ///
512    /// # Returns
513    /// `self` for method chaining.
514    pub fn set_cancellation_token(&mut self, token: CancellationToken) -> &mut Self {
515        self.execution_options.cancellation_token = Some(token);
516        self
517    }
518
519    /// Removes any cancellation token from this snapshot.
520    ///
521    /// # Returns
522    /// `self` for method chaining.
523    pub fn clear_cancellation_token(&mut self) -> &mut Self {
524        self.execution_options.cancellation_token = None;
525        self
526    }
527
528    /// Returns the per-request retry override applied by the client pipeline.
529    ///
530    /// # Returns
531    /// Borrowed [`HttpRequestRetryOverride`].
532    pub fn retry_override(&self) -> &HttpRequestRetryOverride {
533        &self.execution_options.retry_override
534    }
535
536    /// Replaces the retry override for this single request.
537    ///
538    /// # Parameters
539    /// - `retry_override`: New override policy and knobs.
540    ///
541    /// # Returns
542    /// `self` for method chaining.
543    pub fn set_retry_override(&mut self, retry_override: HttpRequestRetryOverride) -> &mut Self {
544        self.execution_options.retry_override = retry_override;
545        self
546    }
547
548    /// Moves the current body out, leaving [`HttpRequestBody::Empty`] in its
549    /// place.
550    ///
551    /// Used internally before handing the payload to reqwest so the snapshot is
552    /// not cloned twice.
553    ///
554    /// # Returns
555    /// Previous [`HttpRequestBody`] value.
556    pub(crate) fn take_body(&mut self) -> HttpRequestBody {
557        std::mem::replace(&mut self.body, HttpRequestBody::Empty)
558    }
559
560    /// Assembles a reqwest [`RequestBuilder`](reqwest::RequestBuilder), applies
561    /// this snapshot's body, then sends with a bounded write phase.
562    ///
563    /// Centralizes send-attempt preparation and transport wiring:
564    /// - invalidates and recomputes effective headers for each attempt;
565    /// - emits request TRACE logs via the provided logger;
566    /// - applies query/timeout/body wiring plus cooperative cancellation and
567    ///   write-timeout handling.
568    ///
569    /// # Parameters
570    /// - `backend`: Shared reqwest client.
571    /// - `logger`: Attempt-scoped request logger.
572    ///
573    /// # Returns
574    /// The successful [`Response`] or a mapped [`HttpError`].
575    ///
576    /// # Errors
577    /// - Cooperative cancellation while waiting on the send future.
578    /// - Transport failures mapped from reqwest.
579    /// - Write timeout when the send future does not complete within
580    ///   `write_timeout`.
581    pub(crate) async fn send_impl(
582        &mut self,
583        backend: &reqwest::Client,
584        logger: &HttpLogger<'_>,
585    ) -> HttpResult<Response> {
586        // Effective headers are cached on the request. Each send attempt must
587        // invalidate and recompute them so injector output and request mutations
588        // are refreshed instead of reusing stale headers from prior attempts.
589        self.invalidate_effective_headers_cache();
590        let method = self.method().clone();
591        let request_url_context = self.resolved_url().ok();
592        let write_timeout = self.execution_options.write_timeout;
593        let cancellation_token = self.execution_options.cancellation_token.clone();
594        let headers = Self::await_pre_send_future(
595            self.effective_headers(),
596            write_timeout,
597            cancellation_token,
598            &method,
599            request_url_context.as_ref(),
600            "Request cancelled while preparing request",
601            format!(
602                "Write timeout after {:?} while preparing request",
603                write_timeout
604            ),
605        )
606        .await?
607        .clone();
608        let url = self.resolved_base_url()?;
609        let request_url = self.resolved_url()?;
610        // Log the request after computing effective headers so TRACE logs
611        // include the same query string used by the actual send path.
612        logger.log_request(self);
613        let mut builder = backend.request(method.clone(), url.clone());
614        builder = builder.headers(headers);
615        if !self.query.is_empty() {
616            builder = builder.query(self.query.as_slice());
617        }
618        if let Some(timeout) = self.execution_options.request_timeout {
619            builder = builder.timeout(timeout);
620        }
621        if let Some(streaming_body) = self.streaming_body.as_ref() {
622            let body = Self::await_pre_send_future(
623                async { Ok(streaming_body.to_reqwest_body().await) },
624                self.execution_options.write_timeout,
625                self.execution_options.cancellation_token.clone(),
626                &method,
627                Some(&request_url),
628                "Request cancelled while preparing streaming request body",
629                format!(
630                    "Write timeout after {:?} while preparing streaming request body",
631                    self.execution_options.write_timeout
632                ),
633            )
634            .await?;
635            builder = builder.body(body);
636        } else {
637            builder = Self::apply_request_body(builder, self.take_body());
638        }
639
640        let send_future =
641            tokio::time::timeout(self.execution_options.write_timeout, builder.send());
642        let next = if let Some(token) = self.execution_options.cancellation_token.as_ref() {
643            tokio::select! {
644                _ = token.cancelled() => {
645                    return Err(HttpError::cancelled("Request cancelled while sending")
646                        .with_method(&method)
647                        .with_url(&request_url));
648                }
649                send_result = send_future => send_result,
650            }
651        } else {
652            send_future.await
653        };
654
655        match next {
656            Ok(Ok(response)) => Ok(response),
657            Ok(Err(error)) => Err(map_reqwest_error(
658                error,
659                HttpErrorKind::Transport,
660                ReqwestErrorPhase::Send,
661                method.clone(),
662                request_url.clone(),
663            )),
664            Err(_) => Err(HttpError::write_timeout(format!(
665                "Write timeout after {:?} while sending request",
666                self.execution_options.write_timeout
667            ))
668            .with_method(&method)
669            .with_url(&request_url)),
670        }
671    }
672
673    /// Waits for one asynchronous pre-send preparation step with cancellation and
674    /// write-timeout handling.
675    ///
676    /// # Parameters
677    /// - `future`: Preparation future, such as async header injection or streaming
678    ///   body factory execution.
679    /// - `write_timeout`: Timeout budget reused for send preparation.
680    /// - `cancellation_token`: Optional request cancellation token.
681    /// - `method`: Request method for error context.
682    /// - `request_url`: Optional resolved request URL for error context.
683    /// - `cancellation_message`: Message used when cancellation wins.
684    /// - `timeout_message`: Message used when timeout wins.
685    ///
686    /// # Returns
687    /// The future output when it completes before cancellation or timeout.
688    ///
689    /// # Errors
690    /// Returns [`HttpErrorKind::Cancelled`] on cancellation,
691    /// [`HttpErrorKind::WriteTimeout`] on timeout, or propagates the future's own
692    /// error.
693    async fn await_pre_send_future<T, F>(
694        future: F,
695        write_timeout: Duration,
696        cancellation_token: Option<CancellationToken>,
697        method: &Method,
698        request_url: Option<&Url>,
699        cancellation_message: &str,
700        timeout_message: String,
701    ) -> HttpResult<T>
702    where
703        F: Future<Output = HttpResult<T>>,
704    {
705        let timed = tokio::time::timeout(write_timeout, future);
706        let next = if let Some(token) = cancellation_token.as_ref() {
707            tokio::select! {
708                _ = token.cancelled() => {
709                    return Err(Self::pre_send_cancelled_error(
710                        cancellation_message,
711                        method,
712                        request_url,
713                    ));
714                }
715                result = timed => result,
716            }
717        } else {
718            timed.await
719        };
720
721        match next {
722            Ok(result) => result,
723            Err(_) => Err(Self::pre_send_write_timeout_error(
724                timeout_message,
725                method,
726                request_url,
727            )),
728        }
729    }
730
731    /// Builds a cancellation error for pre-send preparation.
732    ///
733    /// # Parameters
734    /// - `message`: Cancellation message.
735    /// - `method`: Request method for context.
736    /// - `request_url`: Optional request URL for context.
737    ///
738    /// # Returns
739    /// Cancellation [`HttpError`] with request context attached.
740    fn pre_send_cancelled_error(
741        message: &str,
742        method: &Method,
743        request_url: Option<&Url>,
744    ) -> HttpError {
745        let mut error = HttpError::cancelled(message).with_method(method);
746        if let Some(request_url) = request_url {
747            error = error.with_url(request_url);
748        }
749        error
750    }
751
752    /// Builds a write-timeout error for pre-send preparation.
753    ///
754    /// # Parameters
755    /// - `message`: Timeout message.
756    /// - `method`: Request method for context.
757    /// - `request_url`: Optional request URL for context.
758    ///
759    /// # Returns
760    /// Write-timeout [`HttpError`] with request context attached.
761    fn pre_send_write_timeout_error(
762        message: String,
763        method: &Method,
764        request_url: Option<&Url>,
765    ) -> HttpError {
766        let mut error = HttpError::write_timeout(message).with_method(method);
767        if let Some(request_url) = request_url {
768            error = error.with_url(request_url);
769        }
770        error
771    }
772
773    /// Returns the resolved base URL for current request fields, computing and
774    /// caching it on demand.
775    ///
776    /// # Returns
777    /// Resolved [`Url`] value without builder query pairs (cloned from cache
778    /// when already computed).
779    ///
780    /// # Errors
781    /// Returns [`HttpError::invalid_url`] when parsing fails, the base URL is
782    /// missing for a relative path, joining fails, or [`Self::ipv4_only`]
783    /// rejects an IPv6 literal host.
784    fn resolved_base_url(&self) -> Result<Url, HttpError> {
785        let cached = match self.resolved_url.read() {
786            Ok(guard) => guard.clone(),
787            Err(_) => return Err(HttpError::other("Resolved URL cache read lock poisoned")),
788        };
789        if let Some(url) = cached.as_ref() {
790            return Ok(url.clone());
791        }
792        let resolved = self.compute_resolved_url()?;
793        match self.resolved_url.write() {
794            Ok(mut guard) => *guard = Some(resolved.clone()),
795            Err(_) => return Err(HttpError::other("Resolved URL cache write lock poisoned")),
796        }
797        Ok(resolved)
798    }
799
800    /// Returns the resolved URL plus request-builder query parameters.
801    ///
802    /// This mirrors the URL sent by the request execution path: query pairs already
803    /// present in [`Self::path`] are preserved, and pairs from
804    /// [`Self::query`] are appended in insertion order.
805    ///
806    /// # Returns
807    /// Resolved [`Url`] including query parameters from this request snapshot.
808    ///
809    /// # Errors
810    /// Propagates URL-resolution errors for invalid or unresolved URLs.
811    pub fn resolved_url(&self) -> Result<Url, HttpError> {
812        let mut url = self.resolved_base_url()?;
813        if !self.query.is_empty() {
814            {
815                let mut pairs = url.query_pairs_mut();
816                for (key, value) in &self.query {
817                    pairs.append_pair(key, value);
818                }
819            }
820        }
821        Ok(url)
822    }
823
824    /// Returns cached resolved URL when available.
825    pub fn resolved_url_cached(&self) -> Option<Url> {
826        self.resolved_url
827            .read()
828            .map(|guard| guard.clone())
829            .unwrap_or_default()
830    }
831
832    /// Recomputes and stores the current resolved URL.
833    fn refresh_resolved_url_cache(&mut self) {
834        if let Ok(mut guard) = self.resolved_url.write() {
835            *guard = self.compute_resolved_url().ok();
836        }
837    }
838
839    /// Computes the resolved URL from current path/base/ipv4 settings.
840    fn compute_resolved_url(&self) -> Result<Url, HttpError> {
841        if let Ok(url) = Url::parse(&self.path) {
842            self.validate_resolved_url_host(&url)?;
843            return Ok(url);
844        }
845
846        let base = self.context.base_url.as_ref().ok_or_else(|| {
847            HttpError::invalid_url(format!(
848                "Cannot resolve relative path '{}' without base_url",
849                self.path
850            ))
851        })?;
852
853        let url = base.join(&self.path).map_err(|error| {
854            HttpError::invalid_url(format!(
855                "Failed to resolve path '{}' against base URL '{}': {}",
856                self.path, base, error
857            ))
858        })?;
859        self.validate_resolved_url_host(&url)?;
860        Ok(url)
861    }
862
863    /// Enforces [`Self::ipv4_only`] by rejecting IPv6 literal hosts in `url`.
864    ///
865    /// # Parameters
866    /// - `url`: Candidate URL after parsing or joining.
867    ///
868    /// # Returns
869    /// `Ok(())` when the host is acceptable.
870    ///
871    /// # Errors
872    /// [`HttpError::invalid_url`] when `ipv4_only` is `true` and the host is an
873    /// IPv6 literal.
874    fn validate_resolved_url_host(&self, url: &Url) -> Result<(), HttpError> {
875        if self.context.ipv4_only && matches!(url.host(), Some(Host::Ipv6(_))) {
876            return Err(HttpError::invalid_url(format!(
877                "IPv6 literal host is not allowed when ipv4_only=true: {}",
878                url
879            )));
880        }
881        Ok(())
882    }
883
884    /// Returns the attempt-scoped merged outbound headers.
885    ///
886    /// On first call after invalidation, this computes merged headers by
887    /// replaying defaults/injectors/request-local headers and stores them in
888    /// [`Self::effective_headers`]. Later calls in the same attempt return the
889    /// cached map.
890    ///
891    /// Why this API is async:
892    /// - async injectors are part of header assembly and may perform awaitable
893    ///   work (for example token refresh or other I/O-backed value resolution).
894    /// - therefore header materialization cannot be fully synchronous.
895    ///
896    /// Merge order (later wins on duplicates):
897    /// 1. Client default headers snapshot captured when the builder was
898    ///    created.
899    /// 2. Synchronous injector output in registration order.
900    /// 3. Asynchronous injector output in registration order.
901    /// 4. Request-local headers from this snapshot.
902    ///
903    /// # Returns
904    /// Borrowed merged [`HeaderMap`] from the cache.
905    ///
906    /// # Errors
907    /// Propagates failures returned by any injector's `apply` implementation.
908    pub(crate) async fn effective_headers(&mut self) -> HttpResult<&HeaderMap> {
909        if self.effective_headers.is_none() {
910            self.effective_headers = Some(self.compute_effective_headers().await?);
911        }
912        Ok(self
913            .effective_headers
914            .as_ref()
915            .expect("effective headers cache must be populated after computation"))
916    }
917
918    /// Returns cached merged outbound headers when available.
919    pub(crate) fn effective_headers_cached(&self) -> Option<&HeaderMap> {
920        self.effective_headers.as_ref()
921    }
922
923    /// Clears the effective-header cache.
924    ///
925    /// This method invalidates [`Self::effective_headers`] so the next call to
926    /// [`Self::effective_headers`] recomputes merged headers by re-running
927    /// defaults and header injectors.
928    ///
929    /// Why this is needed:
930    /// - request-local headers may have been mutated (`set_header`, `clear_headers`, etc.);
931    /// - injector output may be time-sensitive (for example rotating auth token
932    ///   or timestamp-based signatures), so each send attempt should recompute
933    ///   merged headers instead of reusing stale values from prior attempts.
934    ///
935    /// When to call:
936    /// - immediately before starting a new send attempt;
937    /// - after any mutation that can change final outbound headers.
938    pub(crate) fn invalidate_effective_headers_cache(&mut self) {
939        self.effective_headers = None;
940    }
941
942    /// Computes merged outbound headers without touching the cache.
943    async fn compute_effective_headers(&self) -> HttpResult<HeaderMap> {
944        let mut headers = self.context.default_headers.clone();
945
946        for injector in &self.context.injectors {
947            injector.apply(&mut headers)?;
948        }
949        for injector in &self.context.async_injectors {
950            injector.apply(&mut headers).await?;
951        }
952
953        headers.extend(self.headers.clone());
954        Ok(headers)
955    }
956
957    /// Returns a pre-cancelled [`HttpError`] when a token is present and
958    /// already cancelled.
959    ///
960    /// # Parameters
961    /// - `message`: Human-readable cancellation reason.
962    ///
963    /// # Returns
964    /// `Some` [`HttpError`] (including method context and cached URL when
965    /// available) when a token exists and is already cancelled; otherwise
966    /// `None`.
967    pub(crate) fn cancelled_error_if_needed(&self, message: &str) -> Option<HttpError> {
968        if self
969            .execution_options
970            .cancellation_token
971            .as_ref()
972            .is_some_and(CancellationToken::is_cancelled)
973        {
974            let mut error = HttpError::cancelled(message.to_string()).with_method(&self.method);
975            if let Ok(url) = self.resolved_url() {
976                error = error.with_url(&url);
977            }
978            Some(error)
979        } else {
980            None
981        }
982    }
983
984    /// Attaches the correct reqwest body encoding for each [`HttpRequestBody`]
985    /// variant.
986    ///
987    /// # Parameters
988    /// - `builder`: Partially configured [`reqwest::RequestBuilder`]
989    ///   (method/URL/headers already set).
990    /// - `body`: Payload variant to attach; moved into the builder.
991    ///
992    /// # Returns
993    /// The same builder with an appropriate `.body(...)` applied (or unchanged
994    /// for [`HttpRequestBody::Empty`]).
995    fn apply_request_body(
996        builder: reqwest::RequestBuilder,
997        body: HttpRequestBody,
998    ) -> reqwest::RequestBuilder {
999        match body {
1000            HttpRequestBody::Empty => builder,
1001            HttpRequestBody::Bytes(bytes)
1002            | HttpRequestBody::Json(bytes)
1003            | HttpRequestBody::Form(bytes)
1004            | HttpRequestBody::Multipart(bytes)
1005            | HttpRequestBody::Ndjson(bytes) => builder.body(bytes),
1006            HttpRequestBody::Stream(chunks) => {
1007                let body_stream = futures_stream::iter(
1008                    chunks.into_iter().map(Result::<Bytes, std::io::Error>::Ok),
1009                );
1010                builder.body(reqwest::Body::wrap_stream(body_stream))
1011            }
1012            HttpRequestBody::Text(text) => builder.body(text),
1013        }
1014    }
1015}
1016
1017impl Clone for HttpRequest {
1018    fn clone(&self) -> Self {
1019        Self {
1020            method: self.method.clone(),
1021            path: self.path.clone(),
1022            query: self.query.clone(),
1023            headers: self.headers.clone(),
1024            body: self.body.clone(),
1025            streaming_body: self.streaming_body.clone(),
1026            resolved_url: RwLock::new(self.resolved_url_cached()),
1027            effective_headers: self.effective_headers.clone(),
1028            execution_options: self.execution_options.clone(),
1029            context: self.context.clone(),
1030        }
1031    }
1032}