1use std::error::Error as StdError;
11use std::sync::Arc;
12
13use thiserror::Error;
14use tonic::{Code, Status};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum GrpcCode {
20 Ok,
22 Cancelled,
24 Unknown,
26 InvalidArgument,
28 DeadlineExceeded,
30 NotFound,
32 AlreadyExists,
34 PermissionDenied,
36 ResourceExhausted,
38 FailedPrecondition,
40 Aborted,
42 OutOfRange,
44 Unimplemented,
46 Internal,
48 Unavailable,
50 DataLoss,
52 Unauthenticated,
54}
55
56impl GrpcCode {
57 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum TransportKind {
118 Timeout,
120 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#[derive(Debug, Error)]
138#[non_exhaustive]
139pub enum SailError {
140 #[error("{message}")]
142 Config {
143 message: String,
145 },
146 #[error("{message}")]
148 Internal {
149 message: String,
151 },
152 #[error("{kind} error: {message}")]
155 Transport {
156 kind: TransportKind,
158 message: String,
160 #[source]
163 source: Option<Box<dyn StdError + Send + Sync + 'static>>,
164 },
165 #[error("{message}")]
167 Creation {
168 message: String,
170 status: u16,
172 body: serde_json::Value,
174 },
175 #[error("{message}")]
177 NotFound {
178 message: String,
180 },
181 #[error("{message}")]
183 PermissionDenied {
184 message: String,
186 },
187 #[error("{message}")]
189 FileNotFound {
190 message: String,
192 },
193 #[error("{message}")]
195 InvalidArgument {
196 message: String,
198 },
199 #[error("image build failed: {message}")]
201 ImageBuild {
202 message: String,
204 },
205 #[error("{message}")]
207 Api {
208 status: u16,
210 message: String,
212 body: serde_json::Value,
214 },
215 #[error("{message}")]
217 ExecRequestNotFound {
218 message: String,
220 },
221 #[error("{message}")]
223 Terminated {
224 message: String,
226 },
227 #[error("{message}")]
229 WorkerLost {
230 message: String,
232 },
233 #[error("{message}")]
235 BrokenPipe {
236 message: String,
238 },
239 #[error("{code}: {detail}")]
241 Execution {
242 code: GrpcCode,
244 detail: String,
246 },
247}
248
249#[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
267fn 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 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 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 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 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 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 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 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 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 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 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 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 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(©).expect("copy must keep a source link");
656 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 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}