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}