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}