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}