use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderErrorKind {
Transport,
Timeout,
RateLimited,
Server,
Auth,
Request,
Malformed,
}
impl ProviderErrorKind {
pub fn is_retryable(self) -> bool {
match self {
Self::Transport
| Self::Timeout
| Self::RateLimited
| Self::Server
| Self::Malformed => true,
Self::Auth | Self::Request => false,
}
}
pub fn from_status(status: u16) -> Self {
match status {
429 => Self::RateLimited,
401 | 403 => Self::Auth,
500..=599 => Self::Server,
_ => Self::Request,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("filesystem error: {0}")]
Io(#[from] std::io::Error),
#[error("state store error: {0}")]
State(#[from] rusqlite::Error),
#[error("provider error ({kind:?}{}): {message}", .status.map(|s| format!(", HTTP {s}")).unwrap_or_default())]
Provider {
kind: ProviderErrorKind,
status: Option<u16>,
retry_after: Option<Duration>,
message: String,
},
#[error("configuration error: {0}")]
Config(String),
#[error("sandbox failed to start: {reason}")]
Sandbox {
reason: String,
},
#[error("refused by policy: {act} {target}{}", .rule.as_ref().map(|r| format!(" (rule {r} in layer {})", .layer.as_deref().unwrap_or("?"))).unwrap_or_default())]
Refused {
act: String,
target: String,
rule: Option<String>,
layer: Option<String>,
},
#[error("mcp server {server}: {reason}")]
Mcp {
server: String,
reason: String,
},
#[error("cannot resume: {reason}")]
Resume {
reason: String,
},
}
impl Error {
pub fn provider(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
Self::Provider {
kind,
status: None,
retry_after: None,
message: message.into(),
}
}
pub fn provider_transport(message: impl Into<String>) -> Self {
Self::provider(ProviderErrorKind::Transport, message)
}
pub fn provider_malformed(message: impl Into<String>) -> Self {
Self::provider(ProviderErrorKind::Malformed, message)
}
pub fn provider_status(
status: u16,
retry_after: Option<Duration>,
message: impl Into<String>,
) -> Self {
Self::Provider {
kind: ProviderErrorKind::from_status(status),
status: Some(status),
retry_after,
message: message.into(),
}
}
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
let kind = if e.is_timeout() {
ProviderErrorKind::Timeout
} else {
ProviderErrorKind::Transport
};
Self::provider(kind, e.to_string())
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_same_status_always_maps_to_the_same_kind() {
use ProviderErrorKind::*;
for (status, kind) in [
(429, RateLimited),
(401, Auth),
(403, Auth),
(400, Request),
(404, Request),
(422, Request),
(500, Server),
(503, Server),
(599, Server),
] {
assert_eq!(ProviderErrorKind::from_status(status), kind, "{status}");
}
}
#[test]
fn retrying_is_worth_it_for_everything_except_a_rejected_request() {
use ProviderErrorKind::*;
for kind in [Transport, Timeout, RateLimited, Server, Malformed] {
assert!(kind.is_retryable(), "{kind:?}");
}
for kind in [Auth, Request] {
assert!(!kind.is_retryable(), "{kind:?}");
}
}
#[test]
fn a_status_failure_keeps_its_status_and_retry_after() {
let e = Error::provider_status(429, Some(Duration::from_secs(7)), "slow down");
let Error::Provider {
kind,
status,
retry_after,
..
} = &e
else {
panic!("expected a provider error");
};
assert_eq!(*kind, ProviderErrorKind::RateLimited);
assert_eq!(*status, Some(429));
assert_eq!(*retry_after, Some(Duration::from_secs(7)));
let shown = e.to_string();
assert!(shown.contains("RateLimited"), "{shown}");
assert!(shown.contains("429"), "{shown}");
}
}