1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//! [`LlmErrorKind`] — a low-cardinality classification of an LLM call
//! failure.
//!
//! A single dimension for error metrics across every LLM-backed adapter:
//! the LLM judge and every HTTP provider agent (vLLM / OpenAI / Anthropic).
//! The variants name
//! *what went wrong* at a granularity that demands different operator
//! responses — a `RateLimited` is backpressure, an `Unauthorized` is a
//! credential rotation, a `Timeout` is saturation — so the same eight
//! buckets serve both alerting and triage.
/// How an LLM call failed, as a stable metric label.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LlmErrorKind {
/// 401/403 — credentials missing, wrong, or revoked.
Unauthorized,
/// 429 — provider-side rate limiting / backpressure.
RateLimited,
/// Other 4xx — a malformed or rejected request.
BadRequest,
/// 5xx or an otherwise unclassified upstream failure.
UpstreamError,
/// The response body could not be parsed into the expected shape.
MalformedBody,
/// The response parsed but carried no usable content.
EmptyContent,
/// The call exceeded its deadline.
Timeout,
/// A connection-level failure before any HTTP status was seen.
Transport,
}
impl LlmErrorKind {
/// Classify an HTTP status code, mirroring the adapters' error
/// mapping. Takes a raw `u16` so the core stays free of any HTTP
/// client dependency.
#[must_use]
pub const fn from_status(status: u16) -> Self {
match status {
401 | 403 => Self::Unauthorized,
429 => Self::RateLimited,
400..=499 => Self::BadRequest,
_ => Self::UpstreamError,
}
}
/// Stable, low-cardinality label value for metrics exposition. These
/// strings are part of the metric contract — dashboards and alerts
/// match on them, so they must not change.
#[must_use]
pub const fn as_label(self) -> &'static str {
match self {
Self::Unauthorized => "unauthorized",
Self::RateLimited => "rate_limited",
Self::BadRequest => "bad_request",
Self::UpstreamError => "upstream_error",
Self::MalformedBody => "malformed_body",
Self::EmptyContent => "empty_content",
Self::Timeout => "timeout",
Self::Transport => "transport",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_classification_matches_the_adapter_mapping() {
assert_eq!(LlmErrorKind::from_status(401), LlmErrorKind::Unauthorized);
assert_eq!(LlmErrorKind::from_status(403), LlmErrorKind::Unauthorized);
assert_eq!(LlmErrorKind::from_status(429), LlmErrorKind::RateLimited);
assert_eq!(LlmErrorKind::from_status(404), LlmErrorKind::BadRequest);
assert_eq!(LlmErrorKind::from_status(400), LlmErrorKind::BadRequest);
assert_eq!(LlmErrorKind::from_status(500), LlmErrorKind::UpstreamError);
assert_eq!(LlmErrorKind::from_status(503), LlmErrorKind::UpstreamError);
}
#[test]
fn labels_are_distinct_and_stable() {
let all = [
LlmErrorKind::Unauthorized,
LlmErrorKind::RateLimited,
LlmErrorKind::BadRequest,
LlmErrorKind::UpstreamError,
LlmErrorKind::MalformedBody,
LlmErrorKind::EmptyContent,
LlmErrorKind::Timeout,
LlmErrorKind::Transport,
];
let labels: std::collections::BTreeSet<&str> =
all.iter().map(|kind| kind.as_label()).collect();
assert_eq!(labels.len(), all.len(), "labels must be unique");
}
}