Skip to main content

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_agent::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_agent::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}