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("image build failed: {message}")]
200 ImageBuild {
201 message: String,
203 },
204 #[error("{message}")]
206 Api {
207 status: u16,
209 message: String,
211 body: serde_json::Value,
213 },
214 #[error("{message}")]
216 ExecRequestNotFound {
217 message: String,
219 },
220 #[error("{message}")]
222 Terminated {
223 message: String,
225 },
226 #[error("{message}")]
228 WorkerLost {
229 message: String,
231 },
232 #[error("{message}")]
234 BrokenPipe {
235 message: String,
237 },
238 #[error("{code}: {detail}")]
240 Execution {
241 code: GrpcCode,
243 detail: String,
245 },
246}
247
248fn transport_failure(status: &Status) -> Option<SailError> {
255 status.source()?;
256 let message = status.message().to_string();
257 let lower = message.to_lowercase();
258 let kind = if status.code() == Code::DeadlineExceeded
259 || lower.contains("timed out")
260 || lower.contains("timeout")
261 {
262 TransportKind::Timeout
263 } else {
264 TransportKind::Connection
265 };
266 Some(SailError::Transport {
267 kind,
268 message,
269 source: Some(Box::new(status.clone())),
270 })
271}
272
273impl SailError {
274 pub(crate) fn from_exec_status(status: &Status) -> SailError {
283 if let Some(err) = transport_failure(status) {
284 return err;
285 }
286 let detail = if status.message().is_empty() {
287 "unknown sailbox exec error"
288 } else {
289 status.message()
290 };
291 if status.code() == Code::NotFound {
292 if detail.contains("exec request") {
293 return SailError::ExecRequestNotFound {
294 message: detail.to_string(),
295 };
296 }
297 return SailError::Terminated {
298 message: format!(
299 "{detail}; this is likely because this Sailbox is no longer running"
300 ),
301 };
302 }
303 if matches!(
304 status.code(),
305 Code::PermissionDenied | Code::Unauthenticated
306 ) {
307 return SailError::PermissionDenied {
308 message: detail.to_string(),
309 };
310 }
311 SailError::Execution {
312 code: status.code().into(),
313 detail: detail.to_string(),
314 }
315 }
316
317 pub(crate) fn from_rpc_status(status: &Status) -> SailError {
322 if let Some(err) = transport_failure(status) {
323 return err;
324 }
325 let detail = if status.message().is_empty() {
326 "unknown worker-proxy error"
327 } else {
328 status.message()
329 };
330 match status.code() {
331 Code::NotFound => SailError::NotFound {
332 message: detail.to_string(),
333 },
334 Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
335 message: detail.to_string(),
336 },
337 code => SailError::Execution {
338 code: code.into(),
339 detail: detail.to_string(),
340 },
341 }
342 }
343
344 pub(crate) fn from_file_rpc_status(status: &Status) -> SailError {
348 if let Some(err) = transport_failure(status) {
349 return err;
350 }
351 let detail = if status.message().is_empty() {
352 "unknown sailbox file error"
353 } else {
354 status.message()
355 };
356 match status.code() {
357 Code::NotFound => SailError::FileNotFound {
358 message: detail.to_string(),
359 },
360 Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
361 message: detail.to_string(),
362 },
363 Code::InvalidArgument => SailError::InvalidArgument {
364 message: detail.to_string(),
365 },
366 code => SailError::Execution {
367 code: code.into(),
368 detail: detail.to_string(),
369 },
370 }
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn not_found_without_exec_request_is_terminated() {
380 let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
381 match err {
382 SailError::Terminated { message } => {
383 assert!(message.contains("no longer running"));
384 }
385 other => panic!("expected Terminated, got {other:?}"),
386 }
387 }
388
389 #[test]
390 fn not_found_with_exec_request_is_request_not_found() {
391 let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
392 assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
393 }
394
395 #[test]
396 fn exec_auth_failure_is_permission_denied() {
397 for status in [
400 Status::unauthenticated("invalid API key"),
401 Status::permission_denied("sailbox owned by another org"),
402 ] {
403 let err = SailError::from_exec_status(&status);
404 assert!(
405 matches!(err, SailError::PermissionDenied { .. }),
406 "got {err:?}"
407 );
408 }
409 }
410
411 #[test]
412 fn other_codes_carry_structured_grpc_code() {
413 let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
414 match err {
415 SailError::Execution { code, detail } => {
416 assert_eq!(code, GrpcCode::Unavailable);
417 assert_eq!(detail, "upstream draining");
418 }
419 other => panic!("expected Execution, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn empty_detail_uses_default() {
425 let err = SailError::from_exec_status(&Status::internal(""));
426 match err {
427 SailError::Execution { code, detail } => {
428 assert_eq!(code, GrpcCode::Internal);
429 assert_eq!(detail, "unknown sailbox exec error");
430 }
431 other => panic!("expected Execution, got {other:?}"),
432 }
433 }
434
435 #[test]
436 fn client_transport_failure_maps_to_transport() {
437 let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
440 let status = Status::from_error(Box::new(io));
441 match SailError::from_rpc_status(&status) {
442 SailError::Transport { kind, source, .. } => {
443 assert_eq!(kind, TransportKind::Connection);
444 assert!(source.is_some());
446 }
447 other => panic!("expected Transport, got {other:?}"),
448 }
449 }
450
451 #[test]
452 fn server_sent_unavailable_stays_in_status_taxonomy() {
453 let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
455 assert!(matches!(err, SailError::Execution { .. }));
456 }
457
458 #[test]
459 fn rpc_and_file_status_taxonomies_diverge_where_intended() {
460 use assert_matches::assert_matches;
461 assert_matches!(
464 SailError::from_rpc_status(&Status::not_found("x")),
465 SailError::NotFound { .. }
466 );
467 assert_matches!(
468 SailError::from_rpc_status(&Status::permission_denied("x")),
469 SailError::PermissionDenied { .. }
470 );
471 assert_matches!(
472 SailError::from_rpc_status(&Status::unauthenticated("x")),
473 SailError::PermissionDenied { .. }
474 );
475 assert_matches!(
476 SailError::from_rpc_status(&Status::invalid_argument("x")),
477 SailError::Execution {
478 code: GrpcCode::InvalidArgument,
479 ..
480 }
481 );
482 assert_matches!(
485 SailError::from_file_rpc_status(&Status::not_found("x")),
486 SailError::FileNotFound { .. }
487 );
488 assert_matches!(
489 SailError::from_file_rpc_status(&Status::invalid_argument("x")),
490 SailError::InvalidArgument { .. }
491 );
492 assert_matches!(
493 SailError::from_file_rpc_status(&Status::permission_denied("x")),
494 SailError::PermissionDenied { .. }
495 );
496 assert_matches!(
497 SailError::from_file_rpc_status(&Status::internal("x")),
498 SailError::Execution {
499 code: GrpcCode::Internal,
500 ..
501 }
502 );
503 }
504
505 #[test]
506 fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
507 let table = [
510 (Code::Ok, "ok"),
511 (Code::Cancelled, "cancelled"),
512 (Code::Unknown, "unknown"),
513 (Code::InvalidArgument, "invalid_argument"),
514 (Code::DeadlineExceeded, "deadline_exceeded"),
515 (Code::NotFound, "not_found"),
516 (Code::AlreadyExists, "already_exists"),
517 (Code::PermissionDenied, "permission_denied"),
518 (Code::ResourceExhausted, "resource_exhausted"),
519 (Code::FailedPrecondition, "failed_precondition"),
520 (Code::Aborted, "aborted"),
521 (Code::OutOfRange, "out_of_range"),
522 (Code::Unimplemented, "unimplemented"),
523 (Code::Internal, "internal"),
524 (Code::Unavailable, "unavailable"),
525 (Code::DataLoss, "data_loss"),
526 (Code::Unauthenticated, "unauthenticated"),
527 ];
528 for (code, expected) in table {
529 assert_eq!(GrpcCode::from(code).as_str(), expected);
530 }
531 }
532}