sail-rs 0.2.11

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Canonical, language-neutral error taxonomy for the Sail core.
//!
//! This enum is the contract every binding maps onto its own error type:
//! the Python SDK to its `sail.errors` classes, the native CLI to its exit
//! handling, the TypeScript SDK to its `SailError` subclasses. The `Display` impl
//! is for Rust consumers and logs only. Error message *text* is NOT a
//! cross-language contract, so bindings are free to format their own
//! user-facing messages from the structured fields.

use std::error::Error as StdError;

use thiserror::Error;
use tonic::{Code, Status};

/// gRPC status code, decoupled from any one client library's spelling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GrpcCode {
    /// The operation completed successfully.
    Ok,
    /// The operation was canceled, typically by the caller.
    Cancelled,
    /// An unknown error, often a status with no mapped code.
    Unknown,
    /// The client specified an invalid argument.
    InvalidArgument,
    /// The deadline expired before the operation could complete.
    DeadlineExceeded,
    /// The requested entity was not found.
    NotFound,
    /// An attempt to create an entity that already exists.
    AlreadyExists,
    /// The caller lacks permission to execute the operation.
    PermissionDenied,
    /// A resource (quota, capacity, file space) has been exhausted.
    ResourceExhausted,
    /// The system is not in a state required for the operation.
    FailedPrecondition,
    /// The operation was aborted, often due to a concurrency conflict.
    Aborted,
    /// The operation was attempted past the valid range.
    OutOfRange,
    /// The operation is not implemented or not supported.
    Unimplemented,
    /// An internal error: an invariant expected by the system was broken.
    Internal,
    /// The service is currently unavailable, typically transient.
    Unavailable,
    /// Unrecoverable data loss or corruption.
    DataLoss,
    /// The request lacks valid authentication credentials.
    Unauthenticated,
}

impl GrpcCode {
    /// A stable, lowercase identifier for the code. Bindings that need a
    /// different spelling (e.g. a language's own convention) map from the
    /// `GrpcCode` value rather than parsing this string.
    pub fn as_str(self) -> &'static str {
        match self {
            GrpcCode::Ok => "ok",
            GrpcCode::Cancelled => "cancelled",
            GrpcCode::Unknown => "unknown",
            GrpcCode::InvalidArgument => "invalid_argument",
            GrpcCode::DeadlineExceeded => "deadline_exceeded",
            GrpcCode::NotFound => "not_found",
            GrpcCode::AlreadyExists => "already_exists",
            GrpcCode::PermissionDenied => "permission_denied",
            GrpcCode::ResourceExhausted => "resource_exhausted",
            GrpcCode::FailedPrecondition => "failed_precondition",
            GrpcCode::Aborted => "aborted",
            GrpcCode::OutOfRange => "out_of_range",
            GrpcCode::Unimplemented => "unimplemented",
            GrpcCode::Internal => "internal",
            GrpcCode::Unavailable => "unavailable",
            GrpcCode::DataLoss => "data_loss",
            GrpcCode::Unauthenticated => "unauthenticated",
        }
    }
}

impl std::fmt::Display for GrpcCode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl From<Code> for GrpcCode {
    fn from(code: Code) -> GrpcCode {
        match code {
            Code::Ok => GrpcCode::Ok,
            Code::Cancelled => GrpcCode::Cancelled,
            Code::Unknown => GrpcCode::Unknown,
            Code::InvalidArgument => GrpcCode::InvalidArgument,
            Code::DeadlineExceeded => GrpcCode::DeadlineExceeded,
            Code::NotFound => GrpcCode::NotFound,
            Code::AlreadyExists => GrpcCode::AlreadyExists,
            Code::PermissionDenied => GrpcCode::PermissionDenied,
            Code::ResourceExhausted => GrpcCode::ResourceExhausted,
            Code::FailedPrecondition => GrpcCode::FailedPrecondition,
            Code::Aborted => GrpcCode::Aborted,
            Code::OutOfRange => GrpcCode::OutOfRange,
            Code::Unimplemented => GrpcCode::Unimplemented,
            Code::Internal => GrpcCode::Internal,
            Code::Unavailable => GrpcCode::Unavailable,
            Code::DataLoss => GrpcCode::DataLoss,
            Code::Unauthenticated => GrpcCode::Unauthenticated,
        }
    }
}

