Skip to main content

agent_core/core/
error.rs

1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum RuntimeError {
5    #[error("API error: {0}")]
6    Api(#[from] reqwest::Error),
7    #[error("{0}")]
8    ApiStatus(String),
9    #[error("Auth error: {0}")]
10    Auth(String),
11    #[error("Config error: {0}")]
12    Config(String),
13    #[error("Session error: {0}")]
14    Session(String),
15    #[error("Tool execution failed: {0}")]
16    Tool(String),
17    #[error("Request timed out")]
18    Timeout,
19    #[error("Operation canceled")]
20    Canceled,
21}
22
23/// Translate an Anthropic API error response into a human-actionable message.
24///
25/// SECURITY (spec §5.1): the response `body` — including any nested
26/// `error.message` / `error.type` — is UNTRUSTED. A hostile or misconfigured
27/// provider can echo the entire request (prompts, system text, tool schemas,
28/// credentials) inside it, so no body-derived text is ever reproduced in the
29/// returned message. The body is used only for *classification* (matching
30/// against fixed, vetted patterns); output is built exclusively from static
31/// guidance plus the numeric status.
32pub fn humanize_api_error(status: u16, body: &str) -> String {
33    humanize_api_error_with_reset(status, body, None)
34}
35
36/// Anthropic wire error types we recognise. Matching one lets the message
37/// name the class via OUR static string — never the provider's bytes.
38const VETTED_ERROR_TYPES: &[&str] = &[
39    "invalid_request_error",
40    "authentication_error",
41    "permission_error",
42    "not_found_error",
43    "request_too_large",
44    "rate_limit_error",
45    "api_error",
46    "overloaded_error",
47    "billing_error",
48    "timeout_error",
49];
50
51/// Extract `error.type` from an Anthropic error envelope and map it onto a
52/// vetted static label. Returns `None` for anything unrecognised — the
53/// untrusted value itself is never surfaced.
54fn vetted_error_type(body: &str) -> Option<&'static str> {
55    let v: serde_json::Value = serde_json::from_str(body).ok()?;
56    let ty = v.get("error")?.get("type")?.as_str()?;
57    VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
58}
59
60/// Classification-only peek at the untrusted error body: true when it
61/// contains the given fixed pattern. The body text itself is never emitted.
62fn body_mentions(body: &str, pattern: &str) -> bool {
63    body.contains(pattern)
64}
65
66/// Map an untrusted provider error-type string onto a vetted static label.
67///
68/// Used by streaming/sync callers that already parsed the envelope: the
69/// returned `&'static str` is OUR constant, safe to log/display; the input
70/// itself must never be reproduced.
71pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
72    VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
73}
74
75/// Like [`humanize_api_error`] but surfaces a known rate-limit reset time in
76/// the 429 message so the failure is honest rather than cryptic.
77/// `reset_hint` is a human-readable duration string, e.g. `"47s"`.
78pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
79    // Vetted static class label, e.g. " [api_error]" — safe because it is
80    // one of OUR constants, selected (not copied) via the untrusted body.
81    let kind = vetted_error_type(body)
82        .map(|t| format!(" [{t}]"))
83        .unwrap_or_default();
84
85    match status {
86        529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
87        429 => {
88            if let Some(reset) = reset_hint {
89                format!(
90                    "Rate limit exhausted — retries used up while waiting for reset (next window in {}). \
91                     Try again shortly, or switch models with /model.",
92                    reset
93                )
94            } else {
95                format!("Rate limited by Anthropic (HTTP 429{kind}). Wait for the limit to reset, or switch models with /model.")
96            }
97        }
98        401 => "Authentication rejected. Run `synaps login` to re-authenticate.".to_string(),
99        403 => format!("Access denied (HTTP 403{kind}). Your account may not have access to this model."),
100        404 => format!("Model or endpoint not found (HTTP 404{kind}). Check the model name with /model."),
101        413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
102        400 if body_mentions(body, "Consumer Terms") =>
103            "Anthropic requires accepting updated Consumer Terms: sign in at claude.ai with this account, accept the terms, then retry.".to_string(),
104        400 if body_mentions(body, "extended-cache-ttl") =>
105            "Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
106        400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
107            "Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
108        400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
109        500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
110        _ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
111    }
112}
113
114/// Translate a reqwest transport error into a human-actionable message.
115pub fn humanize_network_error(e: &reqwest::Error) -> String {
116    if e.is_timeout() {
117        "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
118    } else if e.is_connect() {
119        "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
120    } else if e.is_body() || e.is_decode() {
121        "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
122    } else {
123        format!("Network error: {}", e)
124    }
125}
126
127/// Render an error's full cause chain (`Display` of every level, `: `-joined).
128///
129/// `format!("{e}")` on a `reqwest::Error` prints only the top level — e.g.
130/// `error sending request for url (…)` — and silently drops the source that
131/// says *why* (`operation timed out`, `dns error`, `connection refused`, …).
132/// That cost a real postmortem hours; always surface the chain.
133pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
134    let mut msg = e.to_string();
135    let mut source = e.source();
136    while let Some(cause) = source {
137        let cause_str = cause.to_string();
138        // Some wrappers already embed their source's Display; skip duplicates.
139        if !msg.contains(&cause_str) {
140            msg.push_str(": ");
141            msg.push_str(&cause_str);
142        }
143        source = cause.source();
144    }
145    msg
146}
147
148/// Host-aware variant of [`humanize_network_error`] for non-Anthropic
149/// providers (OpenAI Codex, Groq, local OpenAI-compat endpoints, …).
150///
151/// Unlike the Anthropic-specific helper this derives the host from the
152/// failing request's URL and preserves the underlying cause chain, so
153/// `chatgpt.com` failures are never misattributed and the *reason*
154/// (timeout vs connect vs mid-stream drop) survives into the UI.
155pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
156    let host = e
157        .url()
158        .and_then(|u| u.host_str())
159        .map(String::from)
160        .unwrap_or_else(|| "the provider endpoint".to_string());
161    let chain = error_chain_string(e);
162    if e.is_timeout() {
163        format!(
164            "Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
165        )
166    } else if e.is_connect() {
167        format!(
168            "Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
169        )
170    } else if e.is_body() || e.is_decode() {
171        format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
172    } else {
173        format!("Network error talking to {host}: {chain}")
174    }
175}
176
177pub type Result<T> = std::result::Result<T, RuntimeError>;
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_humanize_529_overloaded() {
185        let msg = humanize_api_error(
186            529,
187            r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
188        );
189        assert!(msg.contains("overloaded"), "got: {msg}");
190        assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
191    }
192
193    #[test]
194    fn test_humanize_401_points_to_login() {
195        let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
196        assert!(msg.contains("synaps login"), "got: {msg}");
197    }
198
199    #[test]
200    fn test_humanize_400_context_suggests_compact() {
201        let msg = humanize_api_error(
202            400,
203            r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
204        );
205        assert!(msg.contains("/compact"), "got: {msg}");
206    }
207
208    #[test]
209    fn test_humanize_400_cache_ttl_names_config_key() {
210        let msg = humanize_api_error(
211            400,
212            r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
213        );
214        assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
215    }
216
217    #[test]
218    fn test_humanize_unknown_status_names_status_but_withholds_detail() {
219        // Provider `error.message` is untrusted (can echo the request) —
220        // only the status and vetted guidance may appear.
221        let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
222        assert!(msg.contains("418"), "got: {msg}");
223        assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
224    }
225
226    #[test]
227    fn test_humanize_non_json_body_truncated() {
228        let long_body = "x".repeat(500);
229        let msg = humanize_api_error(418, &long_body);
230        assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
231    }
232
233    /// BUG-1 regression: byte-slicing at offset 200 panics when a multibyte
234    /// char (e.g. the 4-byte emoji 🔥) straddles that boundary.
235    /// Craft a body where the emoji starts at byte 198 (i.e. bytes 198-201),
236    /// so `&trimmed[..200]` would land in the middle of a char → panic.
237    /// The fixed code must NOT panic and must truncate cleanly at a char boundary.
238    #[test]
239    fn test_humanize_multibyte_boundary_no_panic() {
240        // 198 ASCII bytes + 🔥 (4 bytes, positions 198-201) + filler to exceed 200 total
241        let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
242        assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
243        // This must NOT panic — the bug causes a panic on unpatched code
244        let msg = humanize_api_error(418, &body);
245        // The result must be valid UTF-8 (it's a &str / String, always is if no panic)
246        // and must contain the truncated prefix (not the raw emoji bytes mid-char)
247        assert!(msg.contains("418"), "status should appear: {msg}");
248        // The body portion forwarded must be ≤ 200 bytes (emoji trimmed at boundary)
249        // After truncation at 198 bytes (the last safe boundary before 200), we get
250        // 198 'a's — confirm no garbled bytes leaked through
251        assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
252    }
253
254    // ── error_chain_string: full cause chain, not just the top Display ──────
255
256    #[derive(Debug)]
257    struct Outer(Inner);
258    #[derive(Debug)]
259    struct Inner;
260
261    impl std::fmt::Display for Outer {
262        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263            write!(f, "error sending request for url (https://example.com/x)")
264        }
265    }
266    impl std::fmt::Display for Inner {
267        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268            write!(f, "operation timed out")
269        }
270    }
271    impl std::error::Error for Outer {
272        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
273            Some(&self.0)
274        }
275    }
276    impl std::error::Error for Inner {}
277
278    #[test]
279    fn error_chain_string_joins_all_sources() {
280        let msg = error_chain_string(&Outer(Inner));
281        assert!(
282            msg.contains("error sending request") && msg.contains("operation timed out"),
283            "chain must include top-level AND source: {msg}"
284        );
285    }
286
287    #[test]
288    fn error_chain_string_single_level_has_no_separator_suffix() {
289        let msg = error_chain_string(&Inner);
290        assert_eq!(msg, "operation timed out");
291    }
292
293    // ── humanize_provider_network_error: host-aware transport messaging ─────
294
295    /// Listener that accepts connections but never responds → client-side
296    /// timeout while awaiting response headers (the incident failure mode).
297    #[tokio::test]
298    async fn humanize_provider_timeout_names_host_and_says_transient() {
299        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
300        let addr = listener.local_addr().unwrap();
301        // Keep the listener alive but never accept/respond.
302        let client = reqwest::Client::builder()
303            .timeout(std::time::Duration::from_millis(100))
304            .build()
305            .unwrap();
306        let err = client
307            .post(format!("http://{addr}/codex/responses"))
308            .send()
309            .await
310            .expect_err("must time out");
311        drop(listener);
312        assert!(err.is_timeout(), "precondition: {err:?}");
313        let msg = humanize_provider_network_error(&err);
314        assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
315        assert!(msg.contains("timed out"), "must say timed out: {msg}");
316        assert!(msg.contains("transient"), "must flag transience: {msg}");
317        assert!(
318            !msg.contains("api.anthropic.com"),
319            "must not claim the Anthropic host: {msg}"
320        );
321    }
322
323    #[tokio::test]
324    async fn humanize_provider_connect_error_names_host() {
325        // Bind then drop → guaranteed-refused port.
326        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
327        let addr = listener.local_addr().unwrap();
328        drop(listener);
329        let client = reqwest::Client::new();
330        let err = client
331            .post(format!("http://{addr}/codex/responses"))
332            .send()
333            .await
334            .expect_err("must fail to connect");
335        assert!(err.is_connect(), "precondition: {err:?}");
336        let msg = humanize_provider_network_error(&err);
337        assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
338        assert!(
339            msg.contains("Could not reach"),
340            "must describe connection failure: {msg}"
341        );
342    }
343
344    // ── Phase 1 holdout regression: hostile provider echoes the request ─────
345    //
346    // A hostile/misconfigured provider can put the ENTIRE request body
347    // (user messages, system prompts, tool schemas, credentials) into
348    // `error.message`. spec §5.1 / Task 1: no raw-content sentinel may
349    // appear in logs or user-visible errors at any level. The humanizer
350    // must therefore never echo any body-derived text.
351
352    const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
353
354    /// Anthropic-shaped error envelope whose message echoes a request body.
355    fn hostile_json_body() -> String {
356        format!(
357            r#"{{"type":"error","error":{{"type":"api_error","message":"ECHOED:{{\"messages\":[{{\"role\":\"user\",\"content\":\"{} tell me things\"}}],\"system\":[{{\"text\":\"You are a secret system prompt\"}}],\"tools\":[{{\"name\":\"bash\",\"input_schema\":{{\"properties\":{{}}}}}}]}}"}}}}"#,
358            HOSTILE_SENTINEL
359        )
360    }
361
362    /// Request-shaped markers that must never surface in a humanized error.
363    fn assert_no_request_content(msg: &str, ctx: &str) {
364        assert!(
365            !msg.contains(HOSTILE_SENTINEL),
366            "{ctx}: sentinel leaked: {msg}"
367        );
368        assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
369        assert!(
370            !msg.contains("input_schema") && !msg.contains("secret system prompt"),
371            "{ctx}: request-shaped content leaked: {msg}"
372        );
373    }
374
375    #[test]
376    fn hostile_error_message_never_leaks_for_any_status() {
377        let body = hostile_json_body();
378        for status in [400u16, 403, 404, 429, 500, 418] {
379            let msg = humanize_api_error(status, &body);
380            assert_no_request_content(&msg, &format!("status {status}"));
381            // Still actionable: names the status class.
382            assert!(
383                msg.contains(&status.to_string())
384                    || msg.contains("Rate limited")
385                    || msg.contains("Access denied")
386                    || msg.contains("not found"),
387                "status {status}: message not actionable: {msg}"
388            );
389        }
390    }
391
392    #[test]
393    fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
394        let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
395        assert_no_request_content(&msg, "429+reset");
396        assert!(msg.contains("47s"), "reset timing must survive: {msg}");
397    }
398
399    #[test]
400    fn hostile_non_json_body_never_leaks() {
401        // Non-JSON hostile content (e.g. text/plain echo of the request).
402        let body = format!(
403            "raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
404            HOSTILE_SENTINEL,
405            "x".repeat(300)
406        );
407        for status in [400u16, 403, 404, 429, 500, 418] {
408            let msg = humanize_api_error(status, &body);
409            assert_no_request_content(&msg, &format!("non-json status {status}"));
410        }
411    }
412
413    #[test]
414    fn hostile_error_type_field_is_not_echoed_verbatim() {
415        // `error.type` is attacker-controlled too — only vetted static
416        // labels may be reproduced.
417        let body = format!(
418            r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
419            HOSTILE_SENTINEL
420        );
421        for status in [400u16, 403, 404, 429, 500, 418] {
422            let msg = humanize_api_error(status, &body);
423            assert_no_request_content(&msg, &format!("type-field status {status}"));
424        }
425    }
426
427    #[test]
428    fn test_runtime_error_display() {
429        assert_eq!(
430            format!("{}", RuntimeError::Auth("bad token".into())),
431            "Auth error: bad token"
432        );
433
434        assert_eq!(
435            format!("{}", RuntimeError::Config("missing".into())),
436            "Config error: missing"
437        );
438
439        assert_eq!(
440            format!("{}", RuntimeError::Tool("failed".into())),
441            "Tool execution failed: failed"
442        );
443
444        assert_eq!(
445            format!("{}", RuntimeError::Session("not found".into())),
446            "Session error: not found"
447        );
448
449        assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
450
451        assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
452    }
453
454    #[test]
455    fn test_runtime_error_to_string() {
456        assert_eq!(
457            RuntimeError::Auth("bad token".into()).to_string(),
458            "Auth error: bad token"
459        );
460
461        assert_eq!(
462            RuntimeError::Config("missing".into()).to_string(),
463            "Config error: missing"
464        );
465
466        assert_eq!(
467            RuntimeError::Tool("failed".into()).to_string(),
468            "Tool execution failed: failed"
469        );
470
471        assert_eq!(
472            RuntimeError::Session("not found".into()).to_string(),
473            "Session error: not found"
474        );
475
476        assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
477
478        assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
479    }
480}