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