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