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}