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}