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, the TypeScript SDK to its `SailError` 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    /// A custom image build failed (the imagebuilder reported `failed`).
199    #[error("image build failed: {message}")]
200    ImageBuild {
201        /// Human-readable build failure detail.
202        message: String,
203    },
204    /// Any other non-2xx API response.
205    #[error("{message}")]
206    Api {
207        /// HTTP status code returned by the API.
208        status: u16,
209        /// Human-readable description of the API failure.
210        message: String,
211        /// Parsed response body from the failed API request.
212        body: serde_json::Value,
213    },
214    /// A wait referenced an unknown exec request.
215    #[error("{message}")]
216    ExecRequestNotFound {
217        /// Human-readable description of the unknown exec request.
218        message: String,
219    },
220    /// The sailbox no longer exists on the worker.
221    #[error("{message}")]
222    Terminated {
223        /// Human-readable description noting the sailbox is gone.
224        message: String,
225    },
226    /// The worker hosting the sailbox was lost; output is unrecoverable.
227    #[error("{message}")]
228    WorkerLost {
229        /// Human-readable description of the worker loss.
230        message: String,
231    },
232    /// A stdin write hit a command that already finished or closed its stdin.
233    #[error("{message}")]
234    BrokenPipe {
235        /// Human-readable description of the broken-pipe condition.
236        message: String,
237    },
238    /// Any other exec-RPC failure, classified by gRPC code.
239    #[error("{code}: {detail}")]
240    Execution {
241        /// The gRPC status code that classifies the failure.
242        code: GrpcCode,
243        /// Human-readable detail describing the failure.
244        detail: String,
245    },
246}
247
248/// Detect a client-side transport failure that tonic surfaces as a gRPC
249/// `Status`. A failed connect or transport timeout carries a populated
250/// `source()`, whereas a status decoded from server response trailers does
251/// not. We use that to keep true transport failures (which bindings map to
252/// their own connection/timeout error types) out of the server-status
253/// taxonomy, instead of misreporting them as an `Execution` error.
254fn transport_failure(status: &Status) -> Option<SailError> {
255    status.source()?;
256    let message = status.message().to_string();
257    let lower = message.to_lowercase();
258    let kind = if status.code() == Code::DeadlineExceeded
259        || lower.contains("timed out")
260        || lower.contains("timeout")
261    {
262        TransportKind::Timeout
263    } else {
264        TransportKind::Connection
265    };
266    Some(SailError::Transport {
267        kind,
268        message,
269        source: Some(Box::new(status.clone())),
270    })
271}
272
273impl SailError {
274    /// Classify an exec-context gRPC status into the canonical taxonomy.
275    ///
276    /// NOT_FOUND splits into [`SailError::ExecRequestNotFound`] (the wait
277    /// referenced an unknown exec request) versus [`SailError::Terminated`]
278    /// (the sailbox itself is gone). A credential failure maps to
279    /// [`SailError::PermissionDenied`], matching the listener/file mappers.
280    /// Everything else is a generic [`SailError::Execution`] carrying the
281    /// structured code; bindings format their own message from `code` + `detail`.
282    pub fn from_exec_status(status: &Status) -> SailError {
283        if let Some(err) = transport_failure(status) {
284            return err;
285        }
286        let detail = if status.message().is_empty() {
287            "unknown sailbox exec error"
288        } else {
289            status.message()
290        };
291        if status.code() == Code::NotFound {
292            if detail.contains("exec request") {
293                return SailError::ExecRequestNotFound {
294                    message: detail.to_string(),
295                };
296            }
297            return SailError::Terminated {
298                message: format!(
299                    "{detail}; this is likely because this Sailbox is no longer running"
300                ),
301            };
302        }
303        if matches!(
304            status.code(),
305            Code::PermissionDenied | Code::Unauthenticated
306        ) {
307            return SailError::PermissionDenied {
308                message: detail.to_string(),
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 exec_auth_failure_is_permission_denied() {
397        // exec/wait/cancel auth failures map to PermissionDenied, matching the
398        // listener/file mappers, not the generic Execution catch-all.
399        for status in [
400            Status::unauthenticated("invalid API key"),
401            Status::permission_denied("sailbox owned by another org"),
402        ] {
403            let err = SailError::from_exec_status(&status);
404            assert!(
405                matches!(err, SailError::PermissionDenied { .. }),
406                "got {err:?}"
407            );
408        }
409    }
410
411    #[test]
412    fn other_codes_carry_structured_grpc_code() {
413        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
414        match err {
415            SailError::Execution { code, detail } => {
416                assert_eq!(code, GrpcCode::Unavailable);
417                assert_eq!(detail, "upstream draining");
418            }
419            other => panic!("expected Execution, got {other:?}"),
420        }
421    }
422
423    #[test]
424    fn empty_detail_uses_default() {
425        let err = SailError::from_exec_status(&Status::internal(""));
426        match err {
427            SailError::Execution { code, detail } => {
428                assert_eq!(code, GrpcCode::Internal);
429                assert_eq!(detail, "unknown sailbox exec error");
430            }
431            other => panic!("expected Execution, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn client_transport_failure_maps_to_transport() {
437        // A failed connect surfaces as a status carrying a transport source,
438        // unlike a server-sent status decoded from response trailers.
439        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
440        let status = Status::from_error(Box::new(io));
441        match SailError::from_rpc_status(&status) {
442            SailError::Transport { kind, source, .. } => {
443                assert_eq!(kind, TransportKind::Connection);
444                // The originating status is retained as the structured source.
445                assert!(source.is_some());
446            }
447            other => panic!("expected Transport, got {other:?}"),
448        }
449    }
450
451    #[test]
452    fn server_sent_unavailable_stays_in_status_taxonomy() {
453        // No source => a real server status, not a client transport failure.
454        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
455        assert!(matches!(err, SailError::Execution { .. }));
456    }
457
458    #[test]
459    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
460        use assert_matches::assert_matches;
461        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
462        // argument has no dedicated variant and stays Execution.
463        assert_matches!(
464            SailError::from_rpc_status(&Status::not_found("x")),
465            SailError::NotFound { .. }
466        );
467        assert_matches!(
468            SailError::from_rpc_status(&Status::permission_denied("x")),
469            SailError::PermissionDenied { .. }
470        );
471        assert_matches!(
472            SailError::from_rpc_status(&Status::unauthenticated("x")),
473            SailError::PermissionDenied { .. }
474        );
475        assert_matches!(
476            SailError::from_rpc_status(&Status::invalid_argument("x")),
477            SailError::Execution {
478                code: GrpcCode::InvalidArgument,
479                ..
480            }
481        );
482        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
483        // the generic NotFound) and a rejected argument is InvalidArgument.
484        assert_matches!(
485            SailError::from_file_rpc_status(&Status::not_found("x")),
486            SailError::FileNotFound { .. }
487        );
488        assert_matches!(
489            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
490            SailError::InvalidArgument { .. }
491        );
492        assert_matches!(
493            SailError::from_file_rpc_status(&Status::permission_denied("x")),
494            SailError::PermissionDenied { .. }
495        );
496        assert_matches!(
497            SailError::from_file_rpc_status(&Status::internal("x")),
498            SailError::Execution {
499                code: GrpcCode::Internal,
500                ..
501            }
502        );
503    }
504
505    #[test]
506    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
507        // The lowercase ids are a cross-binding contract, so cover every code
508        // exhaustively: a typo or missed arm in either mapping fails here.
509        let table = [
510            (Code::Ok, "ok"),
511            (Code::Cancelled, "cancelled"),
512            (Code::Unknown, "unknown"),
513            (Code::InvalidArgument, "invalid_argument"),
514            (Code::DeadlineExceeded, "deadline_exceeded"),
515            (Code::NotFound, "not_found"),
516            (Code::AlreadyExists, "already_exists"),
517            (Code::PermissionDenied, "permission_denied"),
518            (Code::ResourceExhausted, "resource_exhausted"),
519            (Code::FailedPrecondition, "failed_precondition"),
520            (Code::Aborted, "aborted"),
521            (Code::OutOfRange, "out_of_range"),
522            (Code::Unimplemented, "unimplemented"),
523            (Code::Internal, "internal"),
524            (Code::Unavailable, "unavailable"),
525            (Code::DataLoss, "data_loss"),
526            (Code::Unauthenticated, "unauthenticated"),
527        ];
528        for (code, expected) in table {
529            assert_eq!(GrpcCode::from(code).as_str(), expected);
530        }
531    }
532}