use swink_agent::AssistantMessageEvent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HttpErrorKind {
Auth,
Throttled,
Network,
}
#[must_use]
pub const fn classify_http_status(code: u16) -> Option<HttpErrorKind> {
match code {
401 | 403 => Some(HttpErrorKind::Auth),
429 => Some(HttpErrorKind::Throttled),
500..=599 => Some(HttpErrorKind::Network),
_ => None,
}
}
#[must_use]
pub fn classify_with_overrides(
code: u16,
overrides: &[(u16, HttpErrorKind)],
) -> Option<HttpErrorKind> {
for (override_code, kind) in overrides {
if code == *override_code {
return Some(kind.clone());
}
}
classify_http_status(code)
}
#[must_use]
pub fn error_event_from_status(status: u16, body: &str, provider: &str) -> AssistantMessageEvent {
error_event_from_status_with_overrides(status, body, provider, &[])
}
#[must_use]
pub fn error_event_from_status_with_overrides(
status: u16,
body: &str,
provider: &str,
overrides: &[(u16, HttpErrorKind)],
) -> AssistantMessageEvent {
let kind = classify_with_overrides(status, overrides);
match kind {
Some(HttpErrorKind::Auth) => AssistantMessageEvent::error_auth(format!(
"{provider} auth error (HTTP {status}): {body}"
)),
Some(HttpErrorKind::Throttled) => AssistantMessageEvent::error_throttled(format!(
"{provider} rate limit (HTTP {status}): {body}"
)),
Some(HttpErrorKind::Network) => AssistantMessageEvent::error_network(format!(
"{provider} server error (HTTP {status}): {body}"
)),
None => {
if (400..500).contains(&status) {
AssistantMessageEvent::error(format!(
"{provider} client error (HTTP {status}): {body}"
))
} else {
AssistantMessageEvent::error(format!("{provider} HTTP {status}: {body}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_401_is_auth() {
assert_eq!(classify_http_status(401), Some(HttpErrorKind::Auth));
}
#[test]
fn classify_403_is_auth() {
assert_eq!(classify_http_status(403), Some(HttpErrorKind::Auth));
}
#[test]
fn classify_429_is_throttled() {
assert_eq!(classify_http_status(429), Some(HttpErrorKind::Throttled));
}
#[test]
fn classify_500_is_network() {
assert_eq!(classify_http_status(500), Some(HttpErrorKind::Network));
}
#[test]
fn classify_200_is_none() {
assert_eq!(classify_http_status(200), None);
}
#[test]
fn classify_with_overrides_applies_first() {
let overrides = vec![(429, HttpErrorKind::Auth)];
assert_eq!(
classify_with_overrides(429, &overrides),
Some(HttpErrorKind::Auth),
);
assert_eq!(
classify_with_overrides(500, &overrides),
Some(HttpErrorKind::Network),
);
}
#[test]
fn error_event_401_is_auth() {
let event = error_event_from_status(401, "bad key", "TestProvider");
match event {
AssistantMessageEvent::Error {
error_message,
error_kind,
..
} => {
assert!(error_message.contains("TestProvider"));
assert!(error_message.contains("401"));
assert!(error_message.contains("bad key"));
assert_eq!(error_kind, Some(swink_agent::StreamErrorKind::Auth));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn error_event_429_is_throttled() {
let event = error_event_from_status(429, "slow down", "TestProvider");
match event {
AssistantMessageEvent::Error {
error_kind,
error_message,
..
} => {
assert!(error_message.contains("429"));
assert_eq!(error_kind, Some(swink_agent::StreamErrorKind::Throttled));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn error_event_500_is_network() {
let event = error_event_from_status(500, "internal", "TestProvider");
match event {
AssistantMessageEvent::Error {
error_kind,
error_message,
..
} => {
assert!(error_message.contains("500"));
assert_eq!(error_kind, Some(swink_agent::StreamErrorKind::Network));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn error_event_400_is_generic_client_error() {
let event = error_event_from_status(400, "bad request", "TestProvider");
match event {
AssistantMessageEvent::Error {
error_kind,
error_message,
..
} => {
assert!(error_message.contains("client error"));
assert!(error_message.contains("400"));
assert_eq!(error_kind, None);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn error_event_with_override_529_network() {
let event = error_event_from_status_with_overrides(
529,
"overloaded",
"Anthropic",
&[(529, HttpErrorKind::Network)],
);
match event {
AssistantMessageEvent::Error {
error_kind,
error_message,
..
} => {
assert!(error_message.contains("Anthropic"));
assert!(error_message.contains("529"));
assert_eq!(error_kind, Some(swink_agent::StreamErrorKind::Network));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn cross_adapter_unexpected_eof_is_network() {
let providers = ["Anthropic", "OpenAI", "Google", "Ollama", "Bedrock"];
for provider in providers {
let event = AssistantMessageEvent::error_network(format!(
"{provider} stream ended unexpectedly"
));
match event {
AssistantMessageEvent::Error { error_kind, .. } => {
assert_eq!(
error_kind,
Some(swink_agent::StreamErrorKind::Network),
"{provider} unexpected EOF should have Network kind"
);
}
other => panic!("expected Error for {provider}, got {other:?}"),
}
}
}
#[test]
fn cross_adapter_content_filter_is_classified() {
let event =
AssistantMessageEvent::error_content_filtered("response blocked by safety filter");
match event {
AssistantMessageEvent::Error { error_kind, .. } => {
assert_eq!(
error_kind,
Some(swink_agent::StreamErrorKind::ContentFiltered),
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn http_error_classification_covers_all_adapter_status_codes() {
for code in [401, 403] {
let event = error_event_from_status(code, "forbidden", "TestAdapter");
match event {
AssistantMessageEvent::Error { error_kind, .. } => {
assert_eq!(
error_kind,
Some(swink_agent::StreamErrorKind::Auth),
"HTTP {code} should be Auth"
);
}
other => panic!("expected Error for HTTP {code}, got {other:?}"),
}
}
let event = error_event_from_status(429, "too many requests", "TestAdapter");
match event {
AssistantMessageEvent::Error { error_kind, .. } => {
assert_eq!(error_kind, Some(swink_agent::StreamErrorKind::Throttled));
}
other => panic!("expected Error, got {other:?}"),
}
for code in [500, 502, 503, 529] {
let event = error_event_from_status_with_overrides(
code,
"server error",
"TestAdapter",
&[(529, HttpErrorKind::Network)],
);
match event {
AssistantMessageEvent::Error { error_kind, .. } => {
assert_eq!(
error_kind,
Some(swink_agent::StreamErrorKind::Network),
"HTTP {code} should be Network"
);
}
other => panic!("expected Error for HTTP {code}, got {other:?}"),
}
}
}
}