Skip to main content

nanny_runtime/tools/
http_get.rs

1// http_get: the first real tool.
2//
3// Makes a single HTTP GET request and returns the response body.
4//
5// Rules:
6// - URL argument is required and must start with http:// or https://
7// - Response body is capped at 1MB: fail closed on large responses
8// - Timeout is enforced: the tool cannot run forever
9// - Failure is reported as its own variant, never as an empty success
10// - Non-2xx HTTP responses are treated as failures
11
12use nanny_core::tool::{Tool, ToolArgs, ToolError, ToolOutput};
13use std::io::Read;
14use std::time::Duration;
15
16/// Maximum response body size.
17/// A tool that reads unbounded data is a resource leak.
18/// Fail closed at 1MB.
19const MAX_BODY_BYTES: u64 = 1024 * 1024;
20
21/// Default timeout for the HTTP request.
22const DEFAULT_TIMEOUT_MS: u64 = 5_000;
23
24// ── HttpGet ───────────────────────────────────────────────────────────────────
25
26/// A tool that makes a single HTTP GET request.
27///
28/// Declared cost: 10 units (charged only on success).
29/// Timeout: 5000ms by default, configurable via `with_timeout`.
30pub struct HttpGet {
31    timeout_ms: u64,
32}
33
34impl HttpGet {
35    /// Create a new HttpGet tool with the default 5000ms timeout.
36    pub fn new() -> Self {
37        Self {
38            timeout_ms: DEFAULT_TIMEOUT_MS,
39        }
40    }
41
42    /// Create an HttpGet tool with a custom timeout.
43    pub fn with_timeout(timeout_ms: u64) -> Self {
44        Self { timeout_ms }
45    }
46}
47
48impl Default for HttpGet {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl Tool for HttpGet {
55    fn name(&self) -> &str {
56        "http_get"
57    }
58
59    fn execute(&self, args: &ToolArgs) -> Result<ToolOutput, ToolError> {
60        // ── Step 1: Require the url argument ──────────────────────────────────
61        let url = args.get("url").ok_or_else(|| ToolError::InvalidArgument {
62            arg: "url".to_string(),
63            reason: "required argument missing".to_string(),
64        })?;
65
66        // ── Step 2: Validate URL format ───────────────────────────────────────
67        //
68        // We do not resolve DNS, follow redirects, or check reachability here.
69        // We only verify the shape is safe to pass to the HTTP client.
70        if !url.starts_with("http://") && !url.starts_with("https://") {
71            return Err(ToolError::InvalidArgument {
72                arg: "url".to_string(),
73                reason: format!("must start with http:// or https://, got: {url}"),
74            });
75        }
76
77        // ── Step 3: Build the HTTP agent with timeout ─────────────────────────
78        //
79        // The agent is created per-call intentionally: no connection pooling,
80        // no shared state between tool executions. Each call is independent.
81        //
82        // `timeout_global` bounds the whole operation, not each socket read, so
83        // a server that trickles bytes forever still cannot outlive the deadline.
84        let agent = ureq::Agent::new_with_config(
85            ureq::Agent::config_builder()
86                .timeout_global(Some(Duration::from_millis(self.timeout_ms)))
87                .build(),
88        );
89
90        // ── Step 4: Make the request ──────────────────────────────────────────
91        let response = agent.get(url).call().map_err(|e| match e {
92            // Non-2xx HTTP status: the server replied but with an error.
93            ureq::Error::StatusCode(code) => ToolError::ExecutionFailed(format!("HTTP {code}")),
94            // A timeout is its own variant, so the deadline is reported as a
95            // timeout rather than guessed at from an error message.
96            ureq::Error::Timeout(_) => ToolError::Timeout {
97                timeout_ms: self.timeout_ms,
98            },
99            // Everything else is a transport failure: DNS, refused connection,
100            // TLS, or a malformed response.
101            other => ToolError::ExecutionFailed(other.to_string()),
102        })?;
103
104        // ── Step 5: Read the body with a hard size cap ────────────────────────
105        //
106        // `take(MAX_BODY_BYTES)` ensures we never read more than 1MB.
107        // If the response is larger, we stop at the limit and return what we have.
108        // This is intentional: fail closed on large payloads.
109        let mut body = String::new();
110        response
111            .into_body()
112            .into_reader()
113            .take(MAX_BODY_BYTES)
114            .read_to_string(&mut body)
115            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
116
117        Ok(ToolOutput { content: body })
118    }
119}
120
121// ── Tests ─────────────────────────────────────────────────────────────────────
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn tool() -> HttpGet {
128        HttpGet::new()
129    }
130
131    // ── Validation tests (no network required) ────────────────────────────────
132
133    #[test]
134    fn rejects_missing_url() {
135        let result = tool().execute(&ToolArgs::new());
136
137        assert!(matches!(
138            result,
139            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
140        ));
141    }
142
143    #[test]
144    fn rejects_url_without_scheme() {
145        let mut args = ToolArgs::new();
146        args.insert("url".to_string(), "example.com/path".to_string());
147
148        let result = tool().execute(&args);
149
150        assert!(matches!(
151            result,
152            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
153        ));
154    }
155
156    #[test]
157    fn rejects_ftp_scheme() {
158        let mut args = ToolArgs::new();
159        args.insert("url".to_string(), "ftp://example.com".to_string());
160
161        let result = tool().execute(&args);
162
163        assert!(matches!(
164            result,
165            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
166        ));
167    }
168
169    #[test]
170    fn accepts_http_scheme() {
171        // We only test that the URL passes validation, not that the request succeeds.
172        // A request to localhost:1 will fail at the network level, not the validation level.
173        let mut args = ToolArgs::new();
174        args.insert("url".to_string(), "http://localhost:1/test".to_string());
175
176        let result = tool().execute(&args);
177
178        // Any error here is a network error, not a validation error.
179        // The absence of InvalidArgument means validation passed.
180        assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
181    }
182
183    #[test]
184    fn accepts_https_scheme() {
185        let mut args = ToolArgs::new();
186        args.insert("url".to_string(), "https://localhost:1/test".to_string());
187
188        let result = tool().execute(&args);
189
190        assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
191    }
192
193    /// A server that accepts the connection and then says nothing must be
194    /// reported as a timeout, not as a generic execution failure. Before ureq 3
195    /// this was inferred by matching on the text of a transport error, so the
196    /// mapping is worth pinning to behaviour rather than to a message.
197    #[test]
198    fn a_server_that_never_replies_is_reported_as_a_timeout() {
199        use std::net::TcpListener;
200
201        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a local port");
202        let port = listener.local_addr().unwrap().port();
203
204        // Hold the accepted connection open without writing a response.
205        std::thread::spawn(move || {
206            let held: Vec<_> = listener.incoming().take(1).filter_map(Result::ok).collect();
207            std::thread::sleep(Duration::from_secs(5));
208            drop(held);
209        });
210
211        let mut args = ToolArgs::new();
212        args.insert("url".to_string(), format!("http://127.0.0.1:{port}/"));
213
214        let result = HttpGet::with_timeout(250).execute(&args);
215
216        assert!(
217            matches!(result, Err(ToolError::Timeout { timeout_ms: 250 })),
218            "expected a timeout, got {result:?}"
219        );
220    }
221
222    #[test]
223    fn name_is_http_get() {
224        assert_eq!(tool().name(), "http_get");
225    }
226}