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