defect_core/http.rs
1//! HTTP fetch backend abstraction.
2//!
3//! [`HttpClient`] is the trait boundary between the `fetch` tool and the underlying HTTP
4//! stack. The concrete implementation comes from `defect-http`; during session assembly
5//! the CLI injects `Arc<dyn HttpClient>` into the agent core, propagated through the tool
6//! context to tools.
7//!
8//! Unlike the per-session `FsBackend` / `ShellBackend` (which live in `defect-agent`),
9//! HTTP has no per-client capability negotiation, so [`HttpClient`] is shared at the
10//! process level rather than assembled per session; it simply reuses the same
11//! `Arc<dyn …>` injection pattern to avoid introducing a new injection path.
12
13use std::time::Duration;
14
15use futures::future::BoxFuture;
16use thiserror::Error;
17
18use crate::error::BoxError;
19
20/// An HTTP fetch request.
21///
22/// Currently `GET`-only — the `fetch` tool's schema also exposes only read semantics, not
23/// method / header / body / auth.
24#[derive(Debug, Clone)]
25pub struct HttpRequest {
26 /// Absolute `http://` / `https://` URL. Other schemes are rejected early by the fetch
27 /// tool layer.
28 pub url: String,
29 /// Per-request total timeout; `None` lets the backend use the stack-level default.
30 pub timeout: Option<Duration>,
31 /// Whether to follow 3xx `Location` redirects. When `false`, treat 3xx responses as
32 /// terminal.
33 pub follow_redirects: bool,
34 /// Maximum number of redirect hops to follow; ignored when `follow_redirects` is
35 /// `false`.
36 pub max_redirects: u32,
37 /// Maximum accumulated body size; if exceeded the body is truncated and
38 /// `HttpResponse::truncated` is set to `true`.
39 pub max_response_bytes: u64,
40}
41
42/// A response that was fetched successfully.
43///
44/// `status` is the status code of the final response (after following redirects);
45/// `final_url` is analogous.
46#[derive(Debug, Clone)]
47pub struct HttpResponse {
48 pub status: u16,
49 /// The raw `content-type` header value (the tool layer should strip parameters like
50 /// boundary/charset to get the main type); `None` if the server did not set it.
51 pub content_type: Option<String>,
52 /// Body truncated to `max_response_bytes`.
53 pub body: Vec<u8>,
54 /// Number of bytes the server actually sent (excluding bytes discarded by truncation
55 /// — the backend stops reading when truncating, so this is approximate and for
56 /// reference only).
57 pub bytes_received: u64,
58 /// `true` if the body was truncated because it exceeded `max_response_bytes`.
59 pub truncated: bool,
60 /// Number of redirects followed. 0 means the first response was final.
61 pub redirects: u32,
62 /// The final URL after following redirects; if no redirects were followed, this is
63 /// the same as `request.url`.
64 pub final_url: String,
65}
66
67#[non_exhaustive]
68#[derive(Debug, Error)]
69pub enum HttpClientError {
70 /// The URL could not be parsed (e.g., invalid scheme, missing host).
71 #[error("invalid URL: {0}")]
72 InvalidUrl(String),
73
74 /// The request timed out as a whole.
75 #[error("http request timed out")]
76 Timeout,
77
78 /// Exceeded `max_redirects` redirects. Contains the actual number of redirects
79 /// attempted.
80 #[error("too many redirects ({0})")]
81 TooManyRedirects(u32),
82
83 /// Transport-layer error (DNS / connect / TLS / IO); source is the underlying error.
84 #[error("http transport error: {0}")]
85 Transport(#[source] BoxError),
86}
87
88/// HTTP fetch backend trait.
89///
90/// Implementors must satisfy the following contract:
91/// - `fetch` must internally enforce the total timeout from `req.timeout` (including
92/// connect and read body);
93/// on timeout, return [`HttpClientError::Timeout`].
94/// - When `req.follow_redirects = true`, follow 3xx responses per RFC 7231, up to
95/// `req.max_redirects` hops; exceeding that returns
96/// [`HttpClientError::TooManyRedirects`].
97/// - When reading the body, stop after accumulating `req.max_response_bytes` and set
98/// `truncated = true` on the response.
99/// - Any HTTP status (including 4xx/5xx) is considered a success
100/// ([`HttpResponse::status`]
101/// is returned as-is); only transport or decode failures should return `Err`.
102pub trait HttpClient: Send + Sync {
103 fn fetch(&self, req: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, HttpClientError>>;
104}
105
106/// A placeholder implementation for testing or an `echo` provider. Every `fetch` call
107/// returns
108/// [`HttpClientError::Transport`], allowing assembly paths that require `Arc<dyn
109/// HttpClient>`
110/// to skip constructing a real HTTP stack.
111pub struct NoopHttpClient;
112
113impl HttpClient for NoopHttpClient {
114 fn fetch(&self, _req: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, HttpClientError>> {
115 Box::pin(async move {
116 Err(HttpClientError::Transport(BoxError::new(
117 std::io::Error::other("NoopHttpClient: HTTP fetch not configured"),
118 )))
119 })
120 }
121}