Skip to main content

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}