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 {
291 if let Some(err) = transport_failure(status) {
292 return err;
293 }
294 let detail = if status.message().is_empty() {
295 "unknown sailbox exec error"
296 } else {
297 status.message()
298 };
299 if status.code() == Code::NotFound {
300 if detail.contains("exec request") {
301 return SailError::ExecRequestNotFound {
302 message: detail.to_string(),
303 };
304 }
305 return SailError::Terminated {
306 message: format!(
307 "{detail}; this is likely because this Sailbox is no longer running"
308 ),
309 };
310 }
311 SailError::Execution {
312 code: status.code().into(),
313 detail: detail.to_string(),
314 }
315 }
316
317 pub 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 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 other_codes_carry_structured_grpc_code() {
397 let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
398 match err {
399 SailError::Execution { code, detail } => {
400 assert_eq!(code, GrpcCode::Unavailable);
401 assert_eq!(detail, "upstream draining");
402 }
403 other => panic!("expected Execution, got {other:?}"),
404 }
405 }
406
407 #[test]
408 fn empty_detail_uses_default() {
409 let err = SailError::from_exec_status(&Status::internal(""));
410 match err {
411 SailError::Execution { code, detail } => {
412 assert_eq!(code, GrpcCode::Internal);
413 assert_eq!(detail, "unknown sailbox exec error");
414 }
415 other => panic!("expected Execution, got {other:?}"),
416 }
417 }
418
419 #[test]
420 fn client_transport_failure_maps_to_transport() {
421 let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
424 let status = Status::from_error(Box::new(io));
425 match SailError::from_rpc_status(&status) {
426 SailError::Transport { kind, source, .. } => {
427 assert_eq!(kind, TransportKind::Connection);
428 assert!(source.is_some());
430 }
431 other => panic!("expected Transport, got {other:?}"),
432 }
433 }
434
435 #[test]
436 fn server_sent_unavailable_stays_in_status_taxonomy() {
437 let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
439 assert!(matches!(err, SailError::Execution { .. }));
440 }
441
442 #[test]
443 fn rpc_and_file_status_taxonomies_diverge_where_intended() {
444 use assert_matches::assert_matches;
445 assert_matches!(
448 SailError::from_rpc_status(&Status::not_found("x")),
449 SailError::NotFound { .. }
450 );
451 assert_matches!(
452 SailError::from_rpc_status(&Status::permission_denied("x")),
453 SailError::PermissionDenied { .. }
454 );
455 assert_matches!(
456 SailError::from_rpc_status(&Status::unauthenticated("x")),
457 SailError::PermissionDenied { .. }
458 );
459 assert_matches!(
460 SailError::from_rpc_status(&Status::invalid_argument("x")),
461 SailError::Execution {
462 code: GrpcCode::InvalidArgument,
463 ..
464 }
465 );
466 assert_matches!(
469 SailError::from_file_rpc_status(&Status::not_found("x")),
470 SailError::FileNotFound { .. }
471 );
472 assert_matches!(
473 SailError::from_file_rpc_status(&Status::invalid_argument("x")),
474 SailError::InvalidArgument { .. }
475 );
476 assert_matches!(
477 SailError::from_file_rpc_status(&Status::permission_denied("x")),
478 SailError::PermissionDenied { .. }
479 );
480 assert_matches!(
481 SailError::from_file_rpc_status(&Status::internal("x")),
482 SailError::Execution {
483 code: GrpcCode::Internal,
484 ..
485 }
486 );
487 }
488
489 #[test]
490 fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
491 let table = [
494 (Code::Ok, "ok"),
495 (Code::Cancelled, "cancelled"),
496 (Code::Unknown, "unknown"),
497 (Code::InvalidArgument, "invalid_argument"),
498 (Code::DeadlineExceeded, "deadline_exceeded"),
499 (Code::NotFound, "not_found"),
500 (Code::AlreadyExists, "already_exists"),
501 (Code::PermissionDenied, "permission_denied"),
502 (Code::ResourceExhausted, "resource_exhausted"),
503 (Code::FailedPrecondition, "failed_precondition"),
504 (Code::Aborted, "aborted"),
505 (Code::OutOfRange, "out_of_range"),
506 (Code::Unimplemented, "unimplemented"),
507 (Code::Internal, "internal"),
508 (Code::Unavailable, "unavailable"),
509 (Code::DataLoss, "data_loss"),
510 (Code::Unauthenticated, "unauthenticated"),
511 ];
512 for (code, expected) in table {
513 assert_eq!(GrpcCode::from(code).as_str(), expected);
514 }
515 }
516}