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