Skip to main content

somatize_worker/
error.rs

1//! What can go wrong in a worker.
2//!
3//! Every error this crate produced used to be `SomaError::Other(String)` —
4//! all 51 of them. So a caller could not tell a dropped socket from a
5//! Python subprocess that died from a plan it could not decode, and the
6//! three want different responses: retry, restart the interpreter, give
7//! up. A string that reads differently is not a distinction a program can
8//! act on.
9//!
10//! These variants are the worker's domain, which is why they live here and
11//! not in [`SomaError`]: `soma-core` describes graphs and their execution,
12//! and it has no business knowing what a venv is. At the boundary — the
13//! traits this crate implements for the runtime — a `WorkerError` converts
14//! into `SomaError`, keeping its message.
15//!
16//! See the "Errors: typed at the edges, shared at the seams" entry in the
17//! design decisions.
18
19use somatize_core::error::SomaError;
20
21/// A failure inside a worker.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum WorkerError {
25    /// The socket or the HTTP endpoint. Usually worth retrying.
26    #[error("transport: {0}")]
27    Transport(String),
28
29    /// The Python subprocess: it would not start, would not answer, or
30    /// answered something that was not a reply.
31    #[error("python subprocess: {0}")]
32    Python(String),
33
34    /// Building or updating an isolated environment.
35    #[error("environment: {0}")]
36    Env(String),
37
38    /// A payload that would not encode or decode. Distinct from
39    /// [`WorkerError::Transport`]: the bytes arrived, they were wrong.
40    #[error("encoding: {0}")]
41    Encoding(String),
42
43    /// A lock was poisoned or a worker thread panicked. Means some other
44    /// error already happened and took a thread with it.
45    #[error("worker state: {0}")]
46    Concurrency(String),
47
48    /// The remote worker ran the plan and reported a failure. The message
49    /// is the worker's, not ours.
50    #[error("remote worker: {0}")]
51    Remote(String),
52
53    /// Filesystem or process I/O, kept as the original `io::Error` so
54    /// callers can still match on its kind.
55    #[error(transparent)]
56    Io(#[from] std::io::Error),
57
58    /// A failure that came from the shared core — a cache miss that
59    /// mattered, a bad graph — and is passed through unchanged.
60    #[error(transparent)]
61    Core(#[from] SomaError),
62}
63
64/// The seam. A worker error crossing into the runtime keeps its message
65/// and its prefix, so `transport: WS connect: …` still says which layer
66/// failed even after the type is gone.
67impl From<WorkerError> for SomaError {
68    fn from(e: WorkerError) -> Self {
69        match e {
70            WorkerError::Core(inner) => inner,
71            WorkerError::Io(inner) => SomaError::Io(inner),
72            other => SomaError::Other(other.to_string()),
73        }
74    }
75}
76
77/// `Result` with this crate's error.
78pub type Result<T> = std::result::Result<T, WorkerError>;
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// The distinction survives as far as the shared type allows.
85    ///
86    /// Before this existed, all 51 errors this crate produced were
87    /// `SomaError::Other`, so "the socket dropped" and "the interpreter
88    /// died" were the same value with different prose. Inside the crate
89    /// they are now different variants; crossing the seam they keep the
90    /// prefix that says which layer failed.
91    #[test]
92    fn a_worker_error_keeps_its_layer_when_it_crosses_the_seam() {
93        let transport: SomaError = WorkerError::Transport("WS connect refused".into()).into();
94        assert!(transport.to_string().contains("transport:"), "{transport}");
95
96        let python: SomaError = WorkerError::Python("interpreter exited".into()).into();
97        assert!(
98            python.to_string().contains("python subprocess:"),
99            "{python}"
100        );
101        assert_ne!(transport.to_string(), python.to_string());
102    }
103
104    /// A core error passing through is not re-wrapped: it would gain a
105    /// second layer of prefix and stop matching what it was.
106    #[test]
107    fn a_core_error_passes_through_unchanged() {
108        let original = SomaError::NodeNotFound("scaler".into());
109        let round_tripped: SomaError = WorkerError::Core(original).into();
110        assert!(matches!(round_tripped, SomaError::NodeNotFound(id) if id == "scaler"));
111    }
112}