use std::collections::HashMap;
use std::fmt;
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
Throttle,
Flake,
Unknown,
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorKind::Throttle => f.write_str("throttle"),
ErrorKind::Flake => f.write_str("flake"),
ErrorKind::Unknown => f.write_str("unknown"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderErrorSignatures {
#[serde(default)]
pub throttle: Vec<String>,
#[serde(default)]
pub flake: Vec<String>,
}
#[derive(Debug)]
pub struct CompileError {
pub provider: String,
pub pattern: String,
pub source: regex::Error,
}
impl fmt::Display for CompileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid error-signature regex for provider '{}': `{}`: {}",
self.provider, self.pattern, self.source
)
}
}
impl std::error::Error for CompileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[derive(Debug, Clone, Default)]
pub struct CompiledSignatures {
pub throttle: Vec<Regex>,
pub flake: Vec<Regex>,
}
impl CompiledSignatures {
pub fn compile(provider: &str, sigs: &ProviderErrorSignatures) -> Result<Self, CompileError> {
let throttle = compile_list(provider, &sigs.throttle)?;
let flake = compile_list(provider, &sigs.flake)?;
Ok(Self { throttle, flake })
}
pub fn is_empty(&self) -> bool {
self.throttle.is_empty() && self.flake.is_empty()
}
}
fn compile_list(provider: &str, patterns: &[String]) -> Result<Vec<Regex>, CompileError> {
patterns
.iter()
.map(|p| {
RegexBuilder::new(p)
.case_insensitive(true)
.build()
.map_err(|e| CompileError {
provider: provider.to_string(),
pattern: p.clone(),
source: e,
})
})
.collect()
}
pub type ProviderSignatureMap = HashMap<String, CompiledSignatures>;
pub fn build_signature_map(
configs: &[crate::provider_budget::ProviderBudgetConfig],
) -> Result<ProviderSignatureMap, CompileError> {
let mut map = HashMap::new();
for cfg in configs {
if let Some(sigs) = cfg.error_signatures.as_ref() {
let compiled = CompiledSignatures::compile(&cfg.id, sigs)?;
if !compiled.is_empty() {
map.insert(cfg.id.clone(), compiled);
}
}
}
Ok(map)
}
pub fn classify(stderr: &str, signatures: Option<&CompiledSignatures>) -> ErrorKind {
let Some(sigs) = signatures else {
return ErrorKind::Unknown;
};
if sigs.throttle.iter().any(|re| re.is_match(stderr)) {
return ErrorKind::Throttle;
}
if sigs.flake.iter().any(|re| re.is_match(stderr)) {
return ErrorKind::Flake;
}
ErrorKind::Unknown
}
pub fn classify_lines(lines: &[String], signatures: Option<&CompiledSignatures>) -> ErrorKind {
if lines.is_empty() {
return ErrorKind::Unknown;
}
let joined = lines.join("\n");
classify(&joined, signatures)
}
pub fn unknown_dedupe_key(provider: &str, stderr: &str) -> String {
let head: String = stderr
.trim()
.chars()
.take(20)
.collect::<String>()
.to_lowercase();
format!("{}::{}", provider, head)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider_budget::ProviderBudgetConfig;
fn sigs(throttle: &[&str], flake: &[&str]) -> ProviderErrorSignatures {
ProviderErrorSignatures {
throttle: throttle.iter().map(|s| s.to_string()).collect(),
flake: flake.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn unknown_when_no_signatures() {
assert_eq!(classify("anything", None), ErrorKind::Unknown);
}
#[test]
fn throttle_matches_case_insensitive() {
let compiled =
CompiledSignatures::compile("zai-coding-plan", &sigs(&["insufficient.?balance"], &[]))
.unwrap();
assert_eq!(
classify("ERROR: Insufficient Balance on account", Some(&compiled)),
ErrorKind::Throttle
);
}
#[test]
fn flake_matches_timeout() {
let compiled =
CompiledSignatures::compile("claude-code", &sigs(&[], &["timeout", "EOF"])).unwrap();
assert_eq!(
classify("stream timeout after 60s", Some(&compiled)),
ErrorKind::Flake
);
}
#[test]
fn throttle_beats_flake_when_both_match() {
let compiled = CompiledSignatures::compile(
"claude-code",
&sigs(&["rate.?limit"], &["rate.?limit.*timeout"]),
)
.unwrap();
assert_eq!(
classify("rate limit timeout", Some(&compiled)),
ErrorKind::Throttle
);
}
#[test]
fn unknown_when_no_pattern_matches() {
let compiled =
CompiledSignatures::compile("claude-code", &sigs(&["429"], &["timeout"])).unwrap();
assert_eq!(
classify("panic: internal assertion failed", Some(&compiled)),
ErrorKind::Unknown
);
}
#[test]
fn classify_lines_joins_and_matches() {
let compiled =
CompiledSignatures::compile("kimi-for-coding", &sigs(&["quota"], &["EOF"])).unwrap();
let lines = vec![
"starting run".to_string(),
"error: daily quota exceeded".to_string(),
];
assert_eq!(classify_lines(&lines, Some(&compiled)), ErrorKind::Throttle);
}
#[test]
fn classify_lines_empty_is_unknown() {
let compiled =
CompiledSignatures::compile("kimi-for-coding", &sigs(&["quota"], &[])).unwrap();
assert_eq!(classify_lines(&[], Some(&compiled)), ErrorKind::Unknown);
}
#[test]
fn compile_error_wraps_regex_error() {
let err = CompiledSignatures::compile("bad", &sigs(&["[unterminated"], &[])).unwrap_err();
assert_eq!(err.provider, "bad");
assert_eq!(err.pattern, "[unterminated");
}
#[test]
fn build_signature_map_skips_providers_without_signatures() {
let configs = vec![
ProviderBudgetConfig {
id: "opencode-go".to_string(),
error_signatures: Some(sigs(&["429"], &["timeout"])),
..Default::default()
},
ProviderBudgetConfig {
id: "no-sigs".to_string(),
error_signatures: None,
..Default::default()
},
ProviderBudgetConfig {
id: "empty-sigs".to_string(),
error_signatures: Some(ProviderErrorSignatures::default()),
..Default::default()
},
];
let map = build_signature_map(&configs).unwrap();
assert!(map.contains_key("opencode-go"));
assert!(!map.contains_key("no-sigs"));
assert!(!map.contains_key("empty-sigs"));
}
#[test]
fn dedupe_key_is_stable_and_lowercase() {
let k1 = unknown_dedupe_key("claude-code", " Unexpected JSON token\n");
let k2 = unknown_dedupe_key("claude-code", "UNEXPECTED JSON token with extra suffix");
assert_eq!(k1, k2);
assert!(k1.starts_with("claude-code::"));
}
#[test]
fn display_matches_expected_tokens() {
assert_eq!(ErrorKind::Throttle.to_string(), "throttle");
assert_eq!(ErrorKind::Flake.to_string(), "flake");
assert_eq!(ErrorKind::Unknown.to_string(), "unknown");
}
}