Skip to main content

qubit_http/client/
http_client.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//! HTTP client: builds requests, applies defaults and interceptors, executes
11//! them with optional retry, and exposes SSE helpers with reconnect.
12//!
13//! Single-shot execution is [`HttpClient::execute`] / [`HttpClient::execute_once`];
14//! retry policy comes from [`crate::HttpClientOptions::retry`] unless overridden
15//! per request.
16//!
17
18use std::time::{
19    Duration,
20    Instant,
21};
22
23use qubit_retry::{
24    AttemptFailure,
25    AttemptFailureDecision,
26    Retry,
27    RetryContext,
28    RetryError,
29    RetryErrorReason,
30};
31
32use crate::sanitize::LogSanitizer;
33use crate::sse::SseReconnectRunner;
34use crate::{
35    response::HttpResponseOptions,
36    sse::{
37        SseMessageStream,
38        SseReconnectOptions,
39    },
40    AsyncHttpHeaderInjector,
41    HttpClientOptions,
42    HttpError,
43    HttpHeaderInjector,
44    HttpLogger,
45    HttpRequest,
46    HttpRequestBuilder,
47    HttpRequestInterceptor,
48    HttpRequestInterceptors,
49    HttpResponse,
50    HttpResponseInterceptor,
51    HttpResponseInterceptors,
52    HttpResponseMeta,
53    HttpResult,
54    HttpRetryOptions,
55};
56
57/// High-level HTTP client: default headers, injectors, interceptors, logging,
58/// timeouts, and optional per-request retry.
59///
60/// [`Clone`] is shallow and cheap enough for typical use (including passing into
61/// retry closures); cloning does not duplicate the underlying connection pool
62/// beyond what [`reqwest::Client`] already shares.
63#[derive(Clone)]
64pub struct HttpClient {
65    /// Pluggable low-level HTTP stack used to send requests (currently reqwest).
66    pub(super) backend: reqwest::Client,
67    /// Timeouts, proxy, logging, default headers, and related settings.
68    pub(super) options: HttpClientOptions,
69    /// Header injectors applied to every outgoing request after default
70    /// headers.
71    pub(super) injectors: Vec<HttpHeaderInjector>,
72    /// Async header injectors applied after sync injectors and before request-level headers.
73    pub(super) async_injectors: Vec<AsyncHttpHeaderInjector>,
74    /// Request interceptors applied before request send for each attempt.
75    request_interceptors: HttpRequestInterceptors,
76    /// Response interceptors applied on successful responses before return.
77    response_interceptors: HttpResponseInterceptors,
78}
79
80impl HttpClient {
81    /// Wraps a built [`reqwest::Client`] with the given options and an empty
82    /// injector list.
83    ///
84    /// # Parameters
85    /// - `backend`: Configured low-level HTTP client used for I/O.
86    /// - `options`: Client-wide timeouts, headers, proxy, logging, etc.
87    ///
88    /// # Returns
89    /// A new [`HttpClient`] with no injectors until
90    /// [`HttpClient::add_header_injector`] is called.
91    pub(crate) fn new(backend: reqwest::Client, options: HttpClientOptions) -> Self {
92        Self {
93            backend,
94            options,
95            injectors: Vec::new(),
96            async_injectors: Vec::new(),
97            request_interceptors: HttpRequestInterceptors::new(),
98            response_interceptors: HttpResponseInterceptors::new(),
99        }
100    }
101
102    /// Returns a reference to the client-wide options (timeouts, proxy, logging,
103    /// default headers, retry defaults, etc.).
104    ///
105    /// # Returns
106    /// Immutable borrow of [`HttpClientOptions`]. Never `None`; always the
107    /// options installed on this client.
108    pub fn options(&self) -> &HttpClientOptions {
109        &self.options
110    }
111
112    /// Appends a [`HttpHeaderInjector`] so its mutation function runs on every
113    /// request. Mutates `self` in place.
114    ///
115    /// # Parameters
116    /// - `injector`: Injector to append (order is preserved).
117    pub fn add_header_injector(&mut self, injector: HttpHeaderInjector) {
118        self.injectors.push(injector);
119    }
120
121    /// Appends an async header injector whose mutation runs after sync injectors.
122    /// Mutates `self` in place.
123    ///
124    /// # Parameters
125    /// - `injector`: Async injector to append (order is preserved).
126    pub fn add_async_header_injector(&mut self, injector: AsyncHttpHeaderInjector) {
127        self.async_injectors.push(injector);
128    }
129
130    /// Appends a request interceptor run before each send attempt (including
131    /// each retry attempt). Mutates `self` in place.
132    ///
133    /// # Parameters
134    /// - `interceptor`: Request interceptor to append (order is preserved).
135    pub fn add_request_interceptor(&mut self, interceptor: HttpRequestInterceptor) {
136        self.request_interceptors.push(interceptor);
137    }
138
139    /// Appends a response interceptor run only after a successful HTTP status
140    /// (after the internal `execute_once` step) and before response body logging.
141    /// Mutates `self` in place.
142    ///
143    /// # Parameters
144    /// - `interceptor`: Response interceptor to append (order is preserved).
145    pub fn add_response_interceptor(&mut self, interceptor: HttpResponseInterceptor) {
146        self.response_interceptors.push(interceptor);
147    }
148
149    /// Validates and adds one client-level default header.
150    ///
151    /// The header is applied to every request before header injectors and
152    /// request-level headers.
153    ///
154    /// # Parameters
155    /// - `name`: Header name.
156    /// - `value`: Header value.
157    ///
158    /// # Returns
159    /// `Ok(self)` after the header is stored.
160    ///
161    /// # Errors
162    /// Returns [`HttpError`] when the header name or value is invalid.
163    pub fn add_header(&mut self, name: &str, value: &str) -> HttpResult<&mut Self> {
164        self.options.add_header(name, value)?;
165        Ok(self)
166    }
167
168    /// Validates and adds many client-level default headers atomically.
169    ///
170    /// If any input pair is invalid, no header from this batch is applied.
171    ///
172    /// # Parameters
173    /// - `headers`: Iterator of `(name, value)` pairs.
174    ///
175    /// # Returns
176    /// `Ok(self)` after all headers are stored.
177    ///
178    /// # Errors
179    /// Returns [`HttpError`] when any name/value pair is invalid (nothing from
180    /// this call is applied).
181    pub fn add_headers(&mut self, headers: &[(&str, &str)]) -> HttpResult<&mut Self> {
182        self.options.add_headers(headers)?;
183        Ok(self)
184    }
185
186    /// Clears all synchronous header injectors. Mutates `self` in place.
187    pub fn clear_header_injectors(&mut self) {
188        self.injectors.clear();
189    }
190
191    /// Clears all async header injectors. Mutates `self` in place.
192    pub fn clear_async_header_injectors(&mut self) {
193        self.async_injectors.clear();
194    }
195
196    /// Clears all request interceptors. Mutates `self` in place.
197    pub fn clear_request_interceptors(&mut self) {
198        self.request_interceptors.clear();
199    }
200
201    /// Clears all response interceptors. Mutates `self` in place.
202    pub fn clear_response_interceptors(&mut self) {
203        self.response_interceptors.clear();
204    }
205
206    /// Starts building an [`HttpRequest`] with the given method and path
207    /// (relative or absolute URL string).
208    ///
209    /// # Parameters
210    /// - `method`: HTTP verb (GET, POST, …).
211    /// - `path`: Path relative to [`HttpClientOptions::base_url`] or a full URL
212    ///   string.
213    ///
214    /// # Returns
215    /// A new [`HttpRequestBuilder`] borrowing this client for defaults; it is
216    /// not sent until built and passed to [`HttpClient::execute`] (or related
217    /// APIs).
218    pub fn request(&self, method: http::Method, path: &str) -> HttpRequestBuilder {
219        HttpRequestBuilder::new(method, path, self)
220    }
221
222    /// Returns a clone of the client-level default header map.
223    ///
224    /// Used when constructing a built [`HttpRequest`] so the snapshot reflects
225    /// headers at build time.
226    ///
227    /// # Returns
228    /// Owned [`http::HeaderMap`] copy of [`HttpClientOptions`] default headers.
229    pub(crate) fn headers_snapshot(&self) -> http::HeaderMap {
230        self.options.default_headers.clone()
231    }
232
233    /// Returns a clone of the registered synchronous header injectors list.
234    ///
235    /// # Returns
236    /// New [`Vec`] with the same injectors and order as on this client.
237    pub(crate) fn injectors_snapshot(&self) -> Vec<HttpHeaderInjector> {
238        self.injectors.clone()
239    }
240
241    /// Returns a clone of the registered async header injectors list.
242    ///
243    /// # Returns
244    /// New [`Vec`] with the same injectors and order as on this client.
245    pub(crate) fn async_injectors_snapshot(&self) -> Vec<AsyncHttpHeaderInjector> {
246        self.async_injectors.clone()
247    }
248
249    /// Sends the request and returns a unified [`HttpResponse`].
250    ///
251    /// Chooses retry vs single attempt from resolved [`HttpRetryOptions`] for
252    /// this request. Performs network I/O and may await the internal
253    /// `execute_once` path
254    /// multiple times with backoff between attempts when retry is enabled.
255    ///
256    /// # Parameters
257    /// - `request`: Built request (URL resolved against `base_url` if path is
258    ///   not absolute).
259    ///
260    /// # Returns
261    /// - `Ok(HttpResponse)` when the HTTP status is success
262    ///   ([`http::StatusCode::is_success`]).
263    /// - `Err(HttpError)` when any attempt fails for URL/header validation,
264    ///   cancellation, interceptor failure, transport/timeout, non-success
265    ///   status, or when the retry executor aborts or exceeds limits.
266    pub async fn execute(&self, request: HttpRequest) -> HttpResult<HttpResponse> {
267        let retry_options = self.options.retry.resolve(&request);
268        if retry_options.should_retry(&request) {
269            self.execute_with_retry(request, retry_options).await
270        } else {
271            self.execute_once(request).await
272        }
273    }
274
275    /// Performs one non-retrying execution: pre-send cancellation check,
276    /// request interceptors, resolve URL, merge headers, log the request, send
277    /// with configured timeouts, map non-success status to an error, then
278    /// response interceptors and response logging. The returned body is read
279    /// lazily according to [`HttpResponse`].
280    ///
281    /// # Parameters
282    /// - `request`: Built request to send (same fields as for
283    ///   [`HttpClient::execute`]).
284    ///
285    /// # Returns
286    /// - `Ok(HttpResponse)` on success status and after interceptors/logging
287    ///   steps succeed.
288    /// - `Err(HttpError)` from request/response interceptors, cancellation,
289    ///   send/transport errors, status mapping, URL resolution for the response
290    ///   wrapper, or response logging failures.
291    ///
292    /// # Side effects
293    /// Network I/O, optional logging, and user-provided interceptor callbacks.
294    pub(crate) async fn execute_once(&self, request: HttpRequest) -> HttpResult<HttpResponse> {
295        let mut request = request;
296        if let Some(error) = request.cancelled_error_if_needed("Request cancelled before sending") {
297            return Err(error);
298        }
299        self.request_interceptors.apply(&mut request)?;
300        let response = self
301            .prepare_and_send_once(request, "Request cancelled before sending")
302            .await?;
303        let mut response = response
304            .into_success_or_status_error("HTTP request failed")
305            .await?;
306        self.response_interceptors.apply(&mut response.meta)?;
307        let logger = HttpLogger::new(&self.options);
308        logger.log_response(&mut response).await?;
309        Ok(response)
310    }
311
312    /// Single low-level send: cancellation check, request logging, one backend
313    /// round-trip, then wraps the backend response as [`HttpResponse`].
314    ///
315    /// Does not run response interceptors or success-status enforcement; those
316    /// happen in [`HttpClient::execute_once`] after this returns.
317    ///
318    /// # Parameters
319    /// - `request`: Request to send (may be mutated for logging/send path).
320    /// - `cancellation_message`: Message embedded if the request is already
321    ///   cancelled when this runs.
322    ///
323    /// # Returns
324    /// - `Ok(HttpResponse)` with lazy body and metadata.
325    /// - `Err(HttpError)` if cancelled before send, URL resolution fails, or
326    ///   send fails.
327    ///
328    /// # Side effects
329    /// Async network I/O and request logging via [`HttpLogger`].
330    async fn prepare_and_send_once(
331        &self,
332        request: HttpRequest,
333        cancellation_message: &str,
334    ) -> HttpResult<HttpResponse> {
335        let mut request = request;
336        if let Some(error) = request.cancelled_error_if_needed(cancellation_message) {
337            return Err(error);
338        }
339        let logger = HttpLogger::new(&self.options);
340        let request_url = request.resolved_url()?;
341        let backend_response = request.send_impl(&self.backend, &logger).await?;
342        let meta = HttpResponseMeta::new(
343            backend_response.status(),
344            backend_response.headers().clone(),
345            backend_response.url().clone(),
346            request.method().clone(),
347        )
348        .with_log_sanitize_policy(self.options.log_sanitize_policy.clone());
349        let response_options = HttpResponseOptions::new(
350            self.options.error_response_preview_limit,
351            self.options.sse_json_mode,
352            self.options.sse_max_line_bytes,
353            self.options.sse_max_frame_bytes,
354            self.options.sse_done_marker_policy.clone(),
355            LogSanitizer::new(self.options.log_sanitize_policy.clone()),
356        );
357        Ok(HttpResponse::from_backend(
358            meta,
359            backend_response,
360            request.read_timeout(),
361            request.cancellation_token().cloned(),
362            request_url,
363            response_options,
364        ))
365    }
366
367    /// Runs [`HttpClient::execute_once`] under the given retry policy.
368    ///
369    /// Between attempts waits according to the resolved retry delay, optionally
370    /// honoring `Retry-After` by extending the next sleep. Each attempt clones
371    /// the request so request bodies can be rebuilt when supported.
372    ///
373    /// # Parameters
374    /// - `request`: Built request passed to each [`HttpClient::execute_once`]
375    ///   attempt (cloned per retry closure).
376    /// - `options`: Effective retry options for this request (from resolution
377    ///   in [`HttpClient::execute`]).
378    ///
379    /// # Returns
380    /// - `Ok(HttpResponse)` when an attempt completes with success status.
381    /// - `Err(HttpError)` from any [`HttpClient::execute_once`] failure that is
382    ///   non-retryable, or from retry exhaustion/max-duration enforcement.
383    ///
384    /// # Side effects
385    /// Multiple async HTTP attempts and optional sleeps.
386    async fn execute_with_retry(
387        &self,
388        request: HttpRequest,
389        options: HttpRetryOptions,
390    ) -> HttpResult<HttpResponse> {
391        let honor_retry_after = request.retry_override().should_honor_retry_after();
392        let retry_options = options.to_executor_options();
393        let started_at = Instant::now();
394
395        let retry_policy_options = options.clone();
396        let retry_delay_options = retry_options.clone();
397        let retry_policy = Retry::<HttpError>::builder()
398            .options(retry_options)
399            .retry_after_from_error(move |error| {
400                honor_retry_after.then_some(error.retry_after).flatten()
401            })
402            .on_failure(
403                move |failure: &AttemptFailure<HttpError>, context: &RetryContext| {
404                    Self::retry_failure_decision(
405                        failure,
406                        context,
407                        &retry_policy_options,
408                        &retry_delay_options,
409                    )
410                },
411            )
412            .build()
413            .expect("validated HTTP retry options should build retry policy");
414
415        let cancellation_token = request.cancellation_token().cloned();
416        let request_method = request.method().clone();
417        let request_url = request.resolved_url().ok();
418        let retry_request = request.clone();
419        let retry_future = retry_policy.run_async(|| {
420            let attempt_request = retry_request.clone();
421            async move { self.execute_once(attempt_request).await }
422        });
423
424        let retry_result = if let Some(token) = cancellation_token.as_ref() {
425            tokio::select! {
426                _ = token.cancelled() => {
427                    return Err(Self::retry_cancelled_error(
428                        "HTTP retry cancelled while waiting before next attempt",
429                        &request_method,
430                        request_url.as_ref(),
431                    ));
432                }
433                result = retry_future => result,
434            }
435        } else {
436            retry_future.await
437        };
438
439        match retry_result {
440            Ok(response) => Ok(response),
441            Err(error) => Err(Self::map_retry_error(
442                error,
443                started_at,
444                options.max_duration,
445                options.max_attempts,
446            )),
447        }
448    }
449
450    /// Returns whether `error` is retryable under `options`.
451    ///
452    /// # Parameters
453    /// - `error`: Error produced by a single HTTP attempt.
454    /// - `options`: Effective retry options for the request.
455    ///
456    /// # Returns
457    /// `true` if another attempt may be scheduled.
458    fn is_retryable_error(error: &HttpError, options: &HttpRetryOptions) -> bool {
459        if error.kind == crate::HttpErrorKind::Status {
460            error
461                .status
462                .is_some_and(|status| options.is_retryable_status(status))
463        } else {
464            options.is_retryable_error_kind(error.kind)
465        }
466    }
467
468    /// Computes the sleep before the next retry attempt.
469    ///
470    /// # Parameters
471    /// - `base_delay`: Delay selected from retry policy and jitter.
472    /// - `retry_after_hint`: Retry-After delay extracted by the retry policy.
473    ///
474    /// # Returns
475    /// `base_delay`, or the larger `Retry-After` value when present.
476    fn retry_sleep_delay(base_delay: Duration, retry_after_hint: Option<Duration>) -> Duration {
477        retry_after_hint
478            .map(|retry_after| retry_after.max(base_delay))
479            .unwrap_or(base_delay)
480    }
481
482    /// Decides how HTTP retry should handle one failed attempt.
483    ///
484    /// # Parameters
485    /// - `failure`: Failed attempt reported by `qubit-retry`.
486    /// - `context`: Retry context for the failed attempt.
487    /// - `policy_options`: HTTP retry allowlists and method policy.
488    /// - `delay_options`: Retry executor options used to calculate base delay.
489    ///
490    /// # Returns
491    /// Decision for `qubit-retry`: abort non-retryable HTTP errors, otherwise
492    /// retry after the larger base delay / Retry-After hint. Non-HTTP runtime
493    /// failures fall back to the retry executor default.
494    fn retry_failure_decision(
495        failure: &AttemptFailure<HttpError>,
496        context: &RetryContext,
497        policy_options: &HttpRetryOptions,
498        delay_options: &qubit_retry::RetryOptions,
499    ) -> AttemptFailureDecision {
500        let error = failure
501            .as_error()
502            .expect("HTTP retry attempts do not configure non-HTTP attempt failures");
503        if !Self::is_retryable_error(error, policy_options) {
504            return AttemptFailureDecision::Abort;
505        }
506
507        let base_delay = delay_options.delay_for_attempt(context.attempt());
508        let sleep_delay = Self::retry_sleep_delay(base_delay, context.retry_after_hint());
509        AttemptFailureDecision::RetryAfter(sleep_delay)
510    }
511
512    /// Adds retry-attempt exhaustion context to the last attempt error.
513    ///
514    /// # Parameters
515    /// - `error`: Last retryable attempt error.
516    /// - `attempts`: Number of attempts already made.
517    /// - `max_attempts`: Configured maximum attempts.
518    ///
519    /// # Returns
520    /// The same error with retry exhaustion details appended to its message.
521    fn map_retry_attempts_exhausted(
522        mut error: HttpError,
523        attempts: u32,
524        max_attempts: u32,
525    ) -> HttpError {
526        error.message = format!(
527            "{} (retry attempts exhausted: {attempts}/{max_attempts})",
528            error.message
529        );
530        error
531    }
532
533    /// Builds the error returned when retry policy stops early.
534    ///
535    /// # Parameters
536    /// - `error`: Attempt error that the retry policy chose not to retry.
537    /// - `attempts`: Number of attempts already made.
538    /// - `started_at`: Start instant of the retry flow.
539    ///
540    /// # Returns
541    /// [`HttpError::retry_aborted`] with the original [`HttpError`] chained as
542    /// source for callers that need the underlying status or transport error.
543    fn map_retry_aborted(error: HttpError, attempts: u32, started_at: Instant) -> HttpError {
544        let elapsed = started_at.elapsed();
545        let summary = error.message.clone();
546        HttpError::retry_aborted(format!(
547            "HTTP retry aborted after {attempts} attempt(s) in {elapsed:?}: {summary}"
548        ))
549        .with_source(error)
550    }
551
552    /// Builds the error when retry max-duration is exhausted.
553    ///
554    /// # Parameters
555    /// - `started_at`: Start instant of the retry flow.
556    /// - `max_duration`: Configured max-duration budget.
557    /// - `last_error`: Last captured retryable attempt error, if any.
558    ///
559    /// # Returns
560    /// Augments the last failure when present, otherwise a dedicated
561    /// max-duration error with no underlying attempt error.
562    fn map_retry_max_duration_exceeded(
563        started_at: Instant,
564        max_duration: Duration,
565        last_error: Option<HttpError>,
566    ) -> HttpError {
567        let elapsed = started_at.elapsed();
568        let max_duration_text = format!("{max_duration:?}");
569        match last_error {
570            Some(mut error) => {
571                error.message = format!(
572                    "{} (retry max duration exceeded: {elapsed:?}/{max_duration_text})",
573                    error.message
574                );
575                error
576            }
577            None => HttpError::retry_max_elapsed_exceeded(format!(
578                "HTTP retry max duration exceeded before a retryable error was captured: {elapsed:?}/{max_duration_text}"
579            )),
580        }
581    }
582
583    /// Maps a [`qubit_retry::RetryError`] into this crate's HTTP error model.
584    ///
585    /// # Parameters
586    /// - `error`: Terminal retry error from `qubit-retry`.
587    /// - `started_at`: Monotonic start instant of the HTTP retry flow.
588    /// - `max_duration`: Optional HTTP total retry budget.
589    /// - `max_attempts`: Configured maximum HTTP attempts.
590    ///
591    /// # Returns
592    /// A rich [`HttpError`] preserving the last attempt error when available.
593    fn map_retry_error(
594        error: RetryError<HttpError>,
595        started_at: Instant,
596        max_duration: Option<Duration>,
597        max_attempts: u32,
598    ) -> HttpError {
599        let attempts = error.attempts();
600        let reason = error.reason();
601        let (_, last_failure, _) = error.into_parts();
602        let last_error = last_failure.and_then(AttemptFailure::into_error);
603
604        match reason {
605            RetryErrorReason::AttemptsExceeded => {
606                let error =
607                    last_error.expect("HTTP retry attempts exceeded should preserve last error");
608                Self::map_retry_attempts_exhausted(error, attempts, max_attempts)
609            }
610            RetryErrorReason::MaxOperationElapsedExceeded
611            | RetryErrorReason::MaxTotalElapsedExceeded => {
612                let max_duration =
613                    max_duration.expect("HTTP retry elapsed limit requires max_duration");
614                Self::map_retry_max_duration_exceeded(started_at, max_duration, last_error)
615            }
616            RetryErrorReason::Aborted => {
617                let error = last_error.expect("HTTP retry abort should preserve last error");
618                if error.kind == crate::HttpErrorKind::Cancelled {
619                    error
620                } else {
621                    Self::map_retry_aborted(error, attempts, started_at)
622                }
623            }
624            RetryErrorReason::UnsupportedOperation | RetryErrorReason::WorkerStillRunning => {
625                HttpError::other(format!(
626                    "HTTP retry executor failed after {attempts} attempt(s): {reason:?}"
627                ))
628            }
629        }
630    }
631
632    /// Builds a cancellation error for retry wait cancellation.
633    ///
634    /// # Parameters
635    /// - `message`: Human-readable cancellation reason.
636    /// - `method`: Request method to attach.
637    /// - `url`: Optional resolved request URL to attach.
638    ///
639    /// # Returns
640    /// [`HttpErrorKind::Cancelled`](crate::HttpErrorKind::Cancelled) with request
641    /// context.
642    fn retry_cancelled_error(
643        message: &str,
644        method: &http::Method,
645        url: Option<&url::Url>,
646    ) -> HttpError {
647        let mut error = HttpError::cancelled(message).with_method(method);
648        if let Some(url) = url {
649            error = error.with_url(url);
650        }
651        error
652    }
653
654    /// Opens an SSE stream and reconnects automatically on retryable stream
655    /// failures.
656    ///
657    /// Reconnect behavior:
658    /// - retryable transport/read failures trigger reconnects;
659    /// - optional reconnect on clean EOF (`reconnect_on_eof`);
660    /// - `Last-Event-ID` is set from the latest parsed SSE last-event-id state;
661    /// - optional use of SSE `retry:` as next reconnect delay.
662    ///
663    /// # Parameters
664    /// - `request`: SSE request template reused on reconnect.
665    /// - `options`: Reconnect limits and delay policy.
666    ///
667    /// # Returns
668    /// SSE message stream yielding messages from one or more reconnect sessions.
669    ///
670    /// # Errors
671    /// Stream items are `Result`; `Err` covers per-item failures such as:
672    /// - initial stream-open failures when not reconnectable or retries exhausted;
673    /// - SSE protocol errors (non-reconnectable by default);
674    /// - transport/read errors after reconnect budget is exhausted.
675    ///
676    /// # Side effects
677    /// Performs repeated HTTP requests and reads on reconnect; may sleep between
678    /// attempts according to reconnect options.
679    pub fn execute_sse_with_reconnect(
680        &self,
681        request: HttpRequest,
682        options: SseReconnectOptions,
683    ) -> SseMessageStream {
684        SseReconnectRunner::new(self.clone(), request, options).run()
685    }
686}
687
688impl std::fmt::Debug for HttpClient {
689    /// Formats the client for debugging (exposes options and injectors; omits
690    /// the backend client).
691    ///
692    /// # Parameters
693    /// - `f`: Destination formatter.
694    ///
695    /// # Returns
696    /// `fmt::Result` from writing the debug struct.
697    ///
698    /// # Errors
699    /// Returns an error if formatting to `f` fails.
700    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701        f.debug_struct("HttpClient")
702            .field("options", &self.options)
703            .field("injectors", &self.injectors)
704            .field("async_injectors", &self.async_injectors)
705            .field("request_interceptors", &self.request_interceptors)
706            .field("response_interceptors", &self.response_interceptors)
707            .finish_non_exhaustive()
708    }
709}