/// How a transport attempt failed, independent of any language's exception
/// names. Bindings map these onto their own transport-error types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransportKind {
    /// The attempt exceeded its deadline before a response arrived.
    Timeout,
    /// The transport could not establish or maintain a connection.
    Connection,
}

impl std::fmt::Display for TransportKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            TransportKind::Timeout => "timeout",
            TransportKind::Connection => "connection",
        })
    }
}

/// The canonical Sail error. Variants carry structured fields so bindings can
/// populate their own exception attributes without parsing strings.
///
/// `Display` is for Rust logs only; bindings format their own user-facing text.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SailError {
    /// Configuration/usage error (e.g. missing API key, bad SAIL_MODE).
    #[error("{message}")]
    Config {
        /// Human-readable description of the misconfiguration.
        message: String,
    },
    /// Unexpected internal failure (client build, TLS config, invariant).
    #[error("{message}")]
    Internal {
        /// Human-readable description of the internal failure.
        message: String,
    },
    /// HTTP/gRPC dial-time transport failure. `source` is the underlying
    /// transport error (e.g. the originating `tonic::Status`) for Rust callers.
    #[error("{kind} error: {message}")]
    Transport {
        /// Whether the transport failed by timeout or by connection error.
        kind: TransportKind,
        /// Human-readable description of the transport failure.
        message: String,
        /// The underlying transport error, retained for Rust callers; `None`
        /// when no originating error was available.
        #[source]
        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
    },
    /// Sailbox creation failed (non-2xx on create).
    #[error("{message}")]
    Creation {
        /// Human-readable description of the creation failure.
        message: String,
        /// HTTP status code returned by the create request.
        status: u16,
        /// Parsed response body from the failed create request.
        body: serde_json::Value,
    },
    /// A resource was not found (404 on get / unknown id across orgs).
    #[error("{message}")]
    NotFound {
        /// Human-readable description of the missing resource.
        message: String,
    },
    /// A credential problem: missing/invalid API key or insufficient scope.
    #[error("{message}")]
    PermissionDenied {
        /// Human-readable description of the credential or scope problem.
        message: String,
    },
    /// A file operation referenced a path that does not exist.
    #[error("{message}")]
    FileNotFound {
        /// Human-readable description of the missing path.
        message: String,
    },
    /// A request argument was rejected as invalid.
    #[error("{message}")]
    InvalidArgument {
        /// Human-readable description of why the argument was rejected.
        message: String,
    },
    /// A custom image build failed (the imagebuilder reported `failed`).
    #[error("image build failed: {message}")]
    ImageBuild {
        /// Human-readable build failure detail.
        message: String,
    },
    /// Any other non-2xx API response.
    #[error("{message}")]
    Api {
        /// HTTP status code returned by the API.
        status: u16,
        /// Human-readable description of the API failure.
        message: String,
        /// Parsed response body from the failed API request.
        body: serde_json::Value,
    },
    /// A wait referenced an unknown exec request.
    #[error("{message}")]
    ExecRequestNotFound {
        /// Human-readable description of the unknown exec request.
        message: String,
    },
    /// The sailbox no longer exists on the worker.
    #[error("{message}")]
    Terminated {
        /// Human-readable description noting the sailbox is gone.
        message: String,
    },
    /// The worker hosting the sailbox was lost; output is unrecoverable.
    #[error("{message}")]
    WorkerLost {
        /// Human-readable description of the worker loss.
        message: String,
    },
    /// A stdin write hit a command that already finished or closed its stdin.
    #[error("{message}")]
    BrokenPipe {
        /// Human-readable description of the broken-pipe condition.
        message: String,
    },
    /// Any other exec-RPC failure, classified by gRPC code.
    #[error("{code}: {detail}")]
    Execution {
        /// The gRPC status code that classifies the failure.
        code: GrpcCode,
        /// Human-readable detail describing the failure.
        detail: String,
    },
}

/// Detect a client-side transport failure that tonic surfaces as a gRPC
/// `Status`. A failed connect or transport timeout carries a populated
/// `source()`, whereas a status decoded from server response trailers does
/// not. We use that to keep true transport failures (which bindings map to
/// their own connection/timeout error types) out of the server-status
/// taxonomy, instead of misreporting them as an `Execution` error.
fn transport_failure(status: &Status) -> Option<SailError> {
    status.source()?;
    let message = status.message().to_string();
    let lower = message.to_lowercase();
    let kind = if status.code() == Code::DeadlineExceeded
        || lower.contains("timed out")
        || lower.contains("timeout")
    {
        TransportKind::Timeout
    } else {
        TransportKind::Connection
    };
    Some(SailError::Transport {
        kind,
        message,
        source: Some(Box::new(status.clone())),
    })
}

impl SailError {
    /// Classify an exec-context gRPC status into the canonical taxonomy.
    ///
    /// NOT_FOUND splits into [`SailError::ExecRequestNotFound`] (the wait
    /// referenced an unknown exec request) versus [`SailError::Terminated`]
    /// (the sailbox itself is gone). A credential failure maps to
    /// [`SailError::PermissionDenied`], matching the listener/file mappers.
    /// Everything else is a generic [`SailError::Execution`] carrying the
    /// structured code; bindings format their own message from `code` + `detail`.
    pub fn from_exec_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown sailbox exec error"
        } else {
            status.message()
        };
        if status.code() == Code::NotFound {
            if detail.contains("exec request") {
                return SailError::ExecRequestNotFound {
                    message: detail.to_string(),
                };
            }
            return SailError::Terminated {
                message: format!(
                    "{detail}; this is likely because this Sailbox is no longer running"
                ),
            };
        }
        if matches!(
            status.code(),
            Code::PermissionDenied | Code::Unauthenticated
        ) {
            return SailError::PermissionDenied {
                message: detail.to_string(),
            };
        }
        SailError::Execution {
            code: status.code().into(),
            detail: detail.to_string(),
        }
    }

    /// Classify a general (non-exec) worker-proxy gRPC status (listeners,
    /// files). NOT_FOUND maps to [`SailError::NotFound`], a credential failure
    /// to [`SailError::PermissionDenied`], and everything else to
    /// [`SailError::Execution`] carrying the structured code.
    pub fn from_rpc_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown worker-proxy error"
        } else {
            status.message()
        };
        match status.code() {
            Code::NotFound => SailError::NotFound {
                message: detail.to_string(),
            },
            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
                message: detail.to_string(),
            },
            code => SailError::Execution {
                code: code.into(),
                detail: detail.to_string(),
            },
        }
    }

    /// Classify a file-RPC gRPC status. Files have their own taxonomy: a missing
    /// path is [`SailError::FileNotFound`] (not the generic NotFound), and a
    /// rejected argument is [`SailError::InvalidArgument`].
    pub fn from_file_rpc_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown sailbox file error"
        } else {
            status.message()
        };
        match status.code() {
            Code::NotFound => SailError::FileNotFound {
                message: detail.to_string(),
            },
            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
                message: detail.to_string(),
            },
            Code::InvalidArgument => SailError::InvalidArgument {
                message: detail.to_string(),
            },
            code => SailError::Execution {
                code: code.into(),
                detail: detail.to_string(),
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn not_found_without_exec_request_is_terminated() {
        let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
        match err {
            SailError::Terminated { message } => {
                assert!(message.contains("no longer running"));
            }
            other => panic!("expected Terminated, got {other:?}"),
        }
    }

    #[test]
    fn not_found_with_exec_request_is_request_not_found() {
        let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
        assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
    }

    #[test]
    fn exec_auth_failure_is_permission_denied() {
        // exec/wait/cancel auth failures map to PermissionDenied, matching the
        // listener/file mappers, not the generic Execution catch-all.
        for status in [
            Status::unauthenticated("invalid API key"),
            Status::permission_denied("sailbox owned by another org"),
        ] {
            let err = SailError::from_exec_status(&status);
            assert!(
                matches!(err, SailError::PermissionDenied { .. }),
                "got {err:?}"
            );
        }
    }

    #[test]
    fn other_codes_carry_structured_grpc_code() {
        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
        match err {
            SailError::Execution { code, detail } => {
                assert_eq!(code, GrpcCode::Unavailable);
                assert_eq!(detail, "upstream draining");
            }
            other => panic!("expected Execution, got {other:?}"),
        }
    }

    #[test]
    fn empty_detail_uses_default() {
        let err = SailError::from_exec_status(&Status::internal(""));
        match err {
            SailError::Execution { code, detail } => {
                assert_eq!(code, GrpcCode::Internal);
                assert_eq!(detail, "unknown sailbox exec error");
            }
            other => panic!("expected Execution, got {other:?}"),
        }
    }

    #[test]
    fn client_transport_failure_maps_to_transport() {
        // A failed connect surfaces as a status carrying a transport source,
        // unlike a server-sent status decoded from response trailers.
        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
        let status = Status::from_error(Box::new(io));
        match SailError::from_rpc_status(&status) {
            SailError::Transport { kind, source, .. } => {
                assert_eq!(kind, TransportKind::Connection);
                // The originating status is retained as the structured source.
                assert!(source.is_some());
            }
            other => panic!("expected Transport, got {other:?}"),
        }
    }

    #[test]
    fn server_sent_unavailable_stays_in_status_taxonomy() {
        // No source => a real server status, not a client transport failure.
        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
        assert!(matches!(err, SailError::Execution { .. }));
    }

    #[test]
    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
        use assert_matches::assert_matches;
        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
        // argument has no dedicated variant and stays Execution.
        assert_matches!(
            SailError::from_rpc_status(&Status::not_found("x")),
            SailError::NotFound { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::permission_denied("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::unauthenticated("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::invalid_argument("x")),
            SailError::Execution {
                code: GrpcCode::InvalidArgument,
                ..
            }
        );
        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
        // the generic NotFound) and a rejected argument is InvalidArgument.
        assert_matches!(
            SailError::from_file_rpc_status(&Status::not_found("x")),
            SailError::FileNotFound { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
            SailError::InvalidArgument { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::permission_denied("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::internal("x")),
            SailError::Execution {
                code: GrpcCode::Internal,
                ..
            }
        );
    }

    #[test]
    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
        // The lowercase ids are a cross-binding contract, so cover every code
        // exhaustively: a typo or missed arm in either mapping fails here.
        let table = [
            (Code::Ok, "ok"),
            (Code::Cancelled, "cancelled"),
            (Code::Unknown, "unknown"),
            (Code::InvalidArgument, "invalid_argument"),
            (Code::DeadlineExceeded, "deadline_exceeded"),
            (Code::NotFound, "not_found"),
            (Code::AlreadyExists, "already_exists"),
            (Code::PermissionDenied, "permission_denied"),
            (Code::ResourceExhausted, "resource_exhausted"),
            (Code::FailedPrecondition, "failed_precondition"),
            (Code::Aborted, "aborted"),
            (Code::OutOfRange, "out_of_range"),
            (Code::Unimplemented, "unimplemented"),
            (Code::Internal, "internal"),
            (Code::Unavailable, "unavailable"),
            (Code::DataLoss, "data_loss"),
            (Code::Unauthenticated, "unauthenticated"),
        ];
        for (code, expected) in table {
            assert_eq!(GrpcCode::from(code).as_str(), expected);
        }
    }
}