use reqwest::StatusCode;
use serde::Serialize;
use turnkey_client::TurnkeyClientError;
const MAX_ERROR_MESSAGE_CHARS: usize = 8 * 1024;
#[derive(Debug, thiserror::Error)]
#[error("{resource} not found: {id}")]
pub struct MissingResource {
resource: &'static str,
id: String,
}
impl MissingResource {
pub fn new(resource: &'static str, id: impl Into<String>) -> Self {
Self {
resource,
id: id.into(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
MissingRequiredInput,
UsageError,
InvalidInput,
Unauthorized,
NotFound,
ApiError,
ApprovalRequired,
NetworkError,
CommandError,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Classification {
pub code: ErrorCode,
pub http_status: Option<u16>,
}
impl Classification {
fn new(code: ErrorCode, http_status: Option<u16>) -> Self {
Self { code, http_status }
}
}
pub fn classify(error: &anyhow::Error) -> Classification {
for cause in error.chain() {
if cause.downcast_ref::<MissingResource>().is_some() {
return Classification::new(ErrorCode::NotFound, None);
}
if let Some(client_error) = cause.downcast_ref::<TurnkeyClientError>() {
return classify_turnkey_client_error(client_error);
}
}
Classification::new(ErrorCode::CommandError, None)
}
pub fn render_error_chain(error: &anyhow::Error) -> String {
let mut rendered = String::new();
for cause in error.chain() {
let text = cause.to_string();
if !rendered.is_empty() {
rendered.push_str(": ");
}
rendered.push_str(&text);
}
truncate_message(rendered)
}
fn truncate_message(message: String) -> String {
if message.len() <= MAX_ERROR_MESSAGE_CHARS {
return message;
}
let total = message.len();
let mut cut = MAX_ERROR_MESSAGE_CHARS;
while !message.is_char_boundary(cut) {
cut -= 1;
}
format!(
"{}… [error message truncated; {total} bytes total]",
&message[..cut]
)
}
fn classify_turnkey_client_error(error: &TurnkeyClientError) -> Classification {
match error {
TurnkeyClientError::UnexpectedHttpStatus(status, _) => {
let code = match StatusCode::from_u16(*status) {
Ok(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => ErrorCode::Unauthorized,
Ok(StatusCode::NOT_FOUND) => ErrorCode::NotFound,
_ => ErrorCode::ApiError,
};
Classification::new(code, Some(*status))
}
TurnkeyClientError::Http(reqwest_error)
if reqwest_error.is_connect()
|| reqwest_error.is_timeout()
|| reqwest_error.is_request() =>
{
Classification::new(ErrorCode::NetworkError, None)
}
TurnkeyClientError::ActivityRequiresApproval(_) => {
Classification::new(ErrorCode::ApprovalRequired, None)
}
TurnkeyClientError::MissingContentTypeHeader
| TurnkeyClientError::HeaderToStrError(_)
| TurnkeyClientError::HeaderFromStrError(_)
| TurnkeyClientError::UnexpectedMimeType(_)
| TurnkeyClientError::Decode(_, _)
| TurnkeyClientError::ActivityFailed(_)
| TurnkeyClientError::UnexpectedActivityStatus(_)
| TurnkeyClientError::UnexpectedInnerActivityResult(_)
| TurnkeyClientError::MissingActivity
| TurnkeyClientError::MissingResult
| TurnkeyClientError::MissingInnerResult
| TurnkeyClientError::ExceededRetries(_) => Classification::new(ErrorCode::ApiError, None),
TurnkeyClientError::BuilderMissingApiKey
| TurnkeyClientError::ReqwestBuilder(_)
| TurnkeyClientError::Http(_)
| TurnkeyClientError::SerdeJsonFailure(_)
| TurnkeyClientError::StamperError(_) => Classification::new(ErrorCode::CommandError, None),
}
}
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
}
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
#[derive(Debug, thiserror::Error)]
#[error("connection refused by peer")]
struct Leaf;
#[derive(Debug, thiserror::Error)]
#[error("HTTP request failed: {0}")]
struct EmbedsSource(#[from] Leaf);
fn client_error(error: TurnkeyClientError) -> anyhow::Error {
anyhow::Error::new(error)
}
#[test]
fn unexpected_http_404_is_not_found_with_status() {
let error = client_error(TurnkeyClientError::UnexpectedHttpStatus(
404,
r#"{"message":"missing deployment"}"#.to_string(),
))
.context("failed to fetch deployment abc-123");
assert_eq!(
classify(&error),
Classification::new(ErrorCode::NotFound, Some(404))
);
}
#[test]
fn empty_response_not_found_maps_to_not_found_without_status() {
let error = anyhow::Error::new(MissingResource::new("deployment", "abc-123"))
.context("failed to fetch deployment abc-123");
assert_eq!(
classify(&error),
Classification::new(ErrorCode::NotFound, None)
);
}
#[test]
fn http_401_and_403_map_to_unauthorized() {
for status in [401u16, 403] {
let error = client_error(TurnkeyClientError::UnexpectedHttpStatus(
status,
"denied".to_string(),
));
assert_eq!(
classify(&error),
Classification::new(ErrorCode::Unauthorized, Some(status)),
"status {status}"
);
}
}
#[test]
fn other_http_status_maps_to_api_error() {
let error = client_error(TurnkeyClientError::UnexpectedHttpStatus(
500,
"boom".to_string(),
));
assert_eq!(
classify(&error),
Classification::new(ErrorCode::ApiError, Some(500))
);
}
#[test]
fn response_protocol_and_activity_failures_map_to_api_error() {
let decode_error = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let errors = [
TurnkeyClientError::MissingContentTypeHeader,
TurnkeyClientError::HeaderToStrError("invalid header".to_string()),
TurnkeyClientError::HeaderFromStrError("invalid MIME type".to_string()),
TurnkeyClientError::UnexpectedMimeType("text/plain".to_string()),
TurnkeyClientError::Decode("invalid JSON".to_string(), decode_error),
TurnkeyClientError::MissingActivity,
TurnkeyClientError::MissingResult,
TurnkeyClientError::MissingInnerResult,
TurnkeyClientError::UnexpectedActivityStatus("REJECTED".to_string()),
TurnkeyClientError::UnexpectedInnerActivityResult("unexpected result".to_string()),
TurnkeyClientError::ActivityFailed(None),
TurnkeyClientError::ExceededRetries(3),
];
for error in errors {
let error = client_error(error);
assert_eq!(
classify(&error),
Classification::new(ErrorCode::ApiError, None)
);
}
}
#[test]
fn activity_requires_approval_maps_to_approval_required() {
let error = client_error(TurnkeyClientError::ActivityRequiresApproval(
"act-1".to_string(),
));
assert_eq!(
classify(&error),
Classification::new(ErrorCode::ApprovalRequired, None)
);
}
#[test]
fn local_client_failures_map_to_command_error() {
let serialization_error = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let errors = [
TurnkeyClientError::BuilderMissingApiKey,
TurnkeyClientError::SerdeJsonFailure(serialization_error),
TurnkeyClientError::StamperError(turnkey_api_key_stamper::StamperError::HexDecode(
"invalid hex".to_string(),
)),
];
for error in errors {
let error = client_error(error);
assert_eq!(
classify(&error),
Classification::new(ErrorCode::CommandError, None)
);
}
}
#[test]
fn unrecognized_error_falls_back_to_command_error() {
let error = anyhow!("some other failure").context("while doing a thing");
assert_eq!(
classify(&error),
Classification::new(ErrorCode::CommandError, None)
);
}
#[test]
fn strip_ansi_removes_escape_sequences() {
let styled = "\x1b[1mUsage:\x1b[0m tvc \x1b[32m<COMMAND>\x1b[0m";
assert_eq!(strip_ansi(styled), "Usage: tvc <COMMAND>");
}
#[test]
fn render_error_chain_preserves_embedded_source_links() {
let error = anyhow::Error::new(EmbedsSource(Leaf)).context("failed to list TVC apps");
assert_eq!(
render_error_chain(&error),
"failed to list TVC apps: HTTP request failed: connection refused by peer: connection refused by peer"
);
}
#[test]
fn render_error_chain_matches_alternate_display_for_short_chain() {
let error = anyhow!("root cause").context("mid").context("top");
let alternate_display = format!("{:#}", error);
assert_eq!(render_error_chain(&error), alternate_display);
}
#[test]
fn truncated_message_is_capped_and_labeled() {
let error = anyhow!("failed: {}", "x".repeat(20_000));
let rendered = render_error_chain(&error);
assert!(rendered.len() <= MAX_ERROR_MESSAGE_CHARS + 64);
assert!(rendered.ends_with("… [error message truncated; 20008 bytes total]"));
}
#[test]
fn truncated_message_preserves_utf8_char_boundaries() {
let message = format!("a{}", "é".repeat(10_000));
let error = anyhow!("{message}");
let rendered = render_error_chain(&error);
let expected = format!(
"{}… [error message truncated; 20001 bytes total]",
&message[..MAX_ERROR_MESSAGE_CHARS - 1]
);
assert_eq!(rendered, expected);
}
}