#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, missing_docs)]
use std::time::Duration;
use honcho_ai::error::{HonchoError, from_response};
fn bad_request_error() -> HonchoError {
HonchoError::BadRequest {
message: "bad input".into(),
body: None,
}
}
fn not_found_error() -> HonchoError {
HonchoError::NotFound {
message: "not found".into(),
}
}
fn partial_failure_server_error() -> HonchoError {
HonchoError::PartialFailure {
messages: vec![],
sent: 0,
error: Box::new(HonchoError::Server {
status: 500,
message: "server boom".into(),
}),
}
}
fn partial_failure_timeout_error() -> HonchoError {
HonchoError::PartialFailure {
messages: vec![],
sent: 0,
error: Box::new(HonchoError::Timeout {
message: "inner timeout".into(),
}),
}
}
fn partial_failure_server_503_error() -> HonchoError {
HonchoError::PartialFailure {
messages: vec![],
sent: 0,
error: Box::new(HonchoError::Server {
status: 503,
message: "unavailable".into(),
}),
}
}
fn partial_failure_rate_limit_error() -> HonchoError {
HonchoError::PartialFailure {
messages: vec![],
sent: 0,
error: Box::new(rate_limit_with_retry_after()),
}
}
fn rate_limit_with_retry_after() -> HonchoError {
HonchoError::RateLimit {
message: "slow down".into(),
retry_after: Some(Duration::from_secs(30)),
}
}
fn rate_limit_without_retry_after() -> HonchoError {
HonchoError::RateLimit {
message: "slow down".into(),
retry_after: None,
}
}
fn io_error() -> HonchoError {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
HonchoError::Io(io)
}
fn decode_error() -> HonchoError {
let source = serde_json::from_str::<serde_json::Value>("not json")
.expect_err("malformed JSON must fail");
HonchoError::Decode {
path: "$.data".into(),
source,
}
}
fn transport_error() -> HonchoError {
let err = reqwest::Client::new()
.get("http://[")
.build()
.expect_err("malformed URL must fail to build");
HonchoError::Transport(err)
}
#[test]
fn message_table() {
let cases: Vec<(&str, HonchoError, &str)> = vec![
("BadRequest", bad_request_error(), "bad input"),
(
"Authentication",
HonchoError::Authentication {
message: "no key".into(),
},
"no key",
),
(
"PermissionDenied",
HonchoError::PermissionDenied {
message: "forbidden".into(),
},
"forbidden",
),
("NotFound", not_found_error(), "not found"),
(
"Conflict",
HonchoError::Conflict {
message: "dup".into(),
body: None,
},
"dup",
),
(
"UnprocessableEntity",
HonchoError::UnprocessableEntity {
message: "u".into(),
body: None,
},
"u",
),
("RateLimit", rate_limit_without_retry_after(), "slow down"),
(
"Client",
HonchoError::Client {
status: 418,
message: "teapot".into(),
},
"teapot",
),
(
"Server",
HonchoError::Server {
status: 503,
message: "down".into(),
},
"down",
),
(
"Timeout",
HonchoError::Timeout {
message: "timed out".into(),
},
"timed out",
),
(
"Connection",
HonchoError::Connection {
message: "refused".into(),
},
"refused",
),
(
"Configuration",
HonchoError::Configuration("bad config".into()),
"bad config",
),
(
"Validation",
HonchoError::Validation("bad val".into()),
"bad val",
),
("Transport", transport_error(), "transport error"),
("Io", io_error(), "I/O error"),
("Decode", decode_error(), "failed to decode response"),
(
"PartialFailure(Server)",
partial_failure_server_error(),
"server boom",
),
];
for (label, err, expected) in cases {
assert_eq!(err.message(), expected, "message() mismatch for {label}");
}
}
#[test]
fn message_partial_failure_delegates_through_chain() {
let inner = partial_failure_server_error();
let outer = HonchoError::PartialFailure {
messages: vec![],
sent: 0,
error: Box::new(inner),
};
assert_eq!(outer.message(), "server boom");
}
#[test]
fn status_code_table() {
let cases: Vec<(&str, HonchoError, Option<u16>)> = vec![
("BadRequest", bad_request_error(), Some(400)),
(
"Authentication",
HonchoError::Authentication {
message: "a".into(),
},
Some(401),
),
(
"PermissionDenied",
HonchoError::PermissionDenied {
message: "p".into(),
},
Some(403),
),
("NotFound", not_found_error(), Some(404)),
(
"Conflict",
HonchoError::Conflict {
message: "c".into(),
body: None,
},
Some(409),
),
(
"UnprocessableEntity",
HonchoError::UnprocessableEntity {
message: "u".into(),
body: None,
},
Some(422),
),
("RateLimit", rate_limit_without_retry_after(), Some(429)),
(
"Client{418}",
HonchoError::Client {
status: 418,
message: "t".into(),
},
Some(418),
),
(
"Server{503}",
HonchoError::Server {
status: 503,
message: "s".into(),
},
Some(503),
),
(
"Timeout",
HonchoError::Timeout {
message: "t".into(),
},
None,
),
(
"Connection",
HonchoError::Connection {
message: "c".into(),
},
None,
),
("Transport", transport_error(), None),
("Decode", decode_error(), None),
("Io", io_error(), None),
(
"Configuration",
HonchoError::Configuration("c".into()),
None,
),
("Validation", HonchoError::Validation("v".into()), None),
(
"PartialFailure(Server500)",
partial_failure_server_error(),
Some(500),
),
(
"PartialFailure(Timeout)",
partial_failure_timeout_error(),
None,
),
(
"PartialFailure(Server503)",
partial_failure_server_503_error(),
Some(503),
),
];
for (label, err, expected) in cases {
assert_eq!(
err.status_code(),
expected,
"status_code() mismatch for {label}"
);
}
}
#[test]
fn code_constructed_directly_variants() {
let cases: Vec<(&str, HonchoError, &str)> = vec![
(
"Timeout",
HonchoError::Timeout {
message: "t".into(),
},
"timeout",
),
(
"Connection",
HonchoError::Connection {
message: "c".into(),
},
"connection_error",
),
("Transport", transport_error(), "transport_error"),
("Decode", decode_error(), "decode_error"),
("Io", io_error(), "io_error"),
(
"Configuration",
HonchoError::Configuration("c".into()),
"configuration_error",
),
(
"Validation",
HonchoError::Validation("v".into()),
"validation_error",
),
(
"PartialFailure",
partial_failure_server_error(),
"partial_failure",
),
];
for (label, err, expected) in cases {
assert_eq!(err.code(), expected, "code() mismatch for {label}");
}
}
#[allow(clippy::type_complexity)]
fn is_retryable_true_cases() -> Vec<(&'static str, HonchoError)> {
vec![
(
"Timeout",
HonchoError::Timeout {
message: "t".into(),
},
),
(
"Connection",
HonchoError::Connection {
message: "c".into(),
},
),
("RateLimit{429}", rate_limit_without_retry_after()),
(
"Server{500}",
HonchoError::Server {
status: 500,
message: "s".into(),
},
),
(
"Server{502}",
HonchoError::Server {
status: 502,
message: "s".into(),
},
),
(
"Server{503}",
HonchoError::Server {
status: 503,
message: "s".into(),
},
),
(
"Server{504}",
HonchoError::Server {
status: 504,
message: "s".into(),
},
),
]
}
#[allow(clippy::type_complexity)]
fn is_retryable_false_cases() -> Vec<(&'static str, HonchoError)> {
vec![
(
"Server{501}",
HonchoError::Server {
status: 501,
message: "s".into(),
},
),
("BadRequest{400}", bad_request_error()),
(
"Authentication{401}",
HonchoError::Authentication {
message: "a".into(),
},
),
(
"PermissionDenied{403}",
HonchoError::PermissionDenied {
message: "p".into(),
},
),
("NotFound{404}", not_found_error()),
(
"Conflict{409}",
HonchoError::Conflict {
message: "c".into(),
body: None,
},
),
(
"UnprocessableEntity{422}",
HonchoError::UnprocessableEntity {
message: "u".into(),
body: None,
},
),
(
"Client{405}",
HonchoError::Client {
status: 405,
message: "m".into(),
},
),
("Transport", transport_error()),
("Decode", decode_error()),
("Io", io_error()),
("Configuration", HonchoError::Configuration("c".into())),
("Validation", HonchoError::Validation("v".into())),
(
"PartialFailure(Server503)",
partial_failure_server_503_error(),
),
("PartialFailure(Timeout)", partial_failure_timeout_error()),
]
}
#[test]
fn is_retryable_full_matrix() {
for (label, err) in is_retryable_true_cases() {
assert!(err.is_retryable(), "expected {label} to be retryable");
}
for (label, err) in is_retryable_false_cases() {
assert!(!err.is_retryable(), "expected {label} to be non-retryable");
}
}
#[test]
fn retry_after_returns_duration_when_rate_limit() {
let err = rate_limit_with_retry_after();
assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
}
#[test]
fn retry_after_returns_none_when_no_header() {
let err = rate_limit_without_retry_after();
assert_eq!(err.retry_after(), None);
}
#[test]
fn retry_after_returns_none_for_non_rate_limit() {
assert_eq!(bad_request_error().retry_after(), None);
assert_eq!(io_error().retry_after(), None);
assert_eq!(transport_error().retry_after(), None);
assert_eq!(decode_error().retry_after(), None);
assert_eq!(
HonchoError::Timeout {
message: "t".into()
}
.retry_after(),
None
);
}
#[test]
fn retry_after_delegates_through_partial_failure() {
let err = partial_failure_rate_limit_error();
assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
}
#[test]
fn retry_after_none_when_inner_has_none() {
let err = partial_failure_server_error();
assert_eq!(err.retry_after(), None);
}
#[test]
fn is_partial_failure_true_for_partial_failure() {
assert!(partial_failure_server_error().is_partial_failure());
assert!(partial_failure_timeout_error().is_partial_failure());
}
#[test]
fn is_partial_failure_false_for_other_variants() {
assert!(!bad_request_error().is_partial_failure());
assert!(!io_error().is_partial_failure());
assert!(!rate_limit_without_retry_after().is_partial_failure());
assert!(!transport_error().is_partial_failure());
}
#[test]
fn into_partial_failure_extracts() {
let err = partial_failure_server_error();
let (messages, inner) = err
.into_partial_failure()
.expect("PartialFailure must extract");
assert!(messages.is_empty());
assert_eq!(inner.code(), "server_error");
assert_eq!(inner.message(), "server boom");
assert_eq!(inner.status_code(), Some(500));
}
#[test]
fn into_partial_failure_none_for_other_variants() {
assert!(bad_request_error().into_partial_failure().is_none());
assert!(io_error().into_partial_failure().is_none());
assert!(transport_error().into_partial_failure().is_none());
}
#[test]
fn io_from_std_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let honcho_err: HonchoError = io_err.into();
assert_eq!(honcho_err.code(), "io_error");
assert_eq!(honcho_err.message(), "I/O error");
assert!(honcho_err.status_code().is_none());
assert!(!honcho_err.is_retryable());
}
#[test]
fn from_response_3xx_maps_to_client_error() {
let err = from_response(
reqwest::StatusCode::from_u16(301).unwrap(),
&reqwest::header::HeaderMap::new(),
&bytes::Bytes::from("moved"),
chrono::Utc::now(),
);
assert_eq!(err.code(), "client_error");
assert_eq!(err.status_code(), Some(301));
assert_eq!(err.message(), "unexpected redirect status 301");
}
#[test]
fn from_response_429_maps_to_rate_limit_with_retry_after() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::RETRY_AFTER,
reqwest::header::HeaderValue::from_static("30"),
);
let err = from_response(
reqwest::StatusCode::from_u16(429).unwrap(),
&headers,
&bytes::Bytes::from("slow down"),
chrono::Utc::now(),
);
assert_eq!(err.code(), "rate_limit_exceeded");
assert_eq!(err.status_code(), Some(429));
assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
}
#[test]
fn from_response_5xx_maps_to_server() {
let err = from_response(
reqwest::StatusCode::from_u16(503).unwrap(),
&reqwest::header::HeaderMap::new(),
&bytes::Bytes::from("unavailable"),
chrono::Utc::now(),
);
assert_eq!(err.code(), "server_error");
assert_eq!(err.status_code(), Some(503));
assert!(err.is_retryable());
}
#[test]
fn from_response_unmapped_4xx_maps_to_client() {
let err = from_response(
reqwest::StatusCode::from_u16(405).unwrap(),
&reqwest::header::HeaderMap::new(),
&bytes::Bytes::from("method not allowed"),
chrono::Utc::now(),
);
assert_eq!(err.code(), "client_error");
assert_eq!(err.status_code(), Some(405));
assert!(!err.is_retryable());
}
#[test]
fn from_response_catch_all_maps_to_client() {
let err = from_response(
reqwest::StatusCode::from_u16(100).unwrap(),
&reqwest::header::HeaderMap::new(),
&bytes::Bytes::from("info"),
chrono::Utc::now(),
);
assert_eq!(err.code(), "client_error");
assert_eq!(err.status_code(), Some(100));
assert_eq!(err.message(), "unexpected response status 100");
}