use harn_lexer::Span;
use super::VmValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArityExpect {
Exact(usize),
Range { min: usize, max: usize },
AtLeast(usize),
}
impl std::fmt::Display for ArityExpect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ArityExpect::Exact(n) => write!(f, "{n}"),
ArityExpect::Range { min, max } => write!(f, "{min}..={max}"),
ArityExpect::AtLeast(n) => write!(f, "at least {n}"),
}
}
}
#[derive(Debug, Clone)]
pub struct ArityMismatchError {
pub callee: String,
pub expected: ArityExpect,
pub got: usize,
pub span: Option<Span>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeadlockDiagnostic {
SelfDeadlock,
WaitForGraph,
}
impl DeadlockDiagnostic {
fn code(self) -> &'static str {
match self {
Self::SelfDeadlock => "HARN-ORC-011",
Self::WaitForGraph => "HARN-ORC-012",
}
}
}
#[derive(Debug, Clone)]
pub struct DeadlockError {
pub diagnostic: DeadlockDiagnostic,
pub kind: String,
pub key: String,
pub detail: String,
}
impl DeadlockError {
pub(crate) fn self_deadlock(
kind: impl Into<String>,
key: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
diagnostic: DeadlockDiagnostic::SelfDeadlock,
kind: kind.into(),
key: key.into(),
detail: detail.into(),
}
}
pub(crate) fn wait_for_graph(
kind: impl Into<String>,
key: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
diagnostic: DeadlockDiagnostic::WaitForGraph,
kind: kind.into(),
key: key.into(),
detail: detail.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ArgTypeMismatchError {
pub callee: String,
pub param: String,
pub expected: String,
pub got: &'static str,
pub span: Option<Span>,
}
#[derive(Debug, Clone)]
pub enum VmError {
StackUnderflow,
StackOverflow,
UndefinedVariable(String),
UndefinedBuiltin(String),
ImmutableAssignment(String),
TypeError(String),
Runtime(String),
DivisionByZero,
Thrown(VmValue),
CategorizedError {
message: String,
category: ErrorCategory,
},
DaemonQueueFull {
daemon_id: String,
capacity: usize,
},
Deadlock(Box<DeadlockError>),
Return(VmValue),
InvalidInstruction(u8),
ArityMismatch(Box<ArityMismatchError>),
ArgTypeMismatch(Box<ArgTypeMismatchError>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ErrorCategory {
Timeout,
Auth,
RateLimit,
Overloaded,
ServerError,
TransientNetwork,
SchemaValidation,
SchemaStreamAborted,
ToolError,
ToolRejected,
EgressBlocked,
Cancelled,
NotFound,
CircuitOpen,
BudgetExceeded,
Generic,
}
impl ErrorCategory {
pub fn as_str(&self) -> &'static str {
match self {
ErrorCategory::Timeout => "timeout",
ErrorCategory::Auth => "auth",
ErrorCategory::RateLimit => "rate_limit",
ErrorCategory::Overloaded => "overloaded",
ErrorCategory::ServerError => "server_error",
ErrorCategory::TransientNetwork => "transient_network",
ErrorCategory::SchemaValidation => "schema_validation",
ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
ErrorCategory::ToolError => "tool_error",
ErrorCategory::ToolRejected => "tool_rejected",
ErrorCategory::EgressBlocked => "egress_blocked",
ErrorCategory::Cancelled => "cancelled",
ErrorCategory::NotFound => "not_found",
ErrorCategory::CircuitOpen => "circuit_open",
ErrorCategory::BudgetExceeded => "budget_exceeded",
ErrorCategory::Generic => "generic",
}
}
pub fn parse(s: &str) -> Self {
match s {
"timeout" => ErrorCategory::Timeout,
"auth" => ErrorCategory::Auth,
"rate_limit" => ErrorCategory::RateLimit,
"overloaded" => ErrorCategory::Overloaded,
"server_error" => ErrorCategory::ServerError,
"transient_network" => ErrorCategory::TransientNetwork,
"schema_validation" => ErrorCategory::SchemaValidation,
"schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
"tool_error" => ErrorCategory::ToolError,
"tool_rejected" => ErrorCategory::ToolRejected,
"egress_blocked" => ErrorCategory::EgressBlocked,
"cancelled" => ErrorCategory::Cancelled,
"not_found" => ErrorCategory::NotFound,
"circuit_open" => ErrorCategory::CircuitOpen,
"budget_exceeded" => ErrorCategory::BudgetExceeded,
_ => ErrorCategory::Generic,
}
}
pub fn is_transient(&self) -> bool {
matches!(
self,
ErrorCategory::Timeout
| ErrorCategory::RateLimit
| ErrorCategory::Overloaded
| ErrorCategory::ServerError
| ErrorCategory::TransientNetwork
)
}
}
pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
VmError::CategorizedError {
message: message.into(),
category,
}
}
pub fn error_to_category(err: &VmError) -> ErrorCategory {
match err {
VmError::CategorizedError { category, .. } => category.clone(),
VmError::Thrown(VmValue::Dict(d)) => d
.get("category")
.map(|v| ErrorCategory::parse(&v.display()))
.unwrap_or(ErrorCategory::Generic),
VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
VmError::Runtime(msg) => classify_error_message(msg),
VmError::Deadlock(_) => ErrorCategory::Generic,
_ => ErrorCategory::Generic,
}
}
pub fn classify_error_message(msg: &str) -> ErrorCategory {
if let Some(cat) = classify_by_http_status(msg) {
return cat;
}
let lower = msg.to_lowercase();
if lower.contains("cancelled") || lower.contains("canceled") {
return ErrorCategory::Cancelled;
}
if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
return ErrorCategory::Timeout;
}
if msg.contains("overloaded_error") {
return ErrorCategory::Overloaded;
}
if msg.contains("api_error") {
return ErrorCategory::ServerError;
}
if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
return ErrorCategory::RateLimit;
}
if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
return ErrorCategory::Auth;
}
if msg.contains("not_found_error") || msg.contains("model_not_found") {
return ErrorCategory::NotFound;
}
if msg.contains("circuit_open") {
return ErrorCategory::CircuitOpen;
}
if lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("connection closed")
|| lower.contains("broken pipe")
|| lower.contains("dns error")
|| lower.contains("stream error")
|| lower.contains("unexpected eof")
{
return ErrorCategory::TransientNetwork;
}
ErrorCategory::Generic
}
fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
for code in extract_http_status_codes(msg) {
return Some(match code {
401 | 403 => ErrorCategory::Auth,
404 | 410 => ErrorCategory::NotFound,
408 | 504 | 522 | 524 => ErrorCategory::Timeout,
429 => ErrorCategory::RateLimit,
503 | 529 => ErrorCategory::Overloaded,
500 | 502 => ErrorCategory::ServerError,
_ => continue,
});
}
None
}
fn extract_http_status_codes(msg: &str) -> Vec<u16> {
let mut codes = Vec::new();
let bytes = msg.as_bytes();
for i in 0..bytes.len().saturating_sub(2) {
if bytes[i].is_ascii_digit()
&& bytes[i + 1].is_ascii_digit()
&& bytes[i + 2].is_ascii_digit()
{
let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
if before_ok && after_ok {
if let Ok(code) = msg[i..i + 3].parse::<u16>() {
if (400..=599).contains(&code) {
codes.push(code);
}
}
}
}
}
codes
}
impl std::fmt::Display for VmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VmError::StackUnderflow => write!(f, "Stack underflow"),
VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
VmError::ImmutableAssignment(n) => {
write!(f, "Cannot assign to immutable binding: {n}")
}
VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
VmError::DivisionByZero => write!(f, "Division by zero"),
VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
VmError::CategorizedError { message, category } => {
write!(f, "Error [{}]: {}", category.as_str(), message)
}
VmError::DaemonQueueFull {
daemon_id,
capacity,
} => write!(
f,
"Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
),
VmError::Deadlock(err) => match err.diagnostic {
DeadlockDiagnostic::SelfDeadlock => write!(
f,
"{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
err.diagnostic.code(),
err.detail,
err.kind,
err.key
),
DeadlockDiagnostic::WaitForGraph => write!(
f,
"{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
err.diagnostic.code(),
err.detail,
err.kind,
err.key
),
},
VmError::Return(_) => write!(f, "Return from function"),
VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
VmError::ArityMismatch(err) => {
let arg_word = match err.expected {
ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
_ => "arguments",
};
write!(
f,
"Arity mismatch: '{}' expects {} {}, got {}{}",
err.callee,
err.expected,
arg_word,
err.got,
fmt_span_suffix(&err.span)
)
}
VmError::ArgTypeMismatch(err) => {
write!(
f,
"Type error: '{}' parameter `{}` expects {}, got {}{}",
err.callee,
err.param,
err.expected,
err.got,
fmt_span_suffix(&err.span)
)
}
}
}
}
fn fmt_span_suffix(span: &Option<Span>) -> String {
match span {
Some(s) => format!(" (at byte {}..{})", s.start, s.end),
None => String::new(),
}
}
impl std::error::Error for VmError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_cancelled_messages() {
assert_eq!(
classify_error_message("Bridge: operation cancelled"),
ErrorCategory::Cancelled
);
assert_eq!(
classify_error_message("operation canceled by host"),
ErrorCategory::Cancelled
);
}
#[test]
fn deadlock_renders_with_stable_code() {
let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
"mutex",
"__default__",
"re-entrant acquire",
)));
assert!(
err.to_string().starts_with("HARN-ORC-011"),
"deadlock Display must carry the stable code: {err}"
);
}
#[test]
fn deadlock_maps_to_generic_category() {
let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
"task",
"task_1",
"self-join",
)));
let category = error_to_category(&err);
assert_eq!(category, ErrorCategory::Generic);
assert!(
!category.is_transient(),
"a deadlock must not be treated as a retryable transient error"
);
}
}