defect_http/lib.rs
1//! HTTP infrastructure shared across modules.
2//!
3//! A thin wrapper on top of `client_util::build_https_client` that adds: timeouts,
4//! transport retry with jitter, HTTP/HTTPS proxy support, and a unified `User-Agent`.
5//! HTTP client abstraction for the agent.
6//!
7//! Current consumers: `defect-llm` (various LLM providers); planned: `defect-tools`'
8//! fetch tool. This layer is extracted into its own crate to prevent the latter from
9//! depending on `defect-llm` (which would create an inverted dependency).
10//!
11//! Public entry points are only [`build_http_stack`], [`HttpStackConfig`], [`HttpStack`],
12//! and [`HttpStackError`]. Concrete layer implementations live in submodules as
13//! `pub(crate)` and are not exposed outside the crate — callers see only a type-erased
14//! Service.
15
16use std::time::Duration;
17
18use http::HeaderValue;
19use hyper_util::client::legacy::Client as HyperClient;
20use hyper_util::rt::TokioExecutor;
21use thiserror::Error;
22use tower::ServiceBuilder;
23use tower::util::BoxCloneSyncService;
24
25use defect_core::error::BoxError;
26
27mod fetch;
28mod proxy;
29mod retry;
30mod trace;
31mod user_agent;
32
33pub use fetch::{
34 FetchHttpClient, build_default_fetch_client_arc, build_fetch_client, build_fetch_client_arc,
35};
36pub use proxy::{ProxyAwareConnector, build_proxy_connector};
37pub use user_agent::default_user_agent;
38
39/// Type-erased service returned by `build_http_stack`.
40///
41/// Takes a `toac::Request` and returns `http::Response<hyper::body::Incoming>`,
42/// with errors unified as [`HttpStackError`]. Each provider passes this to
43/// `toac::ApiClient::new`.
44///
45/// Uses [`BoxCloneSyncService`] instead of `BoxService`: toac's `tower::Service`
46/// impl requires `S: Clone` so that after `poll_ready`, a lock-free clone can be
47/// taken for the future — see the `mem::replace` pattern in toac's `lib.rs`.
48pub type HttpStack =
49 BoxCloneSyncService<toac::Request, http::Response<hyper::body::Incoming>, HttpStackError>;
50
51/// HTTP stack configuration.
52///
53/// `Default::default()` provides recommended values: `total_timeout = 600s`,
54/// `transport_retries = 2`, `initial_backoff = 200ms`, `user_agent = None`
55/// (compile-time default), `proxy = ProxyConfig::FromEnv`.
56#[derive(Debug, Clone)]
57pub struct HttpStackConfig {
58 /// Total timeout for a single request. `None` means no limit. For SSE streaming
59 /// responses, the timer starts after the first byte arrives and continues until the
60 /// stream ends — the default of 600s covers the maximum reasonable duration for
61 /// Anthropic extended thinking.
62 pub total_timeout: Option<Duration>,
63
64 /// Maximum number of transport error retries (excluding the initial attempt). `0`
65 /// disables the retry layer. Only retries transport-level jitter (DNS / TCP / TLS /
66 /// hyper IO); any HTTP status code is treated as "success" and passed through —
67 /// business-level retries are handled in the turn loop.
68 pub transport_retries: u8,
69
70 /// Initial backoff for retries. Each retry multiplies by 2, adds ±25% jitter, and
71 /// caps at 30s.
72 pub initial_backoff: Duration,
73
74 /// `User-Agent` header value. When `None`, uses the compile-time default
75 /// (`defect-http/{version} ({git_sha[..8]})`).
76 pub user_agent: Option<String>,
77
78 /// Proxy configuration.
79 pub proxy: ProxyConfig,
80}
81
82impl Default for HttpStackConfig {
83 fn default() -> Self {
84 Self {
85 total_timeout: Some(Duration::from_secs(600)),
86 transport_retries: 2,
87 initial_backoff: Duration::from_millis(200),
88 user_agent: None,
89 proxy: ProxyConfig::FromEnv,
90 }
91 }
92}
93
94/// Proxy configuration.
95#[derive(Debug, Clone, Default)]
96pub enum ProxyConfig {
97 /// Reads `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` from the environment.
98 #[default]
99 FromEnv,
100 /// Explicitly provided.
101 Explicit(ProxySettings),
102 /// Forcefully disable proxying, even if environment variables are set.
103 Disabled,
104}
105
106/// Explicit proxy settings. `http_proxy` / `https_proxy` may each be `None`;
107/// `no_proxy` is a list of domain suffixes (following the GNU `NO_PROXY` convention).
108#[derive(Debug, Clone, Default)]
109pub struct ProxySettings {
110 pub http_proxy: Option<http::Uri>,
111 pub https_proxy: Option<http::Uri>,
112 pub no_proxy: Vec<String>,
113}
114
115/// HTTP stack-layer error.
116///
117/// Corresponds to the `E` in `toac::CallError<E>` — the provider translates this error
118/// into `ProviderErrorKind` in `call_error_to_provider` (see HTTP retry/error semantics).
119#[derive(Debug, Error)]
120#[non_exhaustive]
121pub enum HttpStackError {
122 /// Transport error (DNS, TCP, TLS, hyper I/O, etc.).
123 #[error("HTTP transport error: {0}")]
124 Transport(#[source] BoxError),
125
126 /// Request timed out. `phase` indicates which stage timed out — currently only
127 /// supports `Total`.
128 /// Staged timeouts for HTTP requests.
129 #[error("HTTP request timed out (phase = {phase:?})")]
130 Timeout { phase: TimeoutPhase },
131
132 /// HTTP layer configuration error (e.g., proxy URL parsing failure).
133 #[error("HTTP layer config invalid: {hint}")]
134 Config { hint: String },
135
136 /// Proxy CONNECT phase failed.
137 #[error("proxy CONNECT failed: {hint}")]
138 ProxyConnect { hint: String },
139}
140
141/// Timeout phase. Mirrors [`defect_core::llm::TimeoutPhase`], but this crate does not
142/// reference the agent's type internally to avoid coupling the layer implementation to
143/// the LLM error model.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[non_exhaustive]
146pub enum TimeoutPhase {
147 Connect,
148 ReadHeaders,
149 ReadBody,
150 Idle,
151 Total,
152}
153
154/// Builds the full HTTP stack; the result can be fed directly to `toac::ApiClient::new`.
155///
156/// Current layer order (outer → inner, request direction):
157/// `UserAgent → Trace → Timeout? → hyper-util Client`
158///
159/// `Timeout` is inserted only when `config.total_timeout = Some(_)` — when `None`,
160/// the entire timeout layer is skipped. This avoids a type mismatch with `Identity`
161/// when `tower::timeout` wraps the error as [`tower::BoxError`] (`option_layer`
162/// does not change the error type on the `None` path).
163pub fn build_http_stack(config: HttpStackConfig) -> Result<HttpStack, HttpStackError> {
164 // The connector layer merges TLS + proxy in one pass: `ProxyConnector` transparently
165 // passes through when no entries are configured, so `Disabled` also uses the same
166 // connector type, avoiding two forked `HyperClient` types behind an `if`.
167 let connector = proxy::build_proxy_connector(&config.proxy)?;
168 let inner =
169 HyperClient::builder(TokioExecutor::default()).build::<_, toac::body::Body>(connector);
170
171 // Maps `hyper-util Client` errors to `HttpStackError::Transport`
172 let transport = ServiceBuilder::new()
173 .map_err(|e: hyper_util::client::legacy::Error| HttpStackError::Transport(BoxError::new(e)))
174 .service(inner);
175
176 let ua_value = match &config.user_agent {
177 Some(s) => HeaderValue::from_str(s).map_err(|e| HttpStackError::Config {
178 hint: format!("invalid user_agent: {e}"),
179 })?,
180 None => user_agent::default_user_agent(),
181 };
182
183 let retry_layer = (config.transport_retries > 0)
184 .then(|| retry::TransportRetryLayer::new(config.transport_retries, config.initial_backoff));
185
186 let retried = ServiceBuilder::new()
187 .option_layer(retry_layer)
188 .service(transport);
189
190 let stack = if let Some(timeout) = config.total_timeout {
191 let s = ServiceBuilder::new()
192 .layer(user_agent::UserAgentLayer::new(ua_value))
193 .layer(trace::TraceLayer)
194 .map_err(map_timeout_error)
195 .layer(tower::timeout::TimeoutLayer::new(timeout))
196 .service(retried);
197 BoxCloneSyncService::new(s)
198 } else {
199 let s = ServiceBuilder::new()
200 .layer(user_agent::UserAgentLayer::new(ua_value))
201 .layer(trace::TraceLayer)
202 .service(retried);
203 BoxCloneSyncService::new(s)
204 };
205
206 Ok(stack)
207}
208
209/// Converts a [`tower::BoxError`] from [`tower::timeout`] back into an
210/// [`HttpStackError`]:
211/// - [`tower::timeout::error::Elapsed`] → `Timeout { phase: Total }`
212/// - Otherwise it should be an inner [`HttpStackError`]—[`tower::timeout`] boxes it, so
213/// `downcast` recovers it
214/// - Last resort (should not happen) → `Transport`, preserving the original source
215fn map_timeout_error(err: tower::BoxError) -> HttpStackError {
216 if err.is::<tower::timeout::error::Elapsed>() {
217 return HttpStackError::Timeout {
218 phase: TimeoutPhase::Total,
219 };
220 }
221 match err.downcast::<HttpStackError>() {
222 Ok(boxed) => *boxed,
223 Err(other) => HttpStackError::Transport(BoxError::from(other)),
224 }
225}