use std::time::{SystemTime, UNIX_EPOCH};
use super::graph::TaskId;
#[must_use]
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
.try_into()
.unwrap_or(u64::MAX)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineageKind {
Failed { error_class: String },
}
#[derive(Debug, Clone)]
pub struct LineageEntry {
pub task_id: TaskId,
pub kind: LineageKind,
pub ts_ms: u64,
}
#[must_use]
pub fn classify_error(error: &str) -> String {
let lower = error.to_lowercase();
if lower.contains("timeout") || lower.contains("timed out") {
"timeout".to_string()
} else if lower.contains("429") || lower.contains("rate limit") || lower.contains("rate_limit")
{
"rate_limit".to_string()
} else if lower.contains("canceled") || lower.contains("cancelled") {
"canceled".to_string()
} else if lower.contains("llm") || lower.contains("provider") || lower.contains("inference") {
"llm_error".to_string()
} else {
"unknown".to_string()
}
}
#[derive(Debug, Clone, Default)]
pub struct ErrorLineage {
entries: Vec<LineageEntry>,
}
impl ErrorLineage {
pub fn push(&mut self, entry: LineageEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn is_recent(&self, ttl_secs: u64) -> bool {
match self.entries.first() {
None => true,
Some(entry) => now_ms().saturating_sub(entry.ts_ms) <= ttl_secs * 1000,
}
}
pub fn merge(&mut self, other: &ErrorLineage, ttl_secs: u64) {
if other.is_recent(ttl_secs) {
for entry in &other.entries {
self.entries.push(entry.clone());
}
}
}
#[must_use]
pub fn consecutive_failed_len(&self) -> usize {
let mut count = 0;
for entry in self.entries.iter().rev() {
match &entry.kind {
LineageKind::Failed { .. } => count += 1,
}
}
count
}
#[must_use]
pub fn first_entry(&self) -> Option<&LineageEntry> {
self.entries.first()
}
#[must_use]
pub fn entries(&self) -> &[LineageEntry] {
&self.entries
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(task_id: u32, ts_ms: u64) -> LineageEntry {
LineageEntry {
task_id: TaskId(task_id),
kind: LineageKind::Failed {
error_class: "timeout".to_string(),
},
ts_ms,
}
}
#[test]
fn empty_chain_is_recent() {
let chain = ErrorLineage::default();
assert!(chain.is_recent(300));
}
#[test]
fn recent_entry_within_ttl() {
let mut chain = ErrorLineage::default();
chain.push(entry(0, now_ms()));
assert!(chain.is_recent(300));
}
#[test]
fn old_entry_outside_ttl() {
let mut chain = ErrorLineage::default();
chain.push(entry(0, now_ms().saturating_sub(600_001)));
assert!(!chain.is_recent(300));
}
#[test]
fn consecutive_failed_len_counts_all() {
let mut chain = ErrorLineage::default();
chain.push(entry(0, 1000));
chain.push(entry(1, 2000));
chain.push(entry(2, 3000));
assert_eq!(chain.consecutive_failed_len(), 3);
}
#[test]
fn merge_appends_recent_entries() {
let mut parent = ErrorLineage::default();
parent.push(entry(0, now_ms()));
let mut child = ErrorLineage::default();
child.merge(&parent, 300);
child.push(entry(1, now_ms()));
assert_eq!(child.entries().len(), 2);
assert_eq!(child.consecutive_failed_len(), 2);
}
#[test]
fn merge_skips_stale_parent() {
let mut parent = ErrorLineage::default();
parent.push(entry(0, now_ms().saturating_sub(700_000)));
let mut child = ErrorLineage::default();
child.merge(&parent, 300);
child.push(entry(1, now_ms()));
assert_eq!(child.entries().len(), 1);
}
#[test]
fn classify_error_timeout() {
assert_eq!(classify_error("task timed out after 30s"), "timeout");
assert_eq!(classify_error("Timeout exceeded"), "timeout");
}
#[test]
fn classify_error_rate_limit() {
assert_eq!(
classify_error("LLM returned 429 Too Many Requests"),
"rate_limit"
);
}
#[test]
fn classify_error_unknown() {
assert_eq!(classify_error("something weird happened"), "unknown");
}
}