1use std::error::Error as StdError;
11
12use thiserror::Error;
13use tonic::{Code, Status};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum GrpcCode {
19 Ok,
21 Cancelled,
23 Unknown,
25 InvalidArgument,
27 DeadlineExceeded,
29 NotFound,
31 AlreadyExists,
33 PermissionDenied,
35 ResourceExhausted,
37 FailedPrecondition,
39 Aborted,
41 OutOfRange,
43 Unimplemented,
45 Internal,
47 Unavailable,
49 DataLoss,
51 Unauthenticated,
53}
54
55impl GrpcCode {
56 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum TransportKind {
117 Timeout,
119 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#[derive(Debug, Error)]
137#[non_exhaustive]
138pub enum SailError {
139 #[error("{message}")]
141 Config {
142 message: String,
144 },
145 #[error("{message}")]
147 Internal {
148 message: String,
150 },
151 #[error("{kind} error: {message}")]
154 Transport {
155 kind: TransportKind,
157 message: String,
159 #[source]
162 source: Option<Box<dyn StdError + Send + Sync + 'static>>,
163 },
164 #[error("{message}")]
166 Creation {
167 message: String,
169 status: u16,
171 body: serde_json::Value,
173 },
174 #[error("{message}")]
176 NotFound {
177 message: String,
179 },
180 #[error("{message}")]
182 PermissionDenied {
183 message: String,
185 },
186 #[error("{message}")]
188 FileNotFound {
189 message: String,
191 },
192 #[error("{message}")]
194 InvalidArgument {
195 message: String,
197 },
198 #[error("{message}")]
200 Api {
201 status: u16,
203 message: String,
205 body: serde_json::Value,
207 },
208 #[error("{message}")]
210 ExecRequestNotFound {
211 message: String,
213 },
214 #[error("{message}")]
216 Terminated {
217 message: String,
219 },
220 #[error("{message}")]
222 WorkerLost {
223 message: String,
225 },
226 #[error("{message}")]
229 ExecInterrupted {
230 retryable: bool,
232 message: String,
234 },
235 #[error("{message}")]
237 ExecCanceled {
238 message: String,
240 },
241 #[error("{message}")]
243 BrokenPipe {
244 message: String,
246 },
247 #[error("{code}: {detail}")]
249 Execution {
250 code: GrpcCode,
252 detail: String,
254 },
255}
256
257fn 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 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 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 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 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 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 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 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 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 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 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}