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// - Cost is only charged on success — failed calls do not spend budget
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/// Cost units charged for a successful http_get call.
22const HTTP_GET_COST: u64 = 10;
23
24/// Default timeout for the HTTP request.
25const DEFAULT_TIMEOUT_MS: u64 = 5_000;
26
27// ── HttpGet ───────────────────────────────────────────────────────────────────
28
29/// A tool that makes a single HTTP GET request.
30///
31/// Declared cost: 10 units (charged only on success).
32/// Timeout: 5000ms by default, configurable via `with_timeout`.
33pub struct HttpGet {
34    timeout_ms: u64,
35}
36
37impl HttpGet {
38    /// Create a new HttpGet tool with the default 5000ms timeout.
39    pub fn new() -> Self {
40        Self {
41            timeout_ms: DEFAULT_TIMEOUT_MS,
42        }
43    }
44
45    /// Create an HttpGet tool with a custom timeout.
46    pub fn with_timeout(timeout_ms: u64) -> Self {
47        Self { timeout_ms }
48    }
49}
50
51impl Default for HttpGet {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl Tool for HttpGet {
58    fn name(&self) -> &str {
59        "http_get"
60    }
61
62    /// Cost charged on success only.
63    /// The ledger is never debited for a failed request.
64    fn declared_cost(&self) -> u64 {
65        HTTP_GET_COST
66    }
67
68    fn execute(&self, args: &ToolArgs) -> Result<ToolOutput, ToolError> {
69        // ── Step 1: Require the url argument ──────────────────────────────────
70        let url = args.get("url").ok_or_else(|| ToolError::InvalidArgument {
71            arg: "url".to_string(),
72            reason: "required argument missing".to_string(),
73        })?;
74
75        // ── Step 2: Validate URL format ───────────────────────────────────────
76        //
77        // We do not resolve DNS, follow redirects, or check reachability here.
78        // We only verify the shape is safe to pass to the HTTP client.
79        if !url.starts_with("http://") && !url.starts_with("https://") {
80            return Err(ToolError::InvalidArgument {
81                arg: "url".to_string(),
82                reason: format!(
83                    "must start with http:// or https://, got: {url}"
84                ),
85            });
86        }
87
88        // ── Step 3: Build the HTTP agent with timeout ─────────────────────────
89        //
90        // The agent is created per-call intentionally — no connection pooling,
91        // no shared state between tool executions. Each call is independent.
92        let agent = ureq::AgentBuilder::new()
93            .timeout(Duration::from_millis(self.timeout_ms))
94            .build();
95
96        // ── Step 4: Make the request ──────────────────────────────────────────
97        let response = agent.get(url).call().map_err(|e| match e {
98            // Non-2xx HTTP status — the server replied but with an error.
99            ureq::Error::Status(code, _) => {
100                ToolError::ExecutionFailed(format!("HTTP {code}"))
101            }
102            // Transport-level error — timeout, DNS failure, connection refused.
103            ureq::Error::Transport(ref t) => {
104                // ureq surfaces timeouts as transport errors.
105                // We detect them by message content — not ideal but correct for v0.1.
106                let msg = t.to_string();
107                if msg.contains("timed out") || msg.contains("deadline") {
108                    ToolError::Timeout {
109                        timeout_ms: self.timeout_ms,
110                    }
111                } else {
112                    ToolError::ExecutionFailed(msg)
113                }
114            }
115        })?;
116
117        // ── Step 5: Read the body with a hard size cap ────────────────────────
118        //
119        // `take(MAX_BODY_BYTES)` ensures we never read more than 1MB.
120        // If the response is larger, we stop at the limit and return what we have.
121        // This is intentional — fail closed on large payloads.
122        let mut body = String::new();
123        response
124            .into_reader()
125            .take(MAX_BODY_BYTES)
126            .read_to_string(&mut body)
127            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
128
129        Ok(ToolOutput { content: body })
130    }
131}
132
133// ── Tests ─────────────────────────────────────────────────────────────────────
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn tool() -> HttpGet {
140        HttpGet::new()
141    }
142
143    // ── Validation tests (no network required) ────────────────────────────────
144
145    #[test]
146    fn rejects_missing_url() {
147        let result = tool().execute(&ToolArgs::new());
148
149        assert!(matches!(
150            result,
151            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
152        ));
153    }
154
155    #[test]
156    fn rejects_url_without_scheme() {
157        let mut args = ToolArgs::new();
158        args.insert("url".to_string(), "example.com/path".to_string());
159
160        let result = tool().execute(&args);
161
162        assert!(matches!(
163            result,
164            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
165        ));
166    }
167
168    #[test]
169    fn rejects_ftp_scheme() {
170        let mut args = ToolArgs::new();
171        args.insert("url".to_string(), "ftp://example.com".to_string());
172
173        let result = tool().execute(&args);
174
175        assert!(matches!(
176            result,
177            Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
178        ));
179    }
180
181    #[test]
182    fn accepts_http_scheme() {
183        // We only test that the URL passes validation, not that the request succeeds.
184        // A request to localhost:1 will fail at the network level, not the validation level.
185        let mut args = ToolArgs::new();
186        args.insert("url".to_string(), "http://localhost:1/test".to_string());
187
188        let result = tool().execute(&args);
189
190        // Any error here is a network error, not a validation error.
191        // The absence of InvalidArgument means validation passed.
192        assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
193    }
194
195    #[test]
196    fn accepts_https_scheme() {
197        let mut args = ToolArgs::new();
198        args.insert("url".to_string(), "https://localhost:1/test".to_string());
199
200        let result = tool().execute(&args);
201
202        assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
203    }
204
205    #[test]
206    fn declared_cost_is_ten() {
207        assert_eq!(tool().declared_cost(), 10);
208    }
209
210    #[test]
211    fn name_is_http_get() {
212        assert_eq!(tool().name(), "http_get");
213    }
214}