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}