Skip to main content

taskvisor/
error.rs

1//! Error types for runtime orchestration and task execution.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use thiserror::Error;
7
8use crate::identity::TaskId;
9
10/// Boxed source error carried by [`TaskError::Fail`] and [`TaskError::Fatal`].
11pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
12
13/// Shared source error carried on the completion plane.
14pub type SharedError = Arc<dyn std::error::Error + Send + Sync + 'static>;
15
16/// Errors produced by the supervisor runtime.
17///
18/// These are orchestration errors: add, remove, shutdown, and run failures.
19/// They are separate from [`TaskError`], which is returned by task attempts.
20///
21/// Match with a wildcard arm because this enum is non-exhaustive.
22///
23/// # Also
24///
25/// - [`Supervisor`](crate::Supervisor) - returns `RuntimeError` from [`run`](crate::Supervisor::run)
26/// - [`SupervisorHandle`](crate::SupervisorHandle) - returns `RuntimeError` from management methods
27#[non_exhaustive]
28#[derive(Error, Debug)]
29pub enum RuntimeError {
30    /// Shutdown grace period was exceeded.
31    ///
32    /// Some tasks did not stop in time and had to be force-terminated.
33    #[error("shutdown timeout {grace:?} exceeded; stuck: {stuck:?}; forcing termination")]
34    GraceExceeded {
35        /// Configured shutdown grace duration.
36        grace: Duration,
37        /// Task names that did not stop in time.
38        stuck: Vec<Arc<str>>,
39    },
40
41    /// A task with the same name is already registered.
42    #[error("task '{name}' already exists in registry")]
43    TaskAlreadyExists {
44        /// Duplicate task name.
45        name: Arc<str>,
46    },
47
48    /// Timed out while waiting for removal confirmation.
49    #[error("timeout waiting for task {id} removal after {timeout:?}")]
50    TaskRemoveTimeout {
51        /// Task id that did not report removal in time.
52        id: TaskId,
53        /// Wait duration before timing out.
54        timeout: Duration,
55    },
56
57    /// Timed out while waiting for registration confirmation.
58    #[error("timeout waiting for task '{name}' registration after {timeout:?}")]
59    TaskAddTimeout {
60        /// Task name that was not confirmed in time.
61        name: Arc<str>,
62        /// Wait duration before timing out.
63        timeout: Duration,
64    },
65
66    /// OS signal listener setup failed.
67    ///
68    /// Signal-based shutdown is unavailable.
69    /// The original [`std::io::Error`] is preserved as the source.
70    #[error("failed to install shutdown signal handlers: {source}")]
71    SignalSetupFailed {
72        /// I/O error returned by signal registration.
73        #[source]
74        source: std::io::Error,
75    },
76
77    /// The runtime is shutting down and no longer accepts commands.
78    #[error("supervisor is shutting down")]
79    ShuttingDown,
80
81    /// [`Supervisor::run`](crate::Supervisor::run) was called more than once.
82    ///
83    /// A supervisor run is single-shot.
84    #[error("supervisor run() was already started")]
85    AlreadyRunning,
86}
87
88impl RuntimeError {
89    /// Returns a stable machine-readable label for logs and metrics.
90    ///
91    /// This label is not the same as `Display`.
92    #[must_use]
93    pub fn as_label(&self) -> &'static str {
94        match self {
95            RuntimeError::GraceExceeded { .. } => "runtime_grace_exceeded",
96            RuntimeError::TaskAlreadyExists { .. } => "runtime_task_already_exists",
97            RuntimeError::TaskRemoveTimeout { .. } => "runtime_task_remove_timeout",
98            RuntimeError::TaskAddTimeout { .. } => "runtime_task_add_timeout",
99            RuntimeError::SignalSetupFailed { .. } => "runtime_signal_setup_failed",
100            RuntimeError::ShuttingDown => "runtime_shutting_down",
101            RuntimeError::AlreadyRunning => "runtime_already_running",
102        }
103    }
104}
105
106/// Errors returned by a task attempt.
107///
108/// A task returns `Result<(), TaskError>` from [`Task::spawn`](crate::Task::spawn).
109///
110/// Restart behavior:
111/// - [`Canceled`](Self::Canceled) is cooperative shutdown and is not retried,
112/// - [`Fatal`](Self::Fatal) is not retryable,
113/// - [`Timeout`](Self::Timeout) is retryable,
114/// - [`Fail`](Self::Fail) is retryable.
115///
116/// Match with a wildcard arm because this enum is non-exhaustive.
117///
118/// # Also
119///
120/// - [`Task`](crate::Task) - task trait
121/// - [`RestartPolicy`](crate::RestartPolicy) - decides whether retryable errors restart
122#[non_exhaustive]
123#[derive(Error, Debug)]
124pub enum TaskError {
125    /// The attempt exceeded its configured timeout.
126    #[error("timed out after {timeout:?}")]
127    Timeout {
128        /// Timeout duration that was exceeded.
129        timeout: Duration,
130    },
131
132    /// Permanent task failure.
133    ///
134    /// This stops the actor and is not retried.
135    #[error("fatal error (no retry): {reason}")]
136    #[non_exhaustive]
137    Fatal {
138        /// Human-readable failure reason.
139        reason: String,
140        /// Process-style exit code, when available.
141        ///
142        /// `None` means this was a logical error with no process exit code.
143        exit_code: Option<i32>,
144        /// Underlying error preserved for source chains.
145        #[source]
146        source: Option<BoxError>,
147    },
148
149    /// Retryable task failure.
150    ///
151    /// The actor may restart after this error, depending on restart policy.
152    #[error("execution failed: {reason}")]
153    #[non_exhaustive]
154    Fail {
155        /// Human-readable failure reason.
156        reason: String,
157        /// Process-style exit code, when available.
158        ///
159        /// `None` means this was a logical error with no process exit code.
160        exit_code: Option<i32>,
161        /// Underlying error preserved for source chains.
162        #[source]
163        source: Option<BoxError>,
164    },
165
166    /// Cooperative cancellation.
167    ///
168    /// Tasks should return this when they stop because [`TaskContext`](crate::TaskContext)
169    /// was cancelled.
170    #[error("context canceled")]
171    Canceled,
172}
173
174impl TaskError {
175    /// Creates a retryable failure with no source error.
176    pub fn fail(reason: impl Into<String>) -> Self {
177        TaskError::Fail {
178            reason: reason.into(),
179            exit_code: None,
180            source: None,
181        }
182    }
183
184    /// Creates a permanent failure with no source error.
185    pub fn fatal(reason: impl Into<String>) -> Self {
186        TaskError::Fatal {
187            reason: reason.into(),
188            exit_code: None,
189            source: None,
190        }
191    }
192
193    /// Creates a retryable failure from a source error.
194    ///
195    /// The display reason is copied from `source.to_string()`.
196    /// The original error remains available through [`std::error::Error::source`].
197    pub fn fail_from<E>(source: E) -> Self
198    where
199        E: std::error::Error + Send + Sync + 'static,
200    {
201        TaskError::Fail {
202            reason: source.to_string(),
203            exit_code: None,
204            source: Some(Box::new(source)),
205        }
206    }
207
208    /// Creates a permanent failure from a source error.
209    ///
210    /// The display reason is copied from `source.to_string()`.
211    /// The original error remains available through [`std::error::Error::source`].
212    pub fn fatal_from<E>(source: E) -> Self
213    where
214        E: std::error::Error + Send + Sync + 'static,
215    {
216        TaskError::Fatal {
217            reason: source.to_string(),
218            exit_code: None,
219            source: Some(Box::new(source)),
220        }
221    }
222
223    /// Attaches a process-style exit code to `Fail` or `Fatal`.
224    ///
225    /// No-op for `Timeout` and `Canceled`.
226    #[must_use]
227    pub fn with_exit_code(mut self, code: impl Into<Option<i32>>) -> Self {
228        let code = code.into();
229        if let TaskError::Fail { exit_code, .. } | TaskError::Fatal { exit_code, .. } = &mut self {
230            *exit_code = code;
231        }
232        self
233    }
234
235    /// Attaches a source error to `Fail` or `Fatal`.
236    ///
237    /// No-op for `Timeout` and `Canceled`.
238    #[must_use]
239    pub fn with_source(mut self, source: impl Into<BoxError>) -> Self {
240        if let TaskError::Fail { source: s, .. } | TaskError::Fatal { source: s, .. } = &mut self {
241            *s = Some(source.into());
242        }
243        self
244    }
245
246    /// Consumes the error and returns its boxed source, if any.
247    #[must_use]
248    pub fn into_source(self) -> Option<BoxError> {
249        match self {
250            TaskError::Fail { source, .. } | TaskError::Fatal { source, .. } => source,
251            _ => None,
252        }
253    }
254
255    /// Returns a stable machine-readable label for logs and metrics.
256    ///
257    /// This label is not the same as `Display`.
258    #[must_use]
259    pub fn as_label(&self) -> &'static str {
260        match self {
261            TaskError::Timeout { .. } => "task_timeout",
262            TaskError::Fatal { .. } => "task_fatal",
263            TaskError::Fail { .. } => "task_failed",
264            TaskError::Canceled => "task_canceled",
265        }
266    }
267
268    /// Returns `true` for errors that may be retried by restart policy.
269    #[must_use]
270    pub fn is_retryable(&self) -> bool {
271        matches!(self, TaskError::Timeout { .. } | TaskError::Fail { .. })
272    }
273
274    /// Returns `true` for [`TaskError::Fatal`].
275    #[must_use]
276    pub fn is_fatal(&self) -> bool {
277        matches!(self, TaskError::Fatal { .. })
278    }
279
280    /// Returns the process-style exit code, if one was attached.
281    #[must_use]
282    pub fn exit_code(&self) -> Option<i32> {
283        match self {
284            TaskError::Fatal { exit_code, .. } | TaskError::Fail { exit_code, .. } => *exit_code,
285            TaskError::Timeout { .. } | TaskError::Canceled => None,
286        }
287    }
288}
289
290/// Umbrella error for mixed supervisor and controller call chains.
291///
292/// [`RuntimeError`] and `ControllerError` (feature `controller`) convert into this type with `?`.
293/// Match on the variant to get the original error back.
294///
295/// Use it when one function mixes `add*` and `submit*` calls:
296///
297/// ```rust
298/// use taskvisor::{Error, RuntimeError};
299///
300/// fn stop() -> Result<(), Error> {
301///     Err(RuntimeError::ShuttingDown)? // `?` converts automatically
302/// }
303///
304/// assert!(matches!(stop(), Err(Error::Runtime(_))));
305/// ```
306///
307/// Match with a wildcard arm because this enum is non-exhaustive.
308#[non_exhaustive]
309#[derive(Error, Debug)]
310pub enum Error {
311    /// Core runtime error from `run`, `add*`, waiters, and management methods.
312    #[error(transparent)]
313    Runtime(#[from] RuntimeError),
314
315    /// Controller submission error from `submit*` methods.
316    ///
317    /// Requires the `controller` feature.
318    #[cfg(feature = "controller")]
319    #[error(transparent)]
320    Controller(#[from] crate::controller::ControllerError),
321}
322
323impl Error {
324    /// Returns a stable machine-readable label for logs and metrics.
325    ///
326    /// The label comes from the wrapped error.
327    #[must_use]
328    pub fn as_label(&self) -> &'static str {
329        match self {
330            Error::Runtime(e) => e.as_label(),
331            #[cfg(feature = "controller")]
332            Error::Controller(e) => e.as_label(),
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn exit_code_is_some_for_fail_with_code() {
343        let e = TaskError::fail("x").with_exit_code(5);
344        assert_eq!(e.exit_code(), Some(5));
345        assert!(e.is_retryable());
346        assert!(!e.is_fatal());
347    }
348
349    #[test]
350    fn exit_code_is_some_for_fatal_with_code() {
351        let e = TaskError::fatal("x").with_exit_code(137);
352        assert_eq!(e.exit_code(), Some(137));
353        assert!(!e.is_retryable());
354        assert!(e.is_fatal());
355    }
356
357    #[test]
358    fn exit_code_is_none_for_logical_fail() {
359        let e = TaskError::fail("logical");
360        assert_eq!(e.exit_code(), None);
361    }
362
363    #[test]
364    fn exit_code_is_none_for_timeout_and_canceled() {
365        assert_eq!(
366            TaskError::Timeout {
367                timeout: Duration::from_secs(1),
368            }
369            .exit_code(),
370            None,
371        );
372        assert_eq!(TaskError::Canceled.exit_code(), None);
373    }
374
375    #[test]
376    fn display_still_renders_reason_only() {
377        let e = TaskError::fail("boom").with_exit_code(1);
378        assert_eq!(e.to_string(), "execution failed: boom");
379    }
380
381    #[test]
382    fn fail_constructor_is_retryable_and_sourceless() {
383        let e = TaskError::fail("logical");
384        assert_eq!(e.to_string(), "execution failed: logical");
385        assert!(e.is_retryable());
386        assert_eq!(e.exit_code(), None);
387        assert!(std::error::Error::source(&e).is_none());
388    }
389
390    #[test]
391    fn fail_from_preserves_source_chain_and_io_kind() {
392        let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
393        let e = TaskError::fail_from(io);
394
395        assert!(e.to_string().contains("denied"));
396        let src = std::error::Error::source(&e).expect("source must be present");
397        let io_ref = src
398            .downcast_ref::<std::io::Error>()
399            .expect("source must downcast to the original io::Error");
400        assert_eq!(io_ref.kind(), std::io::ErrorKind::PermissionDenied);
401    }
402
403    #[test]
404    fn with_exit_code_and_with_source_builders_compose() {
405        let io = std::io::Error::other("boom");
406        let e = TaskError::fail("upload failed")
407            .with_exit_code(13)
408            .with_source(io);
409
410        assert_eq!(e.exit_code(), Some(13));
411        assert_eq!(e.to_string(), "execution failed: upload failed");
412        assert!(std::error::Error::source(&e).is_some());
413    }
414
415    #[test]
416    fn with_exit_code_accepts_bare_and_option() {
417        assert_eq!(TaskError::fail("x").with_exit_code(7).exit_code(), Some(7));
418
419        let dynamic: Option<i32> = None;
420        assert_eq!(
421            TaskError::fail("y").with_exit_code(dynamic).exit_code(),
422            None
423        );
424    }
425
426    #[test]
427    fn fatal_from_is_fatal_and_carries_source() {
428        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
429        let e = TaskError::fatal_from(io);
430
431        assert!(e.is_fatal());
432        assert!(!e.is_retryable());
433
434        let src = std::error::Error::source(&e).expect("source present");
435        assert_eq!(
436            src.downcast_ref::<std::io::Error>().unwrap().kind(),
437            std::io::ErrorKind::NotFound
438        );
439    }
440
441    #[test]
442    fn signal_setup_failed_exposes_io_source() {
443        let io = std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use");
444        let e = RuntimeError::SignalSetupFailed { source: io };
445
446        assert!(e.to_string().contains("in use"));
447
448        let src = std::error::Error::source(&e).expect("source present");
449        assert_eq!(
450            src.downcast_ref::<std::io::Error>().unwrap().kind(),
451            std::io::ErrorKind::AddrInUse
452        );
453    }
454}