1use std::error::Error as StdError;
11use std::sync::Arc;
12
13use thiserror::Error;
14use tonic::{Code, Status};
15
16pub type Result<T, E = SailError> = std::result::Result<T, E>;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum RpcStatus {
24 Ok,
26 Cancelled,
28 Unknown,
30 InvalidArgument,
32 DeadlineExceeded,
34 NotFound,
36 AlreadyExists,
38 PermissionDenied,
40 ResourceExhausted,
42 FailedPrecondition,
44 Aborted,
46 OutOfRange,
48 Unimplemented,
50 Internal,
52 Unavailable,
54 DataLoss,
56 Unauthenticated,
58}
59
60impl RpcStatus {
61 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[non_exhaustive]
121pub enum TransportKind {
122 Timeout,
124 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#[derive(Debug, Error)]
142#[non_exhaustive]
143pub enum SailError {
144 #[error("{message}")]
146 Config {
147 message: String,
149 },
150 #[error("{message}")]
152 Internal {
153 message: String,
155 },
156 #[error("{kind} error: {message}")]
159 Transport {
160 kind: TransportKind,
162 message: String,
164 #[source]
167 source: Option<Box<dyn StdError + Send + Sync + 'static>>,
168 },
169 #[error("{message}")]
171 Creation {
172 message: String,
174 status: u16,
176 body: serde_json::Value,
178 },
179 #[error("{message}")]
181 NotFound {
182 message: String,
184 },
185 #[error("{message}")]
187 PermissionDenied {
188 message: String,
190 },
191 #[error("{message}")]
193 FileNotFound {
194 message: String,
196 },
197 #[error("{message}")]
199 InvalidArgument {
200 message: String,
202 },
203 #[error("image build failed: {message}")]
205 ImageBuild {
206 message: String,
208 },
209 #[error("{message}")]
211 Api {
212 status: u16,
214 message: String,
216 body: serde_json::Value,
218 },
219 #[error("{message}")]
221 ExecRequestNotFound {
222 message: String,
224 },
225 #[error("{message}")]
227 Terminated {
228 message: String,
230 },
231 #[error("{message}")]
233 HostLost {
234 message: String,
236 },
237 #[error("{message}")]
239 BrokenPipe {
240 message: String,
242 },
243 #[error("{code}: {detail}")]
245 Execution {
246 code: RpcStatus,
248 detail: String,
250 },
251}
252
253#[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
271fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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(©).expect("copy must keep a source link");
687 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 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 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}