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). A credential failure maps to
288    /// [`SailError::PermissionDenied`], matching the listener/file mappers.
289    /// Everything else is a generic [`SailError::Execution`] carrying the
290    /// structured code; bindings format their own message from `code` + `detail`.
291    pub fn from_exec_status(status: &Status) -> SailError {
292        if let Some(err) = transport_failure(status) {
293            return err;
294        }
295        let detail = if status.message().is_empty() {
296            "unknown sailbox exec error"
297        } else {
298            status.message()
299        };
300        if status.code() == Code::NotFound {
301            if detail.contains("exec request") {
302                return SailError::ExecRequestNotFound {
303                    message: detail.to_string(),
304                };
305            }
306            return SailError::Terminated {
307                message: format!(
308                    "{detail}; this is likely because this Sailbox is no longer running"
309                ),
310            };
311        }
312        if matches!(
313            status.code(),
314            Code::PermissionDenied | Code::Unauthenticated
315        ) {
316            return SailError::PermissionDenied {
317                message: detail.to_string(),
318            };
319        }
320        SailError::Execution {
321            code: status.code().into(),
322            detail: detail.to_string(),
323        }
324    }
325
326    /// Classify a general (non-exec) worker-proxy gRPC status (listeners,
327    /// files). NOT_FOUND maps to [`SailError::NotFound`], a credential failure
328    /// to [`SailError::PermissionDenied`], and everything else to
329    /// [`SailError::Execution`] carrying the structured code.
330    pub fn from_rpc_status(status: &Status) -> SailError {
331        if let Some(err) = transport_failure(status) {
332            return err;
333        }
334        let detail = if status.message().is_empty() {
335            "unknown worker-proxy error"
336        } else {
337            status.message()
338        };
339        match status.code() {
340            Code::NotFound => SailError::NotFound {
341                message: detail.to_string(),
342            },
343            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
344                message: detail.to_string(),
345            },
346            code => SailError::Execution {
347                code: code.into(),
348                detail: detail.to_string(),
349            },
350        }
351    }
352
353    /// Classify a file-RPC gRPC status. Files have their own taxonomy: a missing
354    /// path is [`SailError::FileNotFound`] (not the generic NotFound), and a
355    /// rejected argument is [`SailError::InvalidArgument`].
356    pub fn from_file_rpc_status(status: &Status) -> SailError {
357        if let Some(err) = transport_failure(status) {
358            return err;
359        }
360        let detail = if status.message().is_empty() {
361            "unknown sailbox file error"
362        } else {
363            status.message()
364        };
365        match status.code() {
366            Code::NotFound => SailError::FileNotFound {
367                message: detail.to_string(),
368            },
369            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
370                message: detail.to_string(),
371            },
372            Code::InvalidArgument => SailError::InvalidArgument {
373                message: detail.to_string(),
374            },
375            code => SailError::Execution {
376                code: code.into(),
377                detail: detail.to_string(),
378            },
379        }
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn not_found_without_exec_request_is_terminated() {
389        let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
390        match err {
391            SailError::Terminated { message } => {
392                assert!(message.contains("no longer running"));
393            }
394            other => panic!("expected Terminated, got {other:?}"),
395        }
396    }
397
398    #[test]
399    fn not_found_with_exec_request_is_request_not_found() {
400        let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
401        assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
402    }
403
404    #[test]
405    fn exec_auth_failure_is_permission_denied() {
406        // exec/wait/cancel auth failures map to PermissionDenied, matching the
407        // listener/file mappers, not the generic Execution catch-all.
408        for status in [
409            Status::unauthenticated("invalid API key"),
410            Status::permission_denied("sailbox owned by another org"),
411        ] {
412            let err = SailError::from_exec_status(&status);
413            assert!(
414                matches!(err, SailError::PermissionDenied { .. }),
415                "got {err:?}"
416            );
417        }
418    }
419
420    #[test]
421    fn other_codes_carry_structured_grpc_code() {
422        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
423        match err {
424            SailError::Execution { code, detail } => {
425                assert_eq!(code, GrpcCode::Unavailable);
426                assert_eq!(detail, "upstream draining");
427            }
428            other => panic!("expected Execution, got {other:?}"),
429        }
430    }
431
432    #[test]
433    fn empty_detail_uses_default() {
434        let err = SailError::from_exec_status(&Status::internal(""));
435        match err {
436            SailError::Execution { code, detail } => {
437                assert_eq!(code, GrpcCode::Internal);
438                assert_eq!(detail, "unknown sailbox exec error");
439            }
440            other => panic!("expected Execution, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn client_transport_failure_maps_to_transport() {
446        // A failed connect surfaces as a status carrying a transport source,
447        // unlike a server-sent status decoded from response trailers.
448        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
449        let status = Status::from_error(Box::new(io));
450        match SailError::from_rpc_status(&status) {
451            SailError::Transport { kind, source, .. } => {
452                assert_eq!(kind, TransportKind::Connection);
453                // The originating status is retained as the structured source.
454                assert!(source.is_some());
455            }
456            other => panic!("expected Transport, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn server_sent_unavailable_stays_in_status_taxonomy() {
462        // No source => a real server status, not a client transport failure.
463        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
464        assert!(matches!(err, SailError::Execution { .. }));
465    }
466
467    #[test]
468    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
469        use assert_matches::assert_matches;
470        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
471        // argument has no dedicated variant and stays Execution.
472        assert_matches!(
473            SailError::from_rpc_status(&Status::not_found("x")),
474            SailError::NotFound { .. }
475        );
476        assert_matches!(
477            SailError::from_rpc_status(&Status::permission_denied("x")),
478            SailError::PermissionDenied { .. }
479        );
480        assert_matches!(
481            SailError::from_rpc_status(&Status::unauthenticated("x")),
482            SailError::PermissionDenied { .. }
483        );
484        assert_matches!(
485            SailError::from_rpc_status(&Status::invalid_argument("x")),
486            SailError::Execution {
487                code: GrpcCode::InvalidArgument,
488                ..
489            }
490        );
491        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
492        // the generic NotFound) and a rejected argument is InvalidArgument.
493        assert_matches!(
494            SailError::from_file_rpc_status(&Status::not_found("x")),
495            SailError::FileNotFound { .. }
496        );
497        assert_matches!(
498            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
499            SailError::InvalidArgument { .. }
500        );
501        assert_matches!(
502            SailError::from_file_rpc_status(&Status::permission_denied("x")),
503            SailError::PermissionDenied { .. }
504        );
505        assert_matches!(
506            SailError::from_file_rpc_status(&Status::internal("x")),
507            SailError::Execution {
508                code: GrpcCode::Internal,
509                ..
510            }
511        );
512    }
513
514    #[test]
515    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
516        // The lowercase ids are a cross-binding contract, so cover every code
517        // exhaustively: a typo or missed arm in either mapping fails here.
518        let table = [
519            (Code::Ok, "ok"),
520            (Code::Cancelled, "cancelled"),
521            (Code::Unknown, "unknown"),
522            (Code::InvalidArgument, "invalid_argument"),
523            (Code::DeadlineExceeded, "deadline_exceeded"),
524            (Code::NotFound, "not_found"),
525            (Code::AlreadyExists, "already_exists"),
526            (Code::PermissionDenied, "permission_denied"),
527            (Code::ResourceExhausted, "resource_exhausted"),
528            (Code::FailedPrecondition, "failed_precondition"),
529            (Code::Aborted, "aborted"),
530            (Code::OutOfRange, "out_of_range"),
531            (Code::Unimplemented, "unimplemented"),
532            (Code::Internal, "internal"),
533            (Code::Unavailable, "unavailable"),
534            (Code::DataLoss, "data_loss"),
535            (Code::Unauthenticated, "unauthenticated"),
536        ];
537        for (code, expected) in table {
538            assert_eq!(GrpcCode::from(code).as_str(), expected);
539        }
540    }
541}