use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use crate::identity::TaskId;
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type SharedError = Arc<dyn std::error::Error + Send + Sync + 'static>;
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum RuntimeError {
#[error("shutdown timeout {grace:?} exceeded; stuck: {stuck:?}; forcing termination")]
GraceExceeded {
grace: Duration,
stuck: Vec<Arc<str>>,
},
#[error("task name '{name}' already exists")]
TaskAlreadyExists {
name: Arc<str>,
},
#[error("registry command queue is full")]
CommandQueueFull,
#[error("timeout waiting for task {id} removal after {timeout:?}")]
TaskRemoveTimeout {
id: TaskId,
timeout: Duration,
},
#[error("failed to install shutdown signal handlers: {source}")]
SignalSetupFailed {
#[source]
source: std::io::Error,
},
#[error("supervisor is shutting down")]
ShuttingDown,
#[error("supervisor run() was already started")]
AlreadyRunning,
}
impl RuntimeError {
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
RuntimeError::GraceExceeded { .. } => "runtime_grace_exceeded",
RuntimeError::TaskAlreadyExists { .. } => "runtime_task_already_exists",
RuntimeError::CommandQueueFull => "runtime_command_queue_full",
RuntimeError::TaskRemoveTimeout { .. } => "runtime_task_remove_timeout",
RuntimeError::SignalSetupFailed { .. } => "runtime_signal_setup_failed",
RuntimeError::ShuttingDown => "runtime_shutting_down",
RuntimeError::AlreadyRunning => "runtime_already_running",
}
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum TaskError {
#[error("timed out after {timeout:?}")]
Timeout {
timeout: Duration,
},
#[error("fatal error (no retry): {reason}")]
#[non_exhaustive]
Fatal {
reason: String,
exit_code: Option<i32>,
#[source]
source: Option<BoxError>,
},
#[error("execution failed: {reason}")]
#[non_exhaustive]
Fail {
reason: String,
exit_code: Option<i32>,
#[source]
source: Option<BoxError>,
},
#[error("context canceled")]
Canceled,
}
impl TaskError {
pub fn fail(reason: impl Into<String>) -> Self {
TaskError::Fail {
reason: reason.into(),
exit_code: None,
source: None,
}
}
pub fn fatal(reason: impl Into<String>) -> Self {
TaskError::Fatal {
reason: reason.into(),
exit_code: None,
source: None,
}
}
pub fn fail_from<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
TaskError::Fail {
reason: source.to_string(),
exit_code: None,
source: Some(Box::new(source)),
}
}
pub fn fatal_from<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
TaskError::Fatal {
reason: source.to_string(),
exit_code: None,
source: Some(Box::new(source)),
}
}
#[must_use]
pub fn with_exit_code(mut self, code: impl Into<Option<i32>>) -> Self {
let code = code.into();
if let TaskError::Fail { exit_code, .. } | TaskError::Fatal { exit_code, .. } = &mut self {
*exit_code = code;
}
self
}
#[must_use]
pub fn with_source(mut self, source: impl Into<BoxError>) -> Self {
if let TaskError::Fail { source: s, .. } | TaskError::Fatal { source: s, .. } = &mut self {
*s = Some(source.into());
}
self
}
#[must_use]
pub fn into_source(self) -> Option<BoxError> {
match self {
TaskError::Fail { source, .. } | TaskError::Fatal { source, .. } => source,
_ => None,
}
}
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
TaskError::Timeout { .. } => "task_timeout",
TaskError::Fatal { .. } => "task_fatal",
TaskError::Fail { .. } => "task_failed",
TaskError::Canceled => "task_canceled",
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, TaskError::Timeout { .. } | TaskError::Fail { .. })
}
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(self, TaskError::Fatal { .. })
}
#[must_use]
pub fn exit_code(&self) -> Option<i32> {
match self {
TaskError::Fatal { exit_code, .. } | TaskError::Fail { exit_code, .. } => *exit_code,
TaskError::Timeout { .. } | TaskError::Canceled => None,
}
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[cfg(feature = "controller")]
#[error(transparent)]
Controller(#[from] crate::controller::ControllerError),
}
impl Error {
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
Error::Runtime(e) => e.as_label(),
#[cfg(feature = "controller")]
Error::Controller(e) => e.as_label(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_queue_full_has_stable_label() {
let error = RuntimeError::CommandQueueFull;
assert_eq!(error.as_label(), "runtime_command_queue_full");
assert_eq!(error.to_string(), "registry command queue is full");
}
#[test]
fn exit_code_is_some_for_fail_with_code() {
let e = TaskError::fail("x").with_exit_code(5);
assert_eq!(e.exit_code(), Some(5));
assert!(e.is_retryable());
assert!(!e.is_fatal());
}
#[test]
fn exit_code_is_some_for_fatal_with_code() {
let e = TaskError::fatal("x").with_exit_code(137);
assert_eq!(e.exit_code(), Some(137));
assert!(!e.is_retryable());
assert!(e.is_fatal());
}
#[test]
fn exit_code_is_none_for_logical_fail() {
let e = TaskError::fail("logical");
assert_eq!(e.exit_code(), None);
}
#[test]
fn exit_code_is_none_for_timeout_and_canceled() {
assert_eq!(
TaskError::Timeout {
timeout: Duration::from_secs(1),
}
.exit_code(),
None,
);
assert_eq!(TaskError::Canceled.exit_code(), None);
}
#[test]
fn display_still_renders_reason_only() {
let e = TaskError::fail("boom").with_exit_code(1);
assert_eq!(e.to_string(), "execution failed: boom");
}
#[test]
fn fail_constructor_is_retryable_and_sourceless() {
let e = TaskError::fail("logical");
assert_eq!(e.to_string(), "execution failed: logical");
assert!(e.is_retryable());
assert_eq!(e.exit_code(), None);
assert!(std::error::Error::source(&e).is_none());
}
#[test]
fn fail_from_preserves_source_chain_and_io_kind() {
let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let e = TaskError::fail_from(io);
assert!(e.to_string().contains("denied"));
let src = std::error::Error::source(&e).expect("source must be present");
let io_ref = src
.downcast_ref::<std::io::Error>()
.expect("source must downcast to the original io::Error");
assert_eq!(io_ref.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn with_exit_code_and_with_source_builders_compose() {
let io = std::io::Error::other("boom");
let e = TaskError::fail("upload failed")
.with_exit_code(13)
.with_source(io);
assert_eq!(e.exit_code(), Some(13));
assert_eq!(e.to_string(), "execution failed: upload failed");
assert!(std::error::Error::source(&e).is_some());
}
#[test]
fn with_exit_code_accepts_bare_and_option() {
assert_eq!(TaskError::fail("x").with_exit_code(7).exit_code(), Some(7));
let dynamic: Option<i32> = None;
assert_eq!(
TaskError::fail("y").with_exit_code(dynamic).exit_code(),
None
);
}
#[test]
fn fatal_from_is_fatal_and_carries_source() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let e = TaskError::fatal_from(io);
assert!(e.is_fatal());
assert!(!e.is_retryable());
let src = std::error::Error::source(&e).expect("source present");
assert_eq!(
src.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::NotFound
);
}
#[test]
fn signal_setup_failed_exposes_io_source() {
let io = std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use");
let e = RuntimeError::SignalSetupFailed { source: io };
assert!(e.to_string().contains("in use"));
let src = std::error::Error::source(&e).expect("source present");
assert_eq!(
src.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::AddrInUse
);
}
}