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