Skip to main content

qubit_http/
lib.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026.
4 *    Haixing Hu, Qubit Co. Ltd.
5 *
6 *    All rights reserved.
7 *
8 ******************************************************************************/
9#![allow(clippy::result_large_err)]
10// Keep `HttpError` rich (method/url/status/source) for diagnostics and retry decisions
11// across the crate's public APIs.
12
13//! # Qubit HTTP
14//!
15//! A general-purpose HTTP infrastructure module for Rust services.
16//!
17//! This crate provides:
18//! - Unified HTTP client options and factory abstractions
19//! - Loading those options from [`qubit_config::ConfigReader`] (`from_config` / factory `create_from_config`)
20//! - Consistent request/response/stream APIs
21//! - Secure and configurable logging with sensitive header masking
22//! - Built-in SSE decoding utilities in [`sse`]
23//! - Unified error model and retry hints
24//!
25//! # Author
26//!
27//! Haixing Hu
28
29mod client;
30pub mod constants;
31mod error;
32mod options;
33mod request;
34mod response;
35pub mod sse;
36
37#[cfg(coverage)]
38#[doc(hidden)]
39pub mod coverage_support {
40    //! Coverage-only hooks for defensive branches not reachable through normal APIs.
41
42    use std::time::{Duration, Instant};
43
44    use http::{HeaderMap, HeaderValue, Method, StatusCode};
45    use qubit_retry::{AttemptFailure, RetryContext, RetryError, RetryErrorReason};
46    use url::Url;
47
48    use crate::error::{backend_error_mapper, ReqwestErrorPhase};
49    use crate::{HttpError, HttpErrorKind, HttpResponse};
50
51    /// Maps a reqwest error through the internal backend mapper.
52    ///
53    /// # Parameters
54    /// - `error`: Reqwest error generated by a coverage test.
55    /// - `phase`: Optional phase name: `send`, `read`, or anything else for none.
56    /// - `default_kind`: Default HTTP error kind passed to the mapper.
57    ///
58    /// # Returns
59    /// The mapped HTTP error kind.
60    pub fn map_reqwest_error_kind(
61        error: reqwest::Error,
62        phase: Option<&str>,
63        default_kind: HttpErrorKind,
64    ) -> HttpErrorKind {
65        let phase = match phase {
66            Some("send") => Some(ReqwestErrorPhase::Send),
67            Some("read") => Some(ReqwestErrorPhase::Read),
68            _ => None,
69        };
70        backend_error_mapper::map_reqwest_error(error, default_kind, phase, None, None).kind
71    }
72
73    /// Exercises timeout classifier branches that require synthetic reqwest metadata.
74    ///
75    /// # Returns
76    /// Kinds selected for connect, send, read, and unknown timeout phases.
77    pub fn classify_timeout_kinds() -> Vec<HttpErrorKind> {
78        vec![
79            backend_error_mapper::coverage_classify_timeout_kind(
80                true,
81                Some(ReqwestErrorPhase::Send),
82            ),
83            backend_error_mapper::coverage_classify_timeout_kind(
84                false,
85                Some(ReqwestErrorPhase::Send),
86            ),
87            backend_error_mapper::coverage_classify_timeout_kind(
88                false,
89                Some(ReqwestErrorPhase::Read),
90            ),
91            backend_error_mapper::coverage_classify_timeout_kind(false, None),
92        ]
93    }
94
95    /// Exercises non-timeout reqwest classifier branches with synthetic metadata.
96    ///
97    /// # Returns
98    /// Kinds selected for decode, status, invalid URL, and fallback categories.
99    pub fn classify_backend_error_kinds() -> Vec<HttpErrorKind> {
100        vec![
101            backend_error_mapper::coverage_classify_reqwest_error_kind(
102                false,
103                true,
104                false,
105                false,
106                false,
107                None,
108                HttpErrorKind::Transport,
109            ),
110            backend_error_mapper::coverage_classify_reqwest_error_kind(
111                false,
112                false,
113                true,
114                false,
115                false,
116                None,
117                HttpErrorKind::Transport,
118            ),
119            backend_error_mapper::coverage_classify_reqwest_error_kind(
120                false,
121                false,
122                false,
123                true,
124                false,
125                None,
126                HttpErrorKind::Transport,
127            ),
128            backend_error_mapper::coverage_classify_reqwest_error_kind(
129                false,
130                false,
131                false,
132                false,
133                false,
134                None,
135                HttpErrorKind::Transport,
136            ),
137        ]
138    }
139
140    /// Exercises HTTP retry mapping branches that are defensive in normal async use.
141    ///
142    /// # Returns
143    /// Diagnostic messages from each mapped retry terminal path.
144    pub fn exercise_http_retry_mapping_paths() -> Vec<String> {
145        let started_at = Instant::now();
146        let context = RetryContext::new(2, 2);
147        let attempts_exceeded = RetryError::<HttpError>::coverage_new(
148            RetryErrorReason::AttemptsExceeded,
149            None,
150            context,
151        );
152        let aborted =
153            RetryError::<HttpError>::coverage_new(RetryErrorReason::Aborted, None, context);
154        let unsupported = RetryError::<HttpError>::coverage_new(
155            RetryErrorReason::UnsupportedOperation,
156            None,
157            context,
158        );
159        let max_elapsed = RetryError::<HttpError>::coverage_new(
160            RetryErrorReason::MaxTotalElapsedExceeded,
161            None,
162            context,
163        );
164        let max_elapsed_unbounded = RetryError::<HttpError>::coverage_new(
165            RetryErrorReason::MaxTotalElapsedExceeded,
166            None,
167            context,
168        );
169
170        vec![
171            crate::HttpClient::coverage_map_retry_error(
172                attempts_exceeded,
173                started_at,
174                Some(Duration::from_millis(1)),
175                2,
176            )
177            .message,
178            crate::HttpClient::coverage_map_retry_error(
179                aborted,
180                started_at,
181                Some(Duration::from_millis(1)),
182                2,
183            )
184            .message,
185            crate::HttpClient::coverage_map_retry_error(
186                unsupported,
187                started_at,
188                Some(Duration::from_millis(1)),
189                2,
190            )
191            .message,
192            crate::HttpClient::coverage_map_retry_error(
193                max_elapsed,
194                started_at,
195                Some(Duration::from_millis(1)),
196                2,
197            )
198            .message,
199            crate::HttpClient::coverage_map_retry_error(max_elapsed_unbounded, started_at, None, 2)
200                .message,
201        ]
202    }
203
204    /// Exercises the retry failure decision branch for non-application failures.
205    ///
206    /// # Returns
207    /// Debug representation of the selected failure decision.
208    pub fn exercise_retry_failure_decision_path() -> String {
209        let retry_options = crate::HttpRetryOptions::default()
210            .to_executor_options()
211            .expect("default retry options should build");
212        let decision = crate::HttpClient::coverage_retry_failure_decision(
213            &AttemptFailure::Timeout,
214            &RetryContext::new(1, 2),
215            &crate::HttpRetryOptions::default(),
216            &retry_options,
217        );
218        format!("{decision:?}")
219    }
220
221    /// Validates an SSE response with a deliberately non-UTF8 Content-Type header.
222    ///
223    /// # Returns
224    /// The resulting error kind and message.
225    pub fn validate_non_utf8_sse_content_type() -> (HttpErrorKind, String) {
226        let mut headers = HeaderMap::new();
227        headers.insert(
228            http::header::CONTENT_TYPE,
229            HeaderValue::from_bytes(b"\xFF").expect("opaque header bytes should be accepted"),
230        );
231        let response = HttpResponse::new(
232            StatusCode::OK,
233            headers,
234            bytes::Bytes::new(),
235            Url::parse("https://example.com/sse").expect("coverage URL should parse"),
236            Method::GET,
237        );
238        let error = crate::sse::coverage_validate_sse_response_content_type(&response)
239            .expect_err("non-UTF8 content type should be rejected");
240        (error.kind, error.message)
241    }
242
243    /// Exercises response preview branches that require internal response state.
244    ///
245    /// # Returns
246    /// Preview strings and cancellation kind diagnostics.
247    pub async fn exercise_response_preview_paths() -> Vec<String> {
248        crate::response::coverage_exercise_response_preview_paths().await
249    }
250
251    /// Exercises request cache branches that are not directly exposed publicly.
252    ///
253    /// # Returns
254    /// Diagnostic strings from the resolved URL and header cache paths.
255    pub async fn exercise_request_cache_paths() -> Vec<String> {
256        crate::request::coverage_exercise_request_cache_paths().await
257    }
258
259    /// Exercises internal threshold-oriented defensive paths.
260    ///
261    /// # Returns
262    /// Diagnostic strings from factory, logger, client, and mapper paths.
263    pub async fn exercise_threshold_paths() -> Vec<String> {
264        let mut diagnostics = crate::client::coverage_exercise_factory_paths();
265        diagnostics.push(crate::client::coverage_exercise_request_log_url_fallback());
266        diagnostics.push(format!(
267            "{:?}",
268            crate::HttpClient::coverage_prepare_cancelled_error().await
269        ));
270        diagnostics.extend(crate::options::coverage_exercise_http_client_option_paths());
271        diagnostics.extend(crate::options::coverage_exercise_config_error_paths());
272        diagnostics.push(crate::options::coverage_exercise_retry_option_paths());
273        diagnostics
274    }
275}
276
277pub use client::http_logger::HttpLogger;
278pub use client::HttpClient;
279pub use client::HttpClientFactory;
280pub use constants::DEFAULT_SENSITIVE_HEADER_NAMES;
281pub use error::{HttpError, HttpErrorKind, HttpResult, RetryHint};
282pub use options::{
283    HttpClientOptions, HttpConfigError, HttpConfigErrorKind, HttpLoggingOptions,
284    HttpRetryMethodPolicy, HttpRetryOptions, HttpTimeoutOptions, ProxyOptions, ProxyType,
285    SensitiveHttpHeaders,
286};
287pub use qubit_retry::{RetryDelay, RetryJitter, RetryOptions};
288pub use request::{
289    AsyncHttpHeaderInjector, HttpHeaderInjector, HttpRequest, HttpRequestBody,
290    HttpRequestBodyByteStream, HttpRequestBuilder, HttpRequestInterceptor, HttpRequestInterceptors,
291    HttpRequestRetryOverride, HttpRequestStreamingBody,
292};
293pub use response::{
294    HttpByteStream, HttpResponse, HttpResponseInterceptor, HttpResponseInterceptors,
295    HttpResponseMeta,
296};
297pub use tokio_util::sync::CancellationToken;