use std::fmt;
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct Error {
kind: ErrorKind,
wire_kind: Option<WireKind>,
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
}
impl Error {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
wire_kind: None,
message: message.into(),
source: None,
}
}
pub fn invalid_arg(message: impl Into<String>) -> Self {
Self::new(ErrorKind::InvalidArg, message)
}
pub fn wire(wire_kind: WireKind, message: impl Into<String>) -> Self {
Self {
kind: wire_kind.error_kind(),
wire_kind: Some(wire_kind),
message: message.into(),
source: None,
}
}
#[must_use]
pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
self.source = Some(Box::new(source));
self
}
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn wire_kind(&self) -> Option<WireKind> {
self.wire_kind
}
pub fn code(&self) -> &'static str {
self.kind.code()
}
pub fn retryable(&self) -> bool {
self.kind.retryable()
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ErrorKind {
Unexpected,
InvalidArg,
Retryable,
Credentials,
Protocol,
BuildWedged,
LaunchDied,
WindowClosed,
Platform,
Timeout,
Interrupted,
Precondition,
ExecFailed,
}
impl ErrorKind {
pub const ALL: [ErrorKind; 13] = [
ErrorKind::Unexpected,
ErrorKind::InvalidArg,
ErrorKind::Retryable,
ErrorKind::Credentials,
ErrorKind::Protocol,
ErrorKind::BuildWedged,
ErrorKind::LaunchDied,
ErrorKind::WindowClosed,
ErrorKind::Platform,
ErrorKind::Timeout,
ErrorKind::Interrupted,
ErrorKind::Precondition,
ErrorKind::ExecFailed,
];
pub fn retryable(self) -> bool {
matches!(self, ErrorKind::Retryable)
}
pub fn code(self) -> &'static str {
match self {
ErrorKind::Unexpected => "ERR_UNEXPECTED",
ErrorKind::InvalidArg => "ERR_INVALID_ARG",
ErrorKind::Retryable => "ERR_RETRYABLE",
ErrorKind::Credentials => "ERR_CREDENTIALS",
ErrorKind::Protocol => "ERR_PROTOCOL",
ErrorKind::BuildWedged => "ERR_BUILD_WEDGED",
ErrorKind::LaunchDied => "ERR_LAUNCH_DIED",
ErrorKind::WindowClosed => "ERR_WINDOW_CLOSED",
ErrorKind::Platform => "ERR_PLATFORM",
ErrorKind::Timeout => "ERR_TIMEOUT",
ErrorKind::Interrupted => "ERR_INTERRUPTED",
ErrorKind::Precondition => "ERR_PRECONDITION",
ErrorKind::ExecFailed => "ERR_EXEC_FAILED",
}
}
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum WireKind {
Unauthorized,
ProtocolError,
NotFound,
Conflict,
StdinClosed,
TooLarge,
RequestTimeout,
NotBootstrapped,
ServerError,
InsufficientStorage,
Transport,
AuthTokenMint,
ExecTimeout,
OutputGap,
}
impl WireKind {
pub const ALL: [WireKind; 14] = [
WireKind::Unauthorized,
WireKind::ProtocolError,
WireKind::NotFound,
WireKind::Conflict,
WireKind::StdinClosed,
WireKind::TooLarge,
WireKind::RequestTimeout,
WireKind::NotBootstrapped,
WireKind::ServerError,
WireKind::InsufficientStorage,
WireKind::Transport,
WireKind::AuthTokenMint,
WireKind::ExecTimeout,
WireKind::OutputGap,
];
pub fn as_str(self) -> &'static str {
match self {
WireKind::Unauthorized => "Unauthorized",
WireKind::ProtocolError => "ProtocolError",
WireKind::NotFound => "NotFound",
WireKind::Conflict => "Conflict",
WireKind::StdinClosed => "StdinClosed",
WireKind::TooLarge => "TooLarge",
WireKind::RequestTimeout => "RequestTimeout",
WireKind::NotBootstrapped => "NotBootstrapped",
WireKind::ServerError => "ServerError",
WireKind::InsufficientStorage => "InsufficientStorage",
WireKind::Transport => "Transport",
WireKind::AuthTokenMint => "AuthTokenMint",
WireKind::ExecTimeout => "ExecTimeout",
WireKind::OutputGap => "OutputGap",
}
}
pub fn status(self) -> Option<u16> {
match self {
WireKind::ProtocolError => Some(400),
WireKind::Unauthorized => Some(401),
WireKind::NotFound => Some(404),
WireKind::RequestTimeout => Some(408),
WireKind::Conflict => Some(409),
WireKind::StdinClosed => Some(410),
WireKind::TooLarge => Some(413),
WireKind::ServerError => Some(500),
WireKind::NotBootstrapped => Some(503),
WireKind::InsufficientStorage => Some(507),
WireKind::Transport
| WireKind::AuthTokenMint
| WireKind::ExecTimeout
| WireKind::OutputGap => None,
}
}
pub fn from_status(status: u16) -> Option<WireKind> {
match status {
400 => Some(WireKind::ProtocolError),
401 => Some(WireKind::Unauthorized),
404 => Some(WireKind::NotFound),
408 => Some(WireKind::RequestTimeout),
409 => Some(WireKind::Conflict),
410 => Some(WireKind::StdinClosed),
413 => Some(WireKind::TooLarge),
503 => Some(WireKind::NotBootstrapped),
507 => Some(WireKind::InsufficientStorage),
s if s >= 500 => Some(WireKind::ServerError),
_ => None,
}
}
pub fn error_kind(self) -> ErrorKind {
match self {
WireKind::Unauthorized => ErrorKind::Credentials,
WireKind::Transport
| WireKind::AuthTokenMint
| WireKind::NotBootstrapped
| WireKind::RequestTimeout
| WireKind::ServerError => ErrorKind::Retryable,
WireKind::ProtocolError
| WireKind::NotFound
| WireKind::Conflict
| WireKind::StdinClosed
| WireKind::TooLarge => ErrorKind::Protocol,
WireKind::ExecTimeout => ErrorKind::Timeout,
WireKind::OutputGap => ErrorKind::Platform,
WireKind::InsufficientStorage => ErrorKind::Platform,
}
}
#[cfg(test)]
fn retryable(self) -> bool {
matches!(
self,
WireKind::Transport
| WireKind::AuthTokenMint
| WireKind::NotBootstrapped
| WireKind::RequestTimeout
| WireKind::ServerError
)
}
}
impl fmt::Display for WireKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kind_carries_its_python_err_code() {
let codes: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
assert_eq!(
codes,
[
"ERR_UNEXPECTED",
"ERR_INVALID_ARG",
"ERR_RETRYABLE",
"ERR_CREDENTIALS",
"ERR_PROTOCOL",
"ERR_BUILD_WEDGED",
"ERR_LAUNCH_DIED",
"ERR_WINDOW_CLOSED",
"ERR_PLATFORM",
"ERR_TIMEOUT",
"ERR_INTERRUPTED",
"ERR_PRECONDITION",
"ERR_EXEC_FAILED",
]
);
}
#[test]
fn no_two_kinds_share_a_code() {
let mut seen: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
seen.sort_unstable();
let before = seen.len();
seen.dedup();
assert_eq!(seen.len(), before, "duplicate ERR_* code");
}
#[test]
fn retryable_agrees_with_the_python_exception_contract() {
for wire in WireKind::ALL {
let err = Error::wire(wire, "detail");
assert_eq!(
err.retryable(),
wire.retryable(),
"{wire} disagrees with errors.py on retryability"
);
}
}
#[test]
fn exactly_five_wire_kinds_are_retryable() {
let retryable: Vec<&str> = WireKind::ALL
.iter()
.filter(|w| w.retryable())
.map(|w| w.as_str())
.collect();
assert_eq!(
retryable,
[
"RequestTimeout",
"NotBootstrapped",
"ServerError",
"Transport",
"AuthTokenMint"
]
);
}
#[test]
fn unauthorized_is_a_credential_failure_not_a_protocol_one() {
let err = Error::wire(WireKind::Unauthorized, "401");
assert_eq!(err.kind(), ErrorKind::Credentials);
assert_eq!(err.code(), "ERR_CREDENTIALS");
assert!(!err.retryable());
}
#[test]
fn no_generic_four_hundred_fallback_can_produce_a_protocol_error() {
assert_eq!(WireKind::from_status(400), Some(WireKind::ProtocolError));
assert_eq!(WireKind::from_status(404), Some(WireKind::NotFound));
for unmapped in [402, 403, 405, 418, 429, 451] {
assert_eq!(
WireKind::from_status(unmapped),
None,
"{unmapped} must not resolve to a daemon-chosen variant"
);
}
}
#[test]
fn five_hundreds_fall_back_to_server_error_except_the_bootstrap_one() {
assert_eq!(WireKind::from_status(500), Some(WireKind::ServerError));
assert_eq!(WireKind::from_status(502), Some(WireKind::ServerError));
assert_eq!(WireKind::from_status(506), Some(WireKind::ServerError));
assert_eq!(WireKind::from_status(508), Some(WireKind::ServerError));
assert_eq!(WireKind::from_status(599), Some(WireKind::ServerError));
assert_eq!(WireKind::from_status(503), Some(WireKind::NotBootstrapped));
}
#[test]
fn a_disk_pressure_507_is_insufficient_storage_and_not_retryable() {
let wire = WireKind::from_status(507);
assert_eq!(
wire,
Some(WireKind::InsufficientStorage),
"507 resolves to {wire:?}"
);
let err = Error::wire(
wire.expect("507 is mapped"),
"PUT /v1/fs/file?path=%2Fw -> 507: 4096 bytes available, below the reserve",
);
assert_eq!(err.kind(), ErrorKind::Platform);
assert_eq!(err.code(), "ERR_PLATFORM");
assert!(!err.retryable(), "a full disk is not retryable unchanged");
assert_eq!(WireKind::InsufficientStorage.status(), Some(507));
assert_eq!(
WireKind::InsufficientStorage.as_str(),
"InsufficientStorage"
);
}
#[test]
fn an_errors_retryability_is_its_kinds() {
for kind in ErrorKind::ALL {
assert_eq!(
Error::new(kind, "detail").retryable(),
kind.retryable(),
"{kind}"
);
}
let retryable: Vec<ErrorKind> = ErrorKind::ALL
.into_iter()
.filter(|kind| kind.retryable())
.collect();
assert_eq!(retryable, [ErrorKind::Retryable]);
}
#[test]
fn status_and_from_status_are_inverses_where_both_are_defined() {
for wire in WireKind::ALL {
if let Some(status) = wire.status() {
assert_eq!(
WireKind::from_status(status),
Some(wire),
"{wire} reports {status} but {status} resolves elsewhere"
);
}
}
}
#[test]
fn a_local_reject_carries_no_wire_kind() {
let err = Error::invalid_arg("refused before any AWS call");
assert_eq!(err.kind(), ErrorKind::InvalidArg);
assert_eq!(err.wire_kind(), None);
assert_eq!(err.to_string(), "refused before any AWS call");
}
#[test]
fn an_attached_source_survives_into_the_error_trait() {
let io = std::io::Error::other("connection reset");
let err = Error::new(ErrorKind::Retryable, "endpoint request failed").with_source(io);
let source = std::error::Error::source(&err).expect("source is reachable");
assert_eq!(source.to_string(), "connection reset");
}
}