use thiserror::Error;
#[derive(Error, Debug)]
pub enum RuntimeError {
#[error("API error: {0}")]
Api(#[from] reqwest::Error),
#[error("{0}")]
ApiStatus(String),
#[error("Auth error: {0}")]
Auth(String),
#[error("Config error: {0}")]
Config(String),
#[error("Session error: {0}")]
Session(String),
#[error("Tool execution failed: {0}")]
Tool(String),
#[error("Request timed out")]
Timeout,
#[error("Operation canceled")]
Canceled,
}
pub fn humanize_api_error(status: u16, body: &str) -> String {
humanize_api_error_with_reset(status, body, None)
}
const VETTED_ERROR_TYPES: &[&str] = &[
"invalid_request_error",
"authentication_error",
"permission_error",
"not_found_error",
"request_too_large",
"rate_limit_error",
"api_error",
"overloaded_error",
"billing_error",
"timeout_error",
];
fn vetted_error_type(body: &str) -> Option<&'static str> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let ty = v.get("error")?.get("type")?.as_str()?;
VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
}
pub const VETTED_PROXY_ERROR_TYPES: &[&str] = &[
"invalid_request_error",
"authentication_error",
"permission_error",
"not_found_error",
"request_too_large",
"rate_limit_error",
"api_error",
"overloaded_error",
"billing_error",
"timeout_error",
"access_terminated_error",
"invalid_authentication_error",
"insufficient_quota",
"quota_exceeded",
"server_error",
"model_not_found",
"invalid_api_key",
];
pub fn vetted_proxy_error_label(body: &str) -> Option<&'static str> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let candidates = [
v.get("error").and_then(|e| e.get("type")),
v.get("error").and_then(|e| e.get("code")),
v.get("type"),
v.get("code"),
];
let label = candidates
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.find_map(|ty| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == ty).copied());
label
}
pub fn humanize_proxy_status_error(
provider: &str,
status: u16,
label: Option<&str>,
) -> Option<String> {
let label = label.and_then(|l| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == l).copied());
match (status, label?) {
(403, "access_terminated_error") => {
let mut msg = format!(
"{provider}: usage quota exhausted (HTTP 403). Your plan's usage window is used up — \
wait for the reset or upgrade, or switch models with /model."
);
if provider == "kimi-code" {
msg.push_str(
" Kimi Code quotas are weekly; the Kimi membership page shows the reset time.",
);
}
Some(msg)
}
(401, "invalid_authentication_error" | "authentication_error" | "invalid_api_key") => {
Some(format!(
"{provider}: authentication rejected (HTTP 401). Run `synaps login --provider {provider}`."
))
}
(429, "rate_limit_error") => Some(format!(
"{provider}: rate limited (HTTP 429). Wait for the limit to reset, or switch models with /model."
)),
(429, "insufficient_quota" | "quota_exceeded") => Some(format!(
"{provider}: usage quota exhausted (HTTP 429). Add credit or wait for the quota to reset, \
or switch models with /model."
)),
_ => None,
}
}
fn body_mentions(body: &str, pattern: &str) -> bool {
body.contains(pattern)
}
pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
}
pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
let kind = vetted_error_type(body)
.map(|t| format!(" [{t}]"))
.unwrap_or_default();
match status {
529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
429 => {
if let Some(reset) = reset_hint {
format!(
"Rate limit exhausted — retries used up while waiting for reset (next window in {}). \
Try again shortly, or switch models with /model.",
reset
)
} else {
format!("Rate limited by Anthropic (HTTP 429{kind}). Wait for the limit to reset, or switch models with /model.")
}
}
401 => "Authentication rejected. Run `synaps login` to re-authenticate.".to_string(),
403 => format!("Access denied (HTTP 403{kind}). Your account may not have access to this model."),
404 => format!("Model or endpoint not found (HTTP 404{kind}). Check the model name with /model."),
413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
400 if body_mentions(body, "Consumer Terms") =>
"Anthropic requires accepting updated Consumer Terms: sign in at claude.ai with this account, accept the terms, then retry.".to_string(),
400 if body_mentions(body, "extended-cache-ttl") =>
"Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
"Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
_ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
}
}
pub fn humanize_network_error(e: &reqwest::Error) -> String {
if e.is_timeout() {
"Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
} else if e.is_connect() {
"Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
} else if e.is_body() || e.is_decode() {
"Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
} else {
format!("Network error: {}", e)
}
}
pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
let mut msg = e.to_string();
let mut source = e.source();
while let Some(cause) = source {
let cause_str = cause.to_string();
if !msg.contains(&cause_str) {
msg.push_str(": ");
msg.push_str(&cause_str);
}
source = cause.source();
}
msg
}
pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
let host = e
.url()
.and_then(|u| u.host_str())
.map(String::from)
.unwrap_or_else(|| "the provider endpoint".to_string());
let chain = error_chain_string(e);
if e.is_timeout() {
format!(
"Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
)
} else if e.is_connect() {
format!(
"Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
)
} else if e.is_body() || e.is_decode() {
format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
} else {
format!("Network error talking to {host}: {chain}")
}
}
pub type Result<T> = std::result::Result<T, RuntimeError>;
#[cfg(test)]
mod tests {
use super::*;
const KIMI_403_BODY: &str = r#"{"error":{"type":"access_terminated_error","message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}"#;
#[test]
fn proxy_labels_never_contain_colon_and_are_superset_of_anthropic() {
for label in VETTED_PROXY_ERROR_TYPES {
assert!(
!label.contains(':'),
"label {label:?} would be truncated by the engine"
);
assert!(
!label.contains('['),
"label {label:?} breaks the [label] suffix"
);
}
for label in VETTED_ERROR_TYPES {
assert!(
VETTED_PROXY_ERROR_TYPES.contains(label),
"proxy list must be a superset: missing {label}"
);
}
}
#[test]
fn proxy_label_reads_error_type() {
assert_eq!(
vetted_proxy_error_label(KIMI_403_BODY),
Some("access_terminated_error")
);
}
#[test]
fn proxy_label_falls_back_to_error_code_then_top_level() {
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"code":"invalid_api_key","message":"x"}}"#),
Some("invalid_api_key")
);
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"type":"weird","code":"insufficient_quota"}}"#),
Some("insufficient_quota")
);
assert_eq!(
vetted_proxy_error_label(r#"{"type":"server_error"}"#),
Some("server_error")
);
assert_eq!(
vetted_proxy_error_label(r#"{"code":"model_not_found"}"#),
Some("model_not_found")
);
}
#[test]
fn proxy_label_rejects_non_string_non_json_and_hostile_values() {
assert_eq!(vetted_proxy_error_label(r#"{"error":{"type":42}}"#), None);
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"code":["a"]}}"#),
None
);
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"type":"<script>ECHO:secret"}}"#),
None
);
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"type":"access_terminated_error2"}}"#),
None
);
assert_eq!(
vetted_proxy_error_label(r#"{"error":{"type":"Access_Terminated_Error"}}"#),
None
);
assert_eq!(vetted_proxy_error_label("<html>403 Forbidden</html>"), None);
assert_eq!(vetted_proxy_error_label(""), None);
assert_eq!(vetted_proxy_error_label(&KIMI_403_BODY[..40]), None);
}
#[test]
fn humanize_proxy_403_access_terminated_is_quota_not_auth() {
let msg = humanize_proxy_status_error("kimi-code", 403, Some("access_terminated_error"))
.expect("known combo");
assert!(
msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
"got: {msg}"
);
assert!(msg.contains("/model"), "got: {msg}");
assert!(msg.contains("weekly"), "kimi-specific hint missing: {msg}");
assert!(msg.contains("membership page"), "got: {msg}");
assert!(
!msg.contains("login"),
"quota must not be misreported as auth: {msg}"
);
let generic = humanize_proxy_status_error("xai-auth", 403, Some("access_terminated_error"))
.expect("known combo");
assert!(
generic.starts_with("xai-auth: usage quota exhausted (HTTP 403)"),
"got: {generic}"
);
assert!(
!generic.contains("weekly"),
"kimi hint leaked to other provider: {generic}"
);
}
#[test]
fn humanize_proxy_401_points_to_provider_login() {
for label in [
"invalid_authentication_error",
"authentication_error",
"invalid_api_key",
] {
let msg = humanize_proxy_status_error("xai-auth", 401, Some(label)).expect(label);
assert!(msg.contains("HTTP 401"), "got: {msg}");
assert!(
msg.contains("synaps login --provider xai-auth"),
"got: {msg}"
);
}
}
#[test]
fn humanize_proxy_429_distinguishes_rate_limit_from_quota() {
let rl = humanize_proxy_status_error("groq", 429, Some("rate_limit_error")).unwrap();
let q1 = humanize_proxy_status_error("groq", 429, Some("insufficient_quota")).unwrap();
let q2 = humanize_proxy_status_error("groq", 429, Some("quota_exceeded")).unwrap();
assert!(rl.contains("rate limited"), "got: {rl}");
assert!(q1.contains("quota exhausted"), "got: {q1}");
assert_eq!(q1, q2);
assert_ne!(rl, q1);
}
#[test]
fn humanize_proxy_unknown_combos_return_none() {
assert_eq!(humanize_proxy_status_error("kimi-code", 403, None), None);
assert_eq!(
humanize_proxy_status_error("kimi-code", 403, Some("rate_limit_error")),
None
);
assert_eq!(
humanize_proxy_status_error("kimi-code", 500, Some("server_error")),
None
);
assert_eq!(
humanize_proxy_status_error("kimi-code", 401, Some("access_terminated_error")),
None
);
assert_eq!(
humanize_proxy_status_error("kimi-code", 403, Some("<script>ECHO")),
None
);
}
#[test]
fn test_humanize_529_overloaded() {
let msg = humanize_api_error(
529,
r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
);
assert!(msg.contains("overloaded"), "got: {msg}");
assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
}
#[test]
fn test_humanize_401_points_to_login() {
let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
assert!(msg.contains("synaps login"), "got: {msg}");
}
#[test]
fn test_humanize_400_context_suggests_compact() {
let msg = humanize_api_error(
400,
r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
);
assert!(msg.contains("/compact"), "got: {msg}");
}
#[test]
fn test_humanize_400_cache_ttl_names_config_key() {
let msg = humanize_api_error(
400,
r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
);
assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
}
#[test]
fn test_humanize_unknown_status_names_status_but_withholds_detail() {
let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
assert!(msg.contains("418"), "got: {msg}");
assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
}
#[test]
fn test_humanize_non_json_body_truncated() {
let long_body = "x".repeat(500);
let msg = humanize_api_error(418, &long_body);
assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
}
#[test]
fn test_humanize_multibyte_boundary_no_panic() {
let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
let msg = humanize_api_error(418, &body);
assert!(msg.contains("418"), "status should appear: {msg}");
assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
}
#[derive(Debug)]
struct Outer(Inner);
#[derive(Debug)]
struct Inner;
impl std::fmt::Display for Outer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "error sending request for url (https://example.com/x)")
}
}
impl std::fmt::Display for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "operation timed out")
}
}
impl std::error::Error for Outer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
impl std::error::Error for Inner {}
#[test]
fn error_chain_string_joins_all_sources() {
let msg = error_chain_string(&Outer(Inner));
assert!(
msg.contains("error sending request") && msg.contains("operation timed out"),
"chain must include top-level AND source: {msg}"
);
}
#[test]
fn error_chain_string_single_level_has_no_separator_suffix() {
let msg = error_chain_string(&Inner);
assert_eq!(msg, "operation timed out");
}
#[tokio::test]
async fn humanize_provider_timeout_names_host_and_says_transient() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(100))
.build()
.unwrap();
let err = client
.post(format!("http://{addr}/codex/responses"))
.send()
.await
.expect_err("must time out");
drop(listener);
assert!(err.is_timeout(), "precondition: {err:?}");
let msg = humanize_provider_network_error(&err);
assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
assert!(msg.contains("timed out"), "must say timed out: {msg}");
assert!(msg.contains("transient"), "must flag transience: {msg}");
assert!(
!msg.contains("api.anthropic.com"),
"must not claim the Anthropic host: {msg}"
);
}
#[tokio::test]
async fn humanize_provider_connect_error_names_host() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let client = reqwest::Client::new();
let err = client
.post(format!("http://{addr}/codex/responses"))
.send()
.await
.expect_err("must fail to connect");
assert!(err.is_connect(), "precondition: {err:?}");
let msg = humanize_provider_network_error(&err);
assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
assert!(
msg.contains("Could not reach"),
"must describe connection failure: {msg}"
);
}
const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
fn hostile_json_body() -> String {
format!(
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\":{{}}}}}}]}}"}}}}"#,
HOSTILE_SENTINEL
)
}
fn assert_no_request_content(msg: &str, ctx: &str) {
assert!(
!msg.contains(HOSTILE_SENTINEL),
"{ctx}: sentinel leaked: {msg}"
);
assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
assert!(
!msg.contains("input_schema") && !msg.contains("secret system prompt"),
"{ctx}: request-shaped content leaked: {msg}"
);
}
#[test]
fn hostile_error_message_never_leaks_for_any_status() {
let body = hostile_json_body();
for status in [400u16, 403, 404, 429, 500, 418] {
let msg = humanize_api_error(status, &body);
assert_no_request_content(&msg, &format!("status {status}"));
assert!(
msg.contains(&status.to_string())
|| msg.contains("Rate limited")
|| msg.contains("Access denied")
|| msg.contains("not found"),
"status {status}: message not actionable: {msg}"
);
}
}
#[test]
fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
assert_no_request_content(&msg, "429+reset");
assert!(msg.contains("47s"), "reset timing must survive: {msg}");
}
#[test]
fn hostile_non_json_body_never_leaks() {
let body = format!(
"raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
HOSTILE_SENTINEL,
"x".repeat(300)
);
for status in [400u16, 403, 404, 429, 500, 418] {
let msg = humanize_api_error(status, &body);
assert_no_request_content(&msg, &format!("non-json status {status}"));
}
}
#[test]
fn hostile_error_type_field_is_not_echoed_verbatim() {
let body = format!(
r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
HOSTILE_SENTINEL
);
for status in [400u16, 403, 404, 429, 500, 418] {
let msg = humanize_api_error(status, &body);
assert_no_request_content(&msg, &format!("type-field status {status}"));
}
}
#[test]
fn test_runtime_error_display() {
assert_eq!(
format!("{}", RuntimeError::Auth("bad token".into())),
"Auth error: bad token"
);
assert_eq!(
format!("{}", RuntimeError::Config("missing".into())),
"Config error: missing"
);
assert_eq!(
format!("{}", RuntimeError::Tool("failed".into())),
"Tool execution failed: failed"
);
assert_eq!(
format!("{}", RuntimeError::Session("not found".into())),
"Session error: not found"
);
assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
}
#[test]
fn test_runtime_error_to_string() {
assert_eq!(
RuntimeError::Auth("bad token".into()).to_string(),
"Auth error: bad token"
);
assert_eq!(
RuntimeError::Config("missing".into()).to_string(),
"Config error: missing"
);
assert_eq!(
RuntimeError::Tool("failed".into()).to_string(),
"Tool execution failed: failed"
);
assert_eq!(
RuntimeError::Session("not found".into()).to_string(),
"Session error: not found"
);
assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
}
}