Skip to main content

sail/
error.rs

1//! Canonical, language-neutral error taxonomy for the Sail core.
2//!
3//! This enum is the contract every binding maps onto its own error type:
4//! the Python SDK to its `sail.errors` classes, the native CLI to its exit
5//! handling, a future TS SDK to its `Error` subclasses. The `Display` impl
6//! is for Rust consumers and logs only. Error message *text* is NOT a
7//! cross-language contract, so bindings are free to format their own
8//! user-facing messages from the structured fields.
9
10use std::error::Error as StdError;
11
12use thiserror::Error;
13use tonic::{Code, Status};
14
15/// gRPC status code, decoupled from any one client library's spelling.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum GrpcCode {
19    /// The operation completed successfully.
20    Ok,
21    /// The operation was canceled, typically by the caller.
22    Cancelled,
23    /// An unknown error, often a status with no mapped code.
24    Unknown,
25    /// The client specified an invalid argument.
26    InvalidArgument,
27    /// The deadline expired before the operation could complete.
28    DeadlineExceeded,
29    /// The requested entity was not found.
30    NotFound,
31    /// An attempt to create an entity that already exists.
32    AlreadyExists,
33    /// The caller lacks permission to execute the operation.
34    PermissionDenied,
35    /// A resource (quota, capacity, file space) has been exhausted.
36    ResourceExhausted,
37    /// The system is not in a state required for the operation.
38    FailedPrecondition,
39    /// The operation was aborted, often due to a concurrency conflict.
40    Aborted,
41    /// The operation was attempted past the valid range.
42    OutOfRange,
43    /// The operation is not implemented or not supported.
44    Unimplemented,
45    /// An internal error: an invariant expected by the system was broken.
46    Internal,
47    /// The service is currently unavailable, typically transient.
48    Unavailable,
49    /// Unrecoverable data loss or corruption.
50    DataLoss,
51    /// The request lacks valid authentication credentials.
52    Unauthenticated,
53}
54
55impl GrpcCode {
56    /// A stable, lowercase identifier for the code. Bindings that need a
57    /// different spelling (e.g. a language's own convention) map from the
58    /// `GrpcCode` value rather than parsing this string.
59    pub fn as_str(self) -> &'static str {
60        match self {
61            GrpcCode::Ok => "ok",
62            GrpcCode::Cancelled => "cancelled",
63            GrpcCode::Unknown => "unknown",
64            GrpcCode::InvalidArgument => "invalid_argument",
65            GrpcCode::DeadlineExceeded => "deadline_exceeded",
66            GrpcCode::NotFound => "not_found",
67            GrpcCode::AlreadyExists => "already_exists",
68            GrpcCode::PermissionDenied => "permission_denied",
69            GrpcCode::ResourceExhausted => "resource_exhausted",
70            GrpcCode::FailedPrecondition => "failed_precondition",
71            GrpcCode::Aborted => "aborted",
72            GrpcCode::OutOfRange => "out_of_range",
73            GrpcCode::Unimplemented => "unimplemented",
74            GrpcCode::Internal => "internal",
75            GrpcCode::Unavailable => "unavailable",
76            GrpcCode::DataLoss => "data_loss",
77            GrpcCode::Unauthenticated => "unauthenticated",
78        }
79    }
80}
81
82impl std::fmt::Display for GrpcCode {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88impl From<Code> for GrpcCode {
89    fn from(code: Code) -> GrpcCode {
90        match code {
91            Code::Ok => GrpcCode::Ok,
92            Code::Cancelled => GrpcCode::Cancelled,
93            Code::Unknown => GrpcCode::Unknown,
94            Code::InvalidArgument => GrpcCode::InvalidArgument,
95            Code::DeadlineExceeded => GrpcCode::DeadlineExceeded,
96            Code::NotFound => GrpcCode::NotFound,
97            Code::AlreadyExists => GrpcCode::AlreadyExists,
98            Code::PermissionDenied => GrpcCode::PermissionDenied,
99            Code::ResourceExhausted => GrpcCode::ResourceExhausted,
100            Code::FailedPrecondition => GrpcCode::FailedPrecondition,
101            Code::Aborted => GrpcCode::Aborted,
102            Code::OutOfRange => GrpcCode::OutOfRange,
103            Code::Unimplemented => GrpcCode::Unimplemented,
104            Code::Internal => GrpcCode::Internal,
105            Code::Unavailable => GrpcCode::Unavailable,
106            Code::DataLoss => GrpcCode::DataLoss,
107            Code::Unauthenticated => GrpcCode::Unauthenticated,
108        }
109    }
110}
111
112/// How a transport attempt failed, independent of any language's exception
113/// names. Bindings map these onto their own transport-error types.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum TransportKind {
117    /// The attempt exceeded its deadline before a response arrived.
118    Timeout,
119    /// The transport could not establish or maintain a connection.
120    Connection,
121}
122
123impl std::fmt::Display for TransportKind {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.write_str(match self {
126            TransportKind::Timeout => "timeout",
127            TransportKind::Connection => "connection",
128        })
129    }
130}
131
132/// The canonical Sail error. Variants carry structured fields so bindings can
133/// populate their own exception attributes without parsing strings.
134///
135/// `Display` is for Rust logs only; bindings format their own user-facing text.
136#[derive(Debug, Error)]
137#[non_exhaustive]
138pub enum SailError {
139    /// Configuration/usage error (e.g. missing API key, bad SAIL_MODE).
140    #[error("{message}")]
141    Config {
142        /// Human-readable description of the misconfiguration.
143        message: String,
144    },
145    /// Unexpected internal failure (client build, TLS config, invariant).
146    #[error("{message}")]
147    Internal {
148        /// Human-readable description of the internal failure.
149        message: String,
150    },
151    /// HTTP/gRPC dial-time transport failure. `source` is the underlying
152    /// transport error (e.g. the originating `tonic::Status`) for Rust callers.
153    #[error("{kind} error: {message}")]
154    Transport {
155        /// Whether the transport failed by timeout or by connection error.
156        kind: TransportKind,
157        /// Human-readable description of the transport failure.
158        message: String,
159        /// The underlying transport error, retained for Rust callers; `None`
160        /// when no originating error was available.
161        #[source]
162        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
163    },
164    /// Sailbox creation failed (non-2xx on create).
165    #[error("{message}")]
166    Creation {
167        /// Human-readable description of the creation failure.
168        message: String,
169        /// HTTP status code returned by the create request.
170        status: u16,
171        /// Parsed response body from the failed create request.
172        body: serde_json::Value,
173    },
174    /// A resource was not found (404 on get / unknown id across orgs).
175    #[error("{message}")]
176    NotFound {
177        /// Human-readable description of the missing resource.
178        message: String,
179    },
180    /// A credential problem: missing/invalid API key or insufficient scope.
181    #[error("{message}")]
182    PermissionDenied {
183        /// Human-readable description of the credential or scope problem.
184        message: String,
185    },
186    /// A file operation referenced a path that does not exist.
187    #[error("{message}")]
188    FileNotFound {
189        /// Human-readable description of the missing path.
190        message: String,
191    },
192    /// A request argument was rejected as invalid.
193    #[error("{message}")]
194    InvalidArgument {
195        /// Human-readable description of why the argument was rejected.
196        message: String,
197    },
198    /// Any other non-2xx API response.
199    #[error("{message}")]
200    Api {
201        /// HTTP status code returned by the API.
202        status: u16,
203        /// Human-readable description of the API failure.
204        message: String,
205        /// Parsed response body from the failed API request.
206        body: serde_json::Value,
207    },
208    /// A wait referenced an unknown exec request.
209    #[error("{message}")]
210    ExecRequestNotFound {
211        /// Human-readable description of the unknown exec request.
212        message: String,
213    },
214    /// The sailbox no longer exists on the worker.
215    #[error("{message}")]
216    Terminated {
217        /// Human-readable description noting the sailbox is gone.
218        message: String,
219    },
220    /// The worker hosting the sailbox was lost; output is unrecoverable.
221    #[error("{message}")]
222    WorkerLost {
223        /// Human-readable description of the worker loss.
224        message: String,
225    },
226    /// The command was interrupted; `retryable` indicates whether a retry
227    /// could still observe the result.
228    #[error("{message}")]
229    ExecInterrupted {
230        /// Whether a retry could still observe the command's result.
231        retryable: bool,
232        /// Human-readable description of the interruption.
233        message: String,
234    },
235    /// The command was canceled before producing an exit code.
236    #[error("{message}")]
237    ExecCanceled {
238        /// Human-readable description of the cancellation.
239        message: String,
240    },
241    /// A stdin write hit a command that already finished or closed its stdin.
242    #[error("{message}")]
243    BrokenPipe {
244        /// Human-readable description of the broken-pipe condition.
245        message: String,
246    },
247    /// Any other exec-RPC failure, classified by gRPC code.
248    #[error("{code}: {detail}")]
249    Execution {
250        /// The gRPC status code that classifies the failure.
251        code: GrpcCode,
252        /// Human-readable detail describing the failure.
253        detail: String,
254    },
255}
256
257/// Detect a client-side transport failure that tonic surfaces as a gRPC
258/// `Status`. A failed connect or transport timeout carries a populated
259/// `source()`, whereas a status decoded from server response trailers does
260/// not. We use that to keep true transport failures (which bindings map to
261/// their own connection/timeout error types) out of the server-status
262/// taxonomy, instead of misreporting them as an `Execution` error.
263fn transport_failure(status: &Status) -> Option<SailError> {
264    status.source()?;
265    let message = status.message().to_string();
266    let lower = message.to_lowercase();
267    let kind = if status.code() == Code::DeadlineExceeded
268        || lower.contains("timed out")
269        || lower.contains("timeout")
270    {
271        TransportKind::Timeout
272    } else {
273        TransportKind::Connection
274    };
275    Some(SailError::Transport {
276        kind,
277        message,
278        source: Some(Box::new(status.clone())),
279    })
280}
281
282impl SailError {
283    /// Classify an exec-context gRPC status into the canonical taxonomy.
284    ///
285    /// NOT_FOUND splits into [`SailError::ExecRequestNotFound`] (the wait
286    /// referenced an unknown exec request) versus [`SailError::Terminated`]
287    /// (the sailbox itself is gone). Everything else is a generic
288    /// [`SailError::Execution`] carrying the structured code; bindings format
289    /// their own message from `code` + `detail`.
290    pub fn from_exec_status(status: &Status) -> SailError {
291        if let Some(err) = transport_failure(status) {
292            return err;
293        }
294        let detail = if status.message().is_empty() {
295            "unknown sailbox exec error"
296        } else {
297            status.message()
298        };
299        if status.code() == Code::NotFound {
300            if detail.contains("exec request") {
301                return SailError::ExecRequestNotFound {
302                    message: detail.to_string(),
303                };
304            }
305            return SailError::Terminated {
306                message: format!(
307                    "{detail}; this is likely because this Sailbox is no longer running"
308                ),
309            };
310        }
311        SailError::Execution {
312            code: status.code().into(),
313            detail: detail.to_string(),
314        }
315    }
316
317    /// Classify a general (non-exec) worker-proxy gRPC status (listeners,
318    /// files). NOT_FOUND maps to [`SailError::NotFound`], a credential failure
319    /// to [`SailError::PermissionDenied`], and everything else to
320    /// [`SailError::Execution`] carrying the structured code.
321    pub fn from_rpc_status(status: &Status) -> SailError {
322        if let Some(err) = transport_failure(status) {
323            return err;
324        }
325        let detail = if status.message().is_empty() {
326            "unknown worker-proxy error"
327        } else {
328            status.message()
329        };
330        match status.code() {
331            Code::NotFound => SailError::NotFound {
332                message: detail.to_string(),
333            },
334            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
335                message: detail.to_string(),
336            },
337            code => SailError::Execution {
338                code: code.into(),
339                detail: detail.to_string(),
340            },
341        }
342    }
343
344    /// Classify a file-RPC gRPC status. Files have their own taxonomy: a missing
345    /// path is [`SailError::FileNotFound`] (not the generic NotFound), and a
346    /// rejected argument is [`SailError::InvalidArgument`].
347    pub fn from_file_rpc_status(status: &Status) -> SailError {
348        if let Some(err) = transport_failure(status) {
349            return err;
350        }
351        let detail = if status.message().is_empty() {
352            "unknown sailbox file error"
353        } else {
354            status.message()
355        };
356        match status.code() {
357            Code::NotFound => SailError::FileNotFound {
358                message: detail.to_string(),
359            },
360            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
361                message: detail.to_string(),
362            },
363            Code::InvalidArgument => SailError::InvalidArgument {
364                message: detail.to_string(),
365            },
366            code => SailError::Execution {
367                code: code.into(),
368                detail: detail.to_string(),
369            },
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn not_found_without_exec_request_is_terminated() {
380        let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
381        match err {
382            SailError::Terminated { message } => {
383                assert!(message.contains("no longer running"));
384            }
385            other => panic!("expected Terminated, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn not_found_with_exec_request_is_request_not_found() {
391        let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
392        assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
393    }
394
395    #[test]
396    fn other_codes_carry_structured_grpc_code() {
397        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
398        match err {
399            SailError::Execution { code, detail } => {
400                assert_eq!(code, GrpcCode::Unavailable);
401                assert_eq!(detail, "upstream draining");
402            }
403            other => panic!("expected Execution, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn empty_detail_uses_default() {
409        let err = SailError::from_exec_status(&Status::internal(""));
410        match err {
411            SailError::Execution { code, detail } => {
412                assert_eq!(code, GrpcCode::Internal);
413                assert_eq!(detail, "unknown sailbox exec error");
414            }
415            other => panic!("expected Execution, got {other:?}"),
416        }
417    }
418
419    #[test]
420    fn client_transport_failure_maps_to_transport() {
421        // A failed connect surfaces as a status carrying a transport source,
422        // unlike a server-sent status decoded from response trailers.
423        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
424        let status = Status::from_error(Box::new(io));
425        match SailError::from_rpc_status(&status) {
426            SailError::Transport { kind, source, .. } => {
427                assert_eq!(kind, TransportKind::Connection);
428                // The originating status is retained as the structured source.
429                assert!(source.is_some());
430            }
431            other => panic!("expected Transport, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn server_sent_unavailable_stays_in_status_taxonomy() {
437        // No source => a real server status, not a client transport failure.
438        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
439        assert!(matches!(err, SailError::Execution { .. }));
440    }
441
442    #[test]
443    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
444        use assert_matches::assert_matches;
445        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
446        // argument has no dedicated variant and stays Execution.
447        assert_matches!(
448            SailError::from_rpc_status(&Status::not_found("x")),
449            SailError::NotFound { .. }
450        );
451        assert_matches!(
452            SailError::from_rpc_status(&Status::permission_denied("x")),
453            SailError::PermissionDenied { .. }
454        );
455        assert_matches!(
456            SailError::from_rpc_status(&Status::unauthenticated("x")),
457            SailError::PermissionDenied { .. }
458        );
459        assert_matches!(
460            SailError::from_rpc_status(&Status::invalid_argument("x")),
461            SailError::Execution {
462                code: GrpcCode::InvalidArgument,
463                ..
464            }
465        );
466        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
467        // the generic NotFound) and a rejected argument is InvalidArgument.
468        assert_matches!(
469            SailError::from_file_rpc_status(&Status::not_found("x")),
470            SailError::FileNotFound { .. }
471        );
472        assert_matches!(
473            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
474            SailError::InvalidArgument { .. }
475        );
476        assert_matches!(
477            SailError::from_file_rpc_status(&Status::permission_denied("x")),
478            SailError::PermissionDenied { .. }
479        );
480        assert_matches!(
481            SailError::from_file_rpc_status(&Status::internal("x")),
482            SailError::Execution {
483                code: GrpcCode::Internal,
484                ..
485            }
486        );
487    }
488
489    #[test]
490    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
491        // The lowercase ids are a cross-binding contract, so cover every code
492        // exhaustively: a typo or missed arm in either mapping fails here.
493        let table = [
494            (Code::Ok, "ok"),
495            (Code::Cancelled, "cancelled"),
496            (Code::Unknown, "unknown"),
497            (Code::InvalidArgument, "invalid_argument"),
498            (Code::DeadlineExceeded, "deadline_exceeded"),
499            (Code::NotFound, "not_found"),
500            (Code::AlreadyExists, "already_exists"),
501            (Code::PermissionDenied, "permission_denied"),
502            (Code::ResourceExhausted, "resource_exhausted"),
503            (Code::FailedPrecondition, "failed_precondition"),
504            (Code::Aborted, "aborted"),
505            (Code::OutOfRange, "out_of_range"),
506            (Code::Unimplemented, "unimplemented"),
507            (Code::Internal, "internal"),
508            (Code::Unavailable, "unavailable"),
509            (Code::DataLoss, "data_loss"),
510            (Code::Unauthenticated, "unauthenticated"),
511        ];
512        for (code, expected) in table {
513            assert_eq!(GrpcCode::from(code).as_str(), expected);
514        }
515    }
516}