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
23pub fn humanize_api_error(status: u16, body: &str) -> String {
33 humanize_api_error_with_reset(status, body, None)
34}
35
36const 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
51fn 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
60fn body_mentions(body: &str, pattern: &str) -> bool {
63 body.contains(pattern)
64}
65
66pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
72 VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
73}
74
75pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
79 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
114pub 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
127pub 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 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
148pub 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 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 #[test]
239 fn test_humanize_multibyte_boundary_no_panic() {
240 let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
242 assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
243 let msg = humanize_api_error(418, &body);
245 assert!(msg.contains("418"), "status should appear: {msg}");
248 assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
252 }
253
254 #[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 #[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 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 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 const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
353
354 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 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 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 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 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}