Skip to main content

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