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;
11use std::sync::Arc;
12
13use thiserror::Error;
14use tonic::{Code, Status};
15
16/// gRPC status code, decoupled from any one client library's spelling.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum GrpcCode {
20    /// The operation completed successfully.
21    Ok,
22    /// The operation was canceled, typically by the caller.
23    Cancelled,
24    /// An unknown error, often a status with no mapped code.
25    Unknown,
26    /// The client specified an invalid argument.
27    InvalidArgument,
28    /// The deadline expired before the operation could complete.
29    DeadlineExceeded,
30    /// The requested entity was not found.
31    NotFound,
32    /// An attempt to create an entity that already exists.
33    AlreadyExists,
34    /// The caller lacks permission to execute the operation.
35    PermissionDenied,
36    /// A resource (quota, capacity, file space) has been exhausted.
37    ResourceExhausted,
38    /// The system is not in a state required for the operation.
39    FailedPrecondition,
40    /// The operation was aborted, often due to a concurrency conflict.
41    Aborted,
42    /// The operation was attempted past the valid range.
43    OutOfRange,
44    /// The operation is not implemented or not supported.
45    Unimplemented,
46    /// An internal error: an invariant expected by the system was broken.
47    Internal,
48    /// The service is currently unavailable, typically transient.
49    Unavailable,
50    /// Unrecoverable data loss or corruption.
51    DataLoss,
52    /// The request lacks valid authentication credentials.
53    Unauthenticated,
54}
55
56impl GrpcCode {
57    /// A stable, lowercase identifier for the code. Bindings that need a
58    /// different spelling (e.g. a language's own convention) map from the
59    /// `GrpcCode` value rather than parsing this string.
60    pub fn as_str(self) -> &'static str {
61        match self {
62            GrpcCode::Ok => "ok",
63            GrpcCode::Cancelled => "cancelled",
64            GrpcCode::Unknown => "unknown",
65            GrpcCode::InvalidArgument => "invalid_argument",
66            GrpcCode::DeadlineExceeded => "deadline_exceeded",
67            GrpcCode::NotFound => "not_found",
68            GrpcCode::AlreadyExists => "already_exists",
69            GrpcCode::PermissionDenied => "permission_denied",
70            GrpcCode::ResourceExhausted => "resource_exhausted",
71            GrpcCode::FailedPrecondition => "failed_precondition",
72            GrpcCode::Aborted => "aborted",
73            GrpcCode::OutOfRange => "out_of_range",
74            GrpcCode::Unimplemented => "unimplemented",
75            GrpcCode::Internal => "internal",
76            GrpcCode::Unavailable => "unavailable",
77            GrpcCode::DataLoss => "data_loss",
78            GrpcCode::Unauthenticated => "unauthenticated",
79        }
80    }
81}
82
83impl std::fmt::Display for GrpcCode {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(self.as_str())
86    }
87}
88
89impl From<Code> for GrpcCode {
90    fn from(code: Code) -> GrpcCode {
91        match code {
92            Code::Ok => GrpcCode::Ok,
93            Code::Cancelled => GrpcCode::Cancelled,
94            Code::Unknown => GrpcCode::Unknown,
95            Code::InvalidArgument => GrpcCode::InvalidArgument,
96            Code::DeadlineExceeded => GrpcCode::DeadlineExceeded,
97            Code::NotFound => GrpcCode::NotFound,
98            Code::AlreadyExists => GrpcCode::AlreadyExists,
99            Code::PermissionDenied => GrpcCode::PermissionDenied,
100            Code::ResourceExhausted => GrpcCode::ResourceExhausted,
101            Code::FailedPrecondition => GrpcCode::FailedPrecondition,
102            Code::Aborted => GrpcCode::Aborted,
103            Code::OutOfRange => GrpcCode::OutOfRange,
104            Code::Unimplemented => GrpcCode::Unimplemented,
105            Code::Internal => GrpcCode::Internal,
106            Code::Unavailable => GrpcCode::Unavailable,
107            Code::DataLoss => GrpcCode::DataLoss,
108            Code::Unauthenticated => GrpcCode::Unauthenticated,
109        }
110    }
111}
112
113/// How a transport attempt failed, independent of any language's exception
114/// names. Bindings map these onto their own transport-error types.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum TransportKind {
118    /// The attempt exceeded its deadline before a response arrived.
119    Timeout,
120    /// The transport could not establish or maintain a connection.
121    Connection,
122}
123
124impl std::fmt::Display for TransportKind {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(match self {
127            TransportKind::Timeout => "timeout",
128            TransportKind::Connection => "connection",
129        })
130    }
131}
132
133/// The canonical Sail error. Variants carry structured fields so bindings can
134/// populate their own exception attributes without parsing strings.
135///
136/// `Display` is for Rust logs only; bindings format their own user-facing text.
137#[derive(Debug, Error)]
138#[non_exhaustive]
139pub enum SailError {
140    /// Configuration/usage error (e.g. a missing API key).
141    #[error("{message}")]
142    Config {
143        /// Human-readable description of the misconfiguration.
144        message: String,
145    },
146    /// Unexpected internal failure (client build, TLS config, invariant).
147    #[error("{message}")]
148    Internal {
149        /// Human-readable description of the internal failure.
150        message: String,
151    },
152    /// HTTP/gRPC dial-time transport failure. `source` is the underlying
153    /// transport error (e.g. the originating `tonic::Status`) for Rust callers.
154    #[error("{kind} error: {message}")]
155    Transport {
156        /// Whether the transport failed by timeout or by connection error.
157        kind: TransportKind,
158        /// Human-readable description of the transport failure.
159        message: String,
160        /// The underlying transport error, retained for Rust callers; `None`
161        /// when no originating error was available.
162        #[source]
163        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
164    },
165    /// Sailbox creation failed (non-2xx on create).
166    #[error("{message}")]
167    Creation {
168        /// Human-readable description of the creation failure.
169        message: String,
170        /// HTTP status code returned by the create request.
171        status: u16,
172        /// Parsed response body from the failed create request.
173        body: serde_json::Value,
174    },
175    /// A resource was not found (404 on get / unknown id across orgs).
176    #[error("{message}")]
177    NotFound {
178        /// Human-readable description of the missing resource.
179        message: String,
180    },
181    /// A credential problem: missing/invalid API key or insufficient scope.
182    #[error("{message}")]
183    PermissionDenied {
184        /// Human-readable description of the credential or scope problem.
185        message: String,
186    },
187    /// A file operation referenced a path that does not exist.
188    #[error("{message}")]
189    FileNotFound {
190        /// Human-readable description of the missing path.
191        message: String,
192    },
193    /// A request argument was rejected as invalid.
194    #[error("{message}")]
195    InvalidArgument {
196        /// Human-readable description of why the argument was rejected.
197        message: String,
198    },
199    /// A custom image build failed (the build reported `failed`).
200    #[error("image build failed: {message}")]
201    ImageBuild {
202        /// Human-readable build failure detail.
203        message: String,
204    },
205    /// Any other non-2xx API response.
206    #[error("{message}")]
207    Api {
208        /// HTTP status code returned by the API.
209        status: u16,
210        /// Human-readable description of the API failure.
211        message: String,
212        /// Parsed response body from the failed API request.
213        body: serde_json::Value,
214    },
215    /// A wait referenced an unknown exec request.
216    #[error("{message}")]
217    ExecRequestNotFound {
218        /// Human-readable description of the unknown exec request.
219        message: String,
220    },
221    /// The sailbox no longer exists on the worker.
222    #[error("{message}")]
223    Terminated {
224        /// Human-readable description noting the sailbox is gone.
225        message: String,
226    },
227    /// The worker hosting the sailbox was lost; output is unrecoverable.
228    #[error("{message}")]
229    WorkerLost {
230        /// Human-readable description of the worker loss.
231        message: String,
232    },
233    /// A stdin write hit a command that already finished or closed its stdin.
234    #[error("{message}")]
235    BrokenPipe {
236        /// Human-readable description of the broken-pipe condition.
237        message: String,
238    },
239    /// Any other exec-RPC failure, classified by gRPC code.
240    #[error("{code}: {detail}")]
241    Execution {
242        /// The gRPC status code that classifies the failure.
243        code: GrpcCode,
244        /// Human-readable detail describing the failure.
245        detail: String,
246    },
247}
248
249/// The `source` link carried by fan-out copies of a shared failure: it
250/// forwards to the shared original error's own source, so every concurrent
251/// waiter can walk the same chain the original owns.
252#[derive(Debug)]
253struct SharedSourceError(Arc<SailError>);
254
255impl std::fmt::Display for SharedSourceError {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        self.0.fmt(f)
258    }
259}
260
261impl StdError for SharedSourceError {
262    fn source(&self) -> Option<&(dyn StdError + 'static)> {
263        StdError::source(self.0.as_ref())
264    }
265}
266
267/// Detect a client-side transport failure that tonic surfaces as a gRPC
268/// `Status`. A failed connect or transport timeout carries a populated
269/// `source()`, whereas a status decoded from server response trailers does
270/// not. We use that to keep true transport failures (which bindings map to
271/// their own connection/timeout error types) out of the server-status
272/// taxonomy, instead of misreporting them as an `Execution` error.
273fn transport_failure(status: &Status) -> Option<SailError> {
274    status.source()?;
275    let message = status.message().to_string();
276    let lower = message.to_lowercase();
277    let kind = if status.code() == Code::DeadlineExceeded
278        || lower.contains("timed out")
279        || lower.contains("timeout")
280    {
281        TransportKind::Timeout
282    } else {
283        TransportKind::Connection
284    };
285    Some(SailError::Transport {
286        kind,
287        message,
288        source: Some(Box::new(status.clone())),
289    })
290}
291
292impl SailError {
293    /// A copy of a shared failure for one concurrent waiter (a sole waiter
294    /// takes the original via `Arc::try_unwrap` instead). Every classified
295    /// field is preserved, and a [`SailError::Transport`] copy keeps a
296    /// working `Error::source()` chain by pointing at the shared original,
297    /// which owns the boxed source that cannot itself be cloned.
298    pub(crate) fn fan_out(this: &Arc<SailError>) -> SailError {
299        let copy = this.clone_without_source();
300        match copy {
301            SailError::Transport {
302                kind,
303                message,
304                source: _,
305            } if StdError::source(this.as_ref()).is_some() => SailError::Transport {
306                kind,
307                message,
308                source: Some(Box::new(SharedSourceError(Arc::clone(this)))),
309            },
310            other => other,
311        }
312    }
313
314    /// A structural copy that drops the Rust-side source chain; the
315    /// [`SailError::fan_out`] wrapper restores it for Transport copies.
316    fn clone_without_source(&self) -> SailError {
317        match self {
318            SailError::Config { message } => SailError::Config {
319                message: message.clone(),
320            },
321            SailError::Internal { message } => SailError::Internal {
322                message: message.clone(),
323            },
324            SailError::Transport {
325                kind,
326                message,
327                source: _,
328            } => SailError::Transport {
329                kind: *kind,
330                message: message.clone(),
331                source: None,
332            },
333            SailError::Creation {
334                message,
335                status,
336                body,
337            } => SailError::Creation {
338                message: message.clone(),
339                status: *status,
340                body: body.clone(),
341            },
342            SailError::NotFound { message } => SailError::NotFound {
343                message: message.clone(),
344            },
345            SailError::PermissionDenied { message } => SailError::PermissionDenied {
346                message: message.clone(),
347            },
348            SailError::FileNotFound { message } => SailError::FileNotFound {
349                message: message.clone(),
350            },
351            SailError::InvalidArgument { message } => SailError::InvalidArgument {
352                message: message.clone(),
353            },
354            SailError::ImageBuild { message } => SailError::ImageBuild {
355                message: message.clone(),
356            },
357            SailError::Api {
358                status,
359                message,
360                body,
361            } => SailError::Api {
362                status: *status,
363                message: message.clone(),
364                body: body.clone(),
365            },
366            SailError::ExecRequestNotFound { message } => SailError::ExecRequestNotFound {
367                message: message.clone(),
368            },
369            SailError::Terminated { message } => SailError::Terminated {
370                message: message.clone(),
371            },
372            SailError::WorkerLost { message } => SailError::WorkerLost {
373                message: message.clone(),
374            },
375            SailError::BrokenPipe { message } => SailError::BrokenPipe {
376                message: message.clone(),
377            },
378            SailError::Execution { code, detail } => SailError::Execution {
379                code: *code,
380                detail: detail.clone(),
381            },
382        }
383    }
384
385    /// Classify an exec-context gRPC status into the canonical taxonomy.
386    ///
387    /// NOT_FOUND splits into [`SailError::ExecRequestNotFound`] (the wait
388    /// referenced an unknown exec request) versus [`SailError::Terminated`]
389    /// (the sailbox itself is gone). A credential failure maps to
390    /// [`SailError::PermissionDenied`], matching the listener/file mappers.
391    /// Everything else is a generic [`SailError::Execution`] carrying the
392    /// structured code; bindings format their own message from `code` + `detail`.
393    pub(crate) fn from_exec_status(status: &Status) -> SailError {
394        if let Some(err) = transport_failure(status) {
395            return err;
396        }
397        let detail = if status.message().is_empty() {
398            "unknown sailbox exec error"
399        } else {
400            status.message()
401        };
402        if status.code() == Code::NotFound {
403            if detail.contains("exec request") {
404                return SailError::ExecRequestNotFound {
405                    message: detail.to_string(),
406                };
407            }
408            return SailError::Terminated {
409                message: format!(
410                    "{detail}; this is likely because this Sailbox is no longer running"
411                ),
412            };
413        }
414        if matches!(
415            status.code(),
416            Code::PermissionDenied | Code::Unauthenticated
417        ) {
418            return SailError::PermissionDenied {
419                message: detail.to_string(),
420            };
421        }
422        SailError::Execution {
423            code: status.code().into(),
424            detail: detail.to_string(),
425        }
426    }
427
428    /// Classify a general (non-exec) worker-proxy gRPC status (listeners,
429    /// files). NOT_FOUND maps to [`SailError::NotFound`], a credential failure
430    /// to [`SailError::PermissionDenied`], and everything else to
431    /// [`SailError::Execution`] carrying the structured code.
432    pub(crate) fn from_rpc_status(status: &Status) -> SailError {
433        if let Some(err) = transport_failure(status) {
434            return err;
435        }
436        let detail = if status.message().is_empty() {
437            "unknown worker-proxy error"
438        } else {
439            status.message()
440        };
441        match status.code() {
442            Code::NotFound => SailError::NotFound {
443                message: detail.to_string(),
444            },
445            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
446                message: detail.to_string(),
447            },
448            code => SailError::Execution {
449                code: code.into(),
450                detail: detail.to_string(),
451            },
452        }
453    }
454
455    /// Classify a file-RPC gRPC status. Files have their own taxonomy: a missing
456    /// path is [`SailError::FileNotFound`] (not the generic NotFound), and a
457    /// rejected argument is [`SailError::InvalidArgument`].
458    pub(crate) fn from_file_rpc_status(status: &Status) -> SailError {
459        if let Some(err) = transport_failure(status) {
460            return err;
461        }
462        let detail = if status.message().is_empty() {
463            "unknown sailbox file error"
464        } else {
465            status.message()
466        };
467        match status.code() {
468            Code::NotFound => SailError::FileNotFound {
469                message: detail.to_string(),
470            },
471            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
472                message: detail.to_string(),
473            },
474            Code::InvalidArgument => SailError::InvalidArgument {
475                message: detail.to_string(),
476            },
477            code => SailError::Execution {
478                code: code.into(),
479                detail: detail.to_string(),
480            },
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn not_found_without_exec_request_is_terminated() {
491        let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
492        match err {
493            SailError::Terminated { message } => {
494                assert!(message.contains("no longer running"));
495            }
496            other => panic!("expected Terminated, got {other:?}"),
497        }
498    }
499
500    #[test]
501    fn not_found_with_exec_request_is_request_not_found() {
502        let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
503        assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
504    }
505
506    #[test]
507    fn exec_auth_failure_is_permission_denied() {
508        // exec/wait/cancel auth failures map to PermissionDenied, matching the
509        // listener/file mappers, not the generic Execution catch-all.
510        for status in [
511            Status::unauthenticated("invalid API key"),
512            Status::permission_denied("sailbox owned by another org"),
513        ] {
514            let err = SailError::from_exec_status(&status);
515            assert!(
516                matches!(err, SailError::PermissionDenied { .. }),
517                "got {err:?}"
518            );
519        }
520    }
521
522    #[test]
523    fn other_codes_carry_structured_grpc_code() {
524        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
525        match err {
526            SailError::Execution { code, detail } => {
527                assert_eq!(code, GrpcCode::Unavailable);
528                assert_eq!(detail, "upstream draining");
529            }
530            other => panic!("expected Execution, got {other:?}"),
531        }
532    }
533
534    #[test]
535    fn empty_detail_uses_default() {
536        let err = SailError::from_exec_status(&Status::internal(""));
537        match err {
538            SailError::Execution { code, detail } => {
539                assert_eq!(code, GrpcCode::Internal);
540                assert_eq!(detail, "unknown sailbox exec error");
541            }
542            other => panic!("expected Execution, got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn client_transport_failure_maps_to_transport() {
548        // A failed connect surfaces as a status carrying a transport source,
549        // unlike a server-sent status decoded from response trailers.
550        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
551        let status = Status::from_error(Box::new(io));
552        match SailError::from_rpc_status(&status) {
553            SailError::Transport { kind, source, .. } => {
554                assert_eq!(kind, TransportKind::Connection);
555                // The originating status is retained as the structured source.
556                assert!(source.is_some());
557            }
558            other => panic!("expected Transport, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn server_sent_unavailable_stays_in_status_taxonomy() {
564        // No source => a real server status, not a client transport failure.
565        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
566        assert!(matches!(err, SailError::Execution { .. }));
567    }
568
569    #[test]
570    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
571        use assert_matches::assert_matches;
572        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
573        // argument has no dedicated variant and stays Execution.
574        assert_matches!(
575            SailError::from_rpc_status(&Status::not_found("x")),
576            SailError::NotFound { .. }
577        );
578        assert_matches!(
579            SailError::from_rpc_status(&Status::permission_denied("x")),
580            SailError::PermissionDenied { .. }
581        );
582        assert_matches!(
583            SailError::from_rpc_status(&Status::unauthenticated("x")),
584            SailError::PermissionDenied { .. }
585        );
586        assert_matches!(
587            SailError::from_rpc_status(&Status::invalid_argument("x")),
588            SailError::Execution {
589                code: GrpcCode::InvalidArgument,
590                ..
591            }
592        );
593        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
594        // the generic NotFound) and a rejected argument is InvalidArgument.
595        assert_matches!(
596            SailError::from_file_rpc_status(&Status::not_found("x")),
597            SailError::FileNotFound { .. }
598        );
599        assert_matches!(
600            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
601            SailError::InvalidArgument { .. }
602        );
603        assert_matches!(
604            SailError::from_file_rpc_status(&Status::permission_denied("x")),
605            SailError::PermissionDenied { .. }
606        );
607        assert_matches!(
608            SailError::from_file_rpc_status(&Status::internal("x")),
609            SailError::Execution {
610                code: GrpcCode::Internal,
611                ..
612            }
613        );
614    }
615
616    #[test]
617    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
618        // The lowercase ids are a cross-binding contract, so cover every code
619        // exhaustively: a typo or missed arm in either mapping fails here.
620        let table = [
621            (Code::Ok, "ok"),
622            (Code::Cancelled, "cancelled"),
623            (Code::Unknown, "unknown"),
624            (Code::InvalidArgument, "invalid_argument"),
625            (Code::DeadlineExceeded, "deadline_exceeded"),
626            (Code::NotFound, "not_found"),
627            (Code::AlreadyExists, "already_exists"),
628            (Code::PermissionDenied, "permission_denied"),
629            (Code::ResourceExhausted, "resource_exhausted"),
630            (Code::FailedPrecondition, "failed_precondition"),
631            (Code::Aborted, "aborted"),
632            (Code::OutOfRange, "out_of_range"),
633            (Code::Unimplemented, "unimplemented"),
634            (Code::Internal, "internal"),
635            (Code::Unavailable, "unavailable"),
636            (Code::DataLoss, "data_loss"),
637            (Code::Unauthenticated, "unauthenticated"),
638        ];
639        for (code, expected) in table {
640            assert_eq!(GrpcCode::from(code).as_str(), expected);
641        }
642    }
643
644    #[test]
645    fn fan_out_copies_keep_the_source_chain() {
646        let original = Arc::new(SailError::Transport {
647            kind: TransportKind::Connection,
648            message: "tcp connect error".to_string(),
649            source: Some(Box::new(std::io::Error::new(
650                std::io::ErrorKind::ConnectionReset,
651                "connection reset by peer",
652            ))),
653        });
654        let copy = SailError::fan_out(&original);
655        let first = StdError::source(&copy).expect("copy must keep a source link");
656        // The link forwards to the shared original's own source.
657        let inner = first
658            .source()
659            .expect("link must forward to the boxed source");
660        assert!(inner.to_string().contains("connection reset by peer"));
661
662        // A shared original without a source stays sourceless rather than
663        // gaining an empty link.
664        let bare = Arc::new(SailError::Transport {
665            kind: TransportKind::Timeout,
666            message: "timed out".to_string(),
667            source: None,
668        });
669        assert!(StdError::source(&SailError::fan_out(&bare)).is_none());
670    }
671}