Skip to main content

microvms_domain/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One error type, two granularities.
3//!
4//! # Why two
5//!
6//! The daemon reports distinct statuses such as `Conflict`, `NotFound`, and
7//! `ProtocolError`. The CLI groups them into a coarse process exit category while
8//! retaining the finer wire kind in its JSON envelope.
9//!
10//! [`Error::kind`] answers the exit-category question; [`Error::wire_kind`] answers
11//! which daemon condition occurred. The conformance suite checks both contracts.
12//!
13//! # What a caller matches on
14//!
15//! [`ErrorKind`], nearly always. [`WireKind`] exists for the two consumers that
16//! need the finer view: the conformance driver, and a caller retrying a specific
17//! daemon condition. Nobody should parse a message.
18
19use std::fmt;
20
21/// A failure, classified once at the point it is raised.
22///
23/// Carries the coarse [`ErrorKind`] every consumer branches on, the optional
24/// [`WireKind`] naming the daemon status that produced it, a message that names
25/// the `docs/PLATFORM.md` finding when a trap closure raised it, and the
26/// underlying error when one exists.
27///
28/// Deliberately a struct with a private body rather than an enum: the variants a
29/// caller wants to match on are the *kinds*, and an enum over every raise site
30/// would make every new failure a breaking change for a binding that matched
31/// exhaustively.
32#[derive(Debug, thiserror::Error)]
33#[error("{message}")]
34pub struct Error {
35    kind: ErrorKind,
36    wire_kind: Option<WireKind>,
37    message: String,
38    #[source]
39    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
40}
41
42impl Error {
43    /// A failure of `kind`, with a message that should name its finding.
44    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
45        Self {
46            kind,
47            wire_kind: None,
48            message: message.into(),
49            source: None,
50        }
51    }
52
53    /// A local reject: the request was refused before any AWS call.
54    ///
55    /// The shorthand exists because every trap closure in this crate ends in one,
56    /// and `ERR_INVALID_ARG` is the code that tells a caller the fix is theirs and
57    /// costs nothing.
58    pub fn invalid_arg(message: impl Into<String>) -> Self {
59        Self::new(ErrorKind::InvalidArg, message)
60    }
61
62    /// A failure the daemon reported by choosing a status.
63    ///
64    /// The [`ErrorKind`] is derived from the [`WireKind`] rather than passed in,
65    /// because the two must not be able to disagree: `Unauthorized` is
66    /// `ERR_CREDENTIALS` and never `ERR_PROTOCOL`, and a call site free to pick
67    /// both is a call site that can get that wrong once.
68    pub fn wire(wire_kind: WireKind, message: impl Into<String>) -> Self {
69        Self {
70            kind: wire_kind.error_kind(),
71            wire_kind: Some(wire_kind),
72            message: message.into(),
73            source: None,
74        }
75    }
76
77    /// Attaches the underlying error, reachable through [`std::error::Error::source`].
78    #[must_use]
79    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
80        self.source = Some(Box::new(source));
81        self
82    }
83
84    /// The coarse class, one per row of the exit-code table.
85    pub fn kind(&self) -> ErrorKind {
86        self.kind
87    }
88
89    /// The daemon status this came from, when it came from one.
90    ///
91    /// `None` for every local reject and every control-plane failure — those never
92    /// reached the in-VM daemon, so there is no status to report.
93    pub fn wire_kind(&self) -> Option<WireKind> {
94        self.wire_kind
95    }
96
97    /// The machine-readable `ERR_*` code, as `cli.py`'s `Code` spells it.
98    pub fn code(&self) -> &'static str {
99        self.kind.code()
100    }
101
102    /// Whether retrying the identical request could plausibly succeed.
103    ///
104    /// Derived from the kind rather than stored, so it cannot drift from it: the
105    /// five retryable daemon conditions all map to [`ErrorKind::Retryable`], which
106    /// is what makes this one comparison instead of a second table.
107    pub fn retryable(&self) -> bool {
108        self.kind.retryable()
109    }
110}
111
112/// The coarse failure classes, one per non-zero row of the CLI's exit table.
113///
114/// These categories are mapped to stable process exit codes by the CLI.
115/// The integer exit code lives in the CLI, not here: a library that owned process
116/// exit codes would be a library with an opinion about being a process.
117#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
118pub enum ErrorKind {
119    /// No handler claimed this — a bug in this crate, not the platform.
120    Unexpected,
121    /// Refused locally, before any AWS call. Every trap closure lands here.
122    InvalidArg,
123    /// Transient. Run the identical request again.
124    Retryable,
125    /// An identity is wrong or absent; waiting will not fix it.
126    Credentials,
127    /// The daemon rejected the request on its merits.
128    Protocol,
129    /// The image build was never scheduled — the `clientToken` replay signature.
130    BuildWedged,
131    /// The MicroVM reached a terminal state before RUNNING; read `stateReason`.
132    LaunchDied,
133    /// The MicroVM ended before the resume (the suspended window, the maximum duration, or a
134    /// terminate), and it can't be resumed; launch a new one. The service refused the resume,
135    /// and `GetMicrovm` reports the VM TERMINATING or TERMINATED.
136    WindowClosed,
137    /// A control-plane failure with no more specific class.
138    Platform,
139    /// A client-side deadline elapsed. The VM and the exec are untouched.
140    Timeout,
141    /// Interrupted after launch; teardown ran and any leak is named in the payload.
142    Interrupted,
143    /// A prerequisite is missing.
144    Precondition,
145    /// The sandbox worked and the command in it exited non-zero.
146    ///
147    /// Its own class because it is the one failure that means nothing is wrong with
148    /// the platform, the credentials, or this client — a CI caller needs to tell
149    /// "your tests failed" from "we never got a VM", and one shared class cannot say
150    /// both.
151    ExecFailed,
152}
153
154impl ErrorKind {
155    /// Every kind, in exit-code order.
156    ///
157    /// Public so a binding can enumerate the catalog and a test can assert the
158    /// mapping is total rather than sampling it.
159    pub const ALL: [ErrorKind; 13] = [
160        ErrorKind::Unexpected,
161        ErrorKind::InvalidArg,
162        ErrorKind::Retryable,
163        ErrorKind::Credentials,
164        ErrorKind::Protocol,
165        ErrorKind::BuildWedged,
166        ErrorKind::LaunchDied,
167        ErrorKind::WindowClosed,
168        ErrorKind::Platform,
169        ErrorKind::Timeout,
170        ErrorKind::Interrupted,
171        ErrorKind::Precondition,
172        ErrorKind::ExecFailed,
173    ];
174
175    /// Whether a failure of this kind could plausibly succeed on an identical retry.
176    ///
177    /// The one answer [`Error::retryable`] reads, public so a binding that carries only
178    /// the code across its boundary (the TypeScript one) asks this rather than keeping a
179    /// list of retryable codes of its own.
180    pub fn retryable(self) -> bool {
181        matches!(self, ErrorKind::Retryable)
182    }
183
184    /// The `ERR_*` string, byte-identical to `cli.py`'s `Code` member.
185    ///
186    /// A string beside the eventual integer because the two are read by different
187    /// consumers: a shell branches on `$?`, an agent parsing `--json` branches on
188    /// `code` and should never have to keep an integer table.
189    pub fn code(self) -> &'static str {
190        match self {
191            ErrorKind::Unexpected => "ERR_UNEXPECTED",
192            ErrorKind::InvalidArg => "ERR_INVALID_ARG",
193            ErrorKind::Retryable => "ERR_RETRYABLE",
194            ErrorKind::Credentials => "ERR_CREDENTIALS",
195            ErrorKind::Protocol => "ERR_PROTOCOL",
196            ErrorKind::BuildWedged => "ERR_BUILD_WEDGED",
197            ErrorKind::LaunchDied => "ERR_LAUNCH_DIED",
198            ErrorKind::WindowClosed => "ERR_WINDOW_CLOSED",
199            ErrorKind::Platform => "ERR_PLATFORM",
200            ErrorKind::Timeout => "ERR_TIMEOUT",
201            ErrorKind::Interrupted => "ERR_INTERRUPTED",
202            ErrorKind::Precondition => "ERR_PRECONDITION",
203            ErrorKind::ExecFailed => "ERR_EXEC_FAILED",
204        }
205    }
206}
207
208impl fmt::Display for ErrorKind {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.write_str(self.code())
211    }
212}
213
214/// The fine-grained taxonomy: one variant per condition the daemon or the
215/// transport distinguishes.
216///
217/// These are the `errors.py` exception *classes*, which is what the conformance
218/// suite asserts on. Several collapse onto one [`ErrorKind`] and that is the
219/// point — the collapse happens at the exit code, not at the raise site.
220#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
221pub enum WireKind {
222    /// 401: the presented bearer token is not the installed one. Fatal.
223    Unauthorized,
224    /// 400: malformed body, missing query key, bad mode, refused tar member.
225    ///
226    /// Never 404. A 404 here reads as a missing file, which is exactly how one
227    /// defect hid for a review round.
228    ProtocolError,
229    /// 404: a genuinely absent exec id, file, or directory.
230    NotFound,
231    /// 409: well-formed, but the target is in the wrong state.
232    Conflict,
233    /// 410: stdin already saw EOF, or the child stopped reading.
234    ///
235    /// Distinct from [`WireKind::Conflict`], which is "you did not ask for stdin"
236    /// and is fixed at start time. This one is a lifecycle fact.
237    StdinClosed,
238    /// 413: over a configured cap (body, tar members or bytes, stdin write).
239    TooLarge,
240    /// 408: the child did not drain its stdin pipe within the write timeout.
241    ///
242    /// Retryable, and the daemon deliberately keeps its stdin handle open so a
243    /// retry can succeed. Some bytes may already have landed; reconciling that is
244    /// the caller's problem, which is why it is a distinct variant.
245    RequestTimeout,
246    /// 503: the run hook has not landed, so the control API is closed.
247    ///
248    /// Not 404 and not a dropped connection. Retry: the platform is about to
249    /// deliver the token.
250    NotBootstrapped,
251    /// 5xx other than 503 and 507: spawn failure, io failure, a panicking task.
252    ServerError,
253    /// 507: a write would take the target filesystem under the daemon's disk reserve.
254    ///
255    /// Not retryable, and that's why the daemon answers 507 rather than 500
256    /// (`crates/agentd/src/fs.rs`): an identical retry against a full disk fails the
257    /// same way, and a retry loop keyed on a 5xx would repeat it. The body names the
258    /// free and reserved bytes. The remedy is freeing space, then retrying.
259    InsufficientStorage,
260    /// The request never produced a status: connection refused, reset, timeout.
261    ///
262    /// Retryable because it says nothing about the daemon's state. A VM that has
263    /// just reached RUNNING commonly refuses a connection or two before the proxy
264    /// path is wired up.
265    Transport,
266    /// `CreateMicrovmAuthToken` failed.
267    ///
268    /// Retryable, and load-bearing rather than optimistic: proxy tokens expire at
269    /// 60 minutes, so a long run *will* mint mid-flight, and a throttle at that
270    /// moment must not kill a trial that is otherwise healthy.
271    AuthTokenMint,
272    /// A client-side wait or stream deadline elapsed. The exec is untouched.
273    ExecTimeout,
274    /// Output bytes are gone for good — the replay ring evicted them, or this
275    /// subscriber lagged the live channel.
276    OutputGap,
277}
278
279impl WireKind {
280    /// Every variant, so a test can assert the mapping is total.
281    pub const ALL: [WireKind; 14] = [
282        WireKind::Unauthorized,
283        WireKind::ProtocolError,
284        WireKind::NotFound,
285        WireKind::Conflict,
286        WireKind::StdinClosed,
287        WireKind::TooLarge,
288        WireKind::RequestTimeout,
289        WireKind::NotBootstrapped,
290        WireKind::ServerError,
291        WireKind::InsufficientStorage,
292        WireKind::Transport,
293        WireKind::AuthTokenMint,
294        WireKind::ExecTimeout,
295        WireKind::OutputGap,
296    ];
297
298    /// The stable name the CLI puts in `data.kind` and the bindings use for the
299    /// corresponding exception. Conformance tests compare these names.
300    pub fn as_str(self) -> &'static str {
301        match self {
302            WireKind::Unauthorized => "Unauthorized",
303            WireKind::ProtocolError => "ProtocolError",
304            WireKind::NotFound => "NotFound",
305            WireKind::Conflict => "Conflict",
306            WireKind::StdinClosed => "StdinClosed",
307            WireKind::TooLarge => "TooLarge",
308            WireKind::RequestTimeout => "RequestTimeout",
309            WireKind::NotBootstrapped => "NotBootstrapped",
310            WireKind::ServerError => "ServerError",
311            WireKind::InsufficientStorage => "InsufficientStorage",
312            WireKind::Transport => "Transport",
313            WireKind::AuthTokenMint => "AuthTokenMint",
314            WireKind::ExecTimeout => "ExecTimeout",
315            WireKind::OutputGap => "OutputGap",
316        }
317    }
318
319    /// The status that produces this variant, for the ones that come from one.
320    ///
321    /// `None` for the four that have no status: two never got a response
322    /// ([`WireKind::Transport`], [`WireKind::AuthTokenMint`]) and two are client-side
323    /// facts ([`WireKind::ExecTimeout`], [`WireKind::OutputGap`]).
324    pub fn status(self) -> Option<u16> {
325        match self {
326            WireKind::ProtocolError => Some(400),
327            WireKind::Unauthorized => Some(401),
328            WireKind::NotFound => Some(404),
329            WireKind::RequestTimeout => Some(408),
330            WireKind::Conflict => Some(409),
331            WireKind::StdinClosed => Some(410),
332            WireKind::TooLarge => Some(413),
333            WireKind::ServerError => Some(500),
334            WireKind::NotBootstrapped => Some(503),
335            WireKind::InsufficientStorage => Some(507),
336            WireKind::Transport
337            | WireKind::AuthTokenMint
338            | WireKind::ExecTimeout
339            | WireKind::OutputGap => None,
340        }
341    }
342
343    /// The variant a response status means, or `None` for a status the daemon does
344    /// not use to mean anything.
345    ///
346    /// The table is explicit and there is deliberately **no generic 4xx fallback**.
347    /// The daemon's whole point is that 400 and 404 mean different things, and a
348    /// fallback that mapped "some 4xx" to one variant would reintroduce the
349    /// phantom-missing-file defect that hid in the Python client for a review
350    /// round. 5xx does fall back — to [`WireKind::ServerError`], which is what
351    /// `errors.py` does — because every 5xx means the same thing to a caller:
352    /// the daemon broke, try again. The two the daemon chooses on purpose keep their
353    /// own variants: 503 is "come back in a moment", and 507 is "free space first".
354    pub fn from_status(status: u16) -> Option<WireKind> {
355        match status {
356            400 => Some(WireKind::ProtocolError),
357            401 => Some(WireKind::Unauthorized),
358            404 => Some(WireKind::NotFound),
359            408 => Some(WireKind::RequestTimeout),
360            409 => Some(WireKind::Conflict),
361            410 => Some(WireKind::StdinClosed),
362            413 => Some(WireKind::TooLarge),
363            503 => Some(WireKind::NotBootstrapped),
364            // Before the 5xx fallback, which would make a full disk retryable.
365            507 => Some(WireKind::InsufficientStorage),
366            s if s >= 500 => Some(WireKind::ServerError),
367            _ => None,
368        }
369    }
370
371    /// The exit-code class this collapses onto.
372    ///
373    /// The ordering `cli.py`'s `classify` documents as "the contract" is a `match`
374    /// here rather than a sequence of `isinstance` tests, which removes the way it
375    /// could be got wrong: [`WireKind::Unauthorized`] is an HTTP error whose remedy
376    /// is a credential rather than a wait, and in Python it had to be *checked
377    /// before* the generic retryable test to avoid being swallowed by it. A match on
378    /// a closed enum has no order to get wrong.
379    pub fn error_kind(self) -> ErrorKind {
380        match self {
381            // A credential, not a wait. Retrying a 401 forever is one of the two
382            // mistakes the Python ordering existed to prevent.
383            WireKind::Unauthorized => ErrorKind::Credentials,
384            // The retryable daemon conditions. Failing a launch that
385            // was 200 ms from ready is the other mistake.
386            WireKind::Transport
387            | WireKind::AuthTokenMint
388            | WireKind::NotBootstrapped
389            | WireKind::RequestTimeout
390            | WireKind::ServerError => ErrorKind::Retryable,
391            // The daemon rejected the request on its merits. Four classes, one exit
392            // code: a shell cannot act differently on them, and a caller that can
393            // reads `data.kind`.
394            WireKind::ProtocolError
395            | WireKind::NotFound
396            | WireKind::Conflict
397            | WireKind::StdinClosed
398            | WireKind::TooLarge => ErrorKind::Protocol,
399            // A client-side deadline. `cli.py` reaches ERR_PLATFORM for this by
400            // accident of `isinstance` ordering — `ExecTimeout` is an `AgentdError`
401            // and is tested before the `TimeoutError` branch — but ERR_TIMEOUT's own
402            // row reads "a client-side deadline elapsed; the VM and the exec are
403            // untouched", which is `ExecTimeout`'s docstring. Corrected here.
404            WireKind::ExecTimeout => ErrorKind::Timeout,
405            // No better row exists: a gap is not the caller's argument, not the
406            // daemon refusing the request, and not retryable — the bytes are gone.
407            // `cli.py` reaches ERR_PLATFORM for it too, and this one is right.
408            WireKind::OutputGap => ErrorKind::Platform,
409            // A condition of the VM rather than of the request, and not retryable
410            // until space is freed: the row the CLI's `sync` already reported it on.
411            WireKind::InsufficientStorage => ErrorKind::Platform,
412        }
413    }
414
415    /// Whether retrying the identical request could plausibly succeed.
416    ///
417    /// The `errors.py` contract, restated as an independent table so the test below
418    /// can compare it against [`Error::retryable`] rather than trusting the two
419    /// agree. Test-only and deliberately not public: a second public answer to
420    /// "is this retryable" is a second thing to keep in step, and the production
421    /// answer must stay derived from [`WireKind::error_kind`] so a new variant
422    /// cannot be retryable in one place and fatal in the other.
423    #[cfg(test)]
424    fn retryable(self) -> bool {
425        matches!(
426            self,
427            WireKind::Transport
428                | WireKind::AuthTokenMint
429                | WireKind::NotBootstrapped
430                | WireKind::RequestTimeout
431                | WireKind::ServerError
432        )
433    }
434}
435
436impl fmt::Display for WireKind {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        f.write_str(self.as_str())
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    /// The thirteen codes, spelled as `cli.py` spells them. Written out rather
447    /// than generated, because a generated list would agree with a typo.
448    #[test]
449    fn every_kind_carries_its_python_err_code() {
450        let codes: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
451        assert_eq!(
452            codes,
453            [
454                "ERR_UNEXPECTED",
455                "ERR_INVALID_ARG",
456                "ERR_RETRYABLE",
457                "ERR_CREDENTIALS",
458                "ERR_PROTOCOL",
459                "ERR_BUILD_WEDGED",
460                "ERR_LAUNCH_DIED",
461                "ERR_WINDOW_CLOSED",
462                "ERR_PLATFORM",
463                "ERR_TIMEOUT",
464                "ERR_INTERRUPTED",
465                "ERR_PRECONDITION",
466                "ERR_EXEC_FAILED",
467            ]
468        );
469    }
470
471    /// Thirteen distinct codes for thirteen kinds. Two kinds sharing a code would
472    /// make the CLI's failure envelope ambiguous, which is the one thing the string
473    /// beside the integer exists to prevent.
474    #[test]
475    fn no_two_kinds_share_a_code() {
476        let mut seen: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
477        seen.sort_unstable();
478        let before = seen.len();
479        seen.dedup();
480        assert_eq!(seen.len(), before, "duplicate ERR_* code");
481    }
482
483    /// `retryable()` reads the kind, and the kind is derived from the wire kind, so
484    /// the only way the two can disagree is a wrong row in `error_kind`. Compared
485    /// against the `errors.py` table restated independently in `WireKind::retryable`.
486    #[test]
487    fn retryable_agrees_with_the_python_exception_contract() {
488        for wire in WireKind::ALL {
489            let err = Error::wire(wire, "detail");
490            assert_eq!(
491                err.retryable(),
492                wire.retryable(),
493                "{wire} disagrees with errors.py on retryability"
494            );
495        }
496    }
497
498    /// Exactly the five `errors.py` marks retryable, named so a sixth added by
499    /// mistake fails here rather than in a retry loop that never terminates.
500    #[test]
501    fn exactly_five_wire_kinds_are_retryable() {
502        let retryable: Vec<&str> = WireKind::ALL
503            .iter()
504            .filter(|w| w.retryable())
505            .map(|w| w.as_str())
506            .collect();
507        assert_eq!(
508            retryable,
509            [
510                "RequestTimeout",
511                "NotBootstrapped",
512                "ServerError",
513                "Transport",
514                "AuthTokenMint"
515            ]
516        );
517    }
518
519    /// A 401 is a credential and never a protocol error, whatever order the
520    /// classification is written in. This is the `isinstance`-ordering trap from
521    /// `cli.py` made unreachable.
522    #[test]
523    fn unauthorized_is_a_credential_failure_not_a_protocol_one() {
524        let err = Error::wire(WireKind::Unauthorized, "401");
525        assert_eq!(err.kind(), ErrorKind::Credentials);
526        assert_eq!(err.code(), "ERR_CREDENTIALS");
527        assert!(!err.retryable());
528    }
529
530    /// The load-bearing distinction: 400 and 404 are different variants, and no
531    /// generic 4xx fallback can produce either. 402 is a 4xx the daemon never
532    /// chooses, and it must map to nothing at all rather than to the nearest
533    /// neighbour.
534    #[test]
535    fn no_generic_four_hundred_fallback_can_produce_a_protocol_error() {
536        assert_eq!(WireKind::from_status(400), Some(WireKind::ProtocolError));
537        assert_eq!(WireKind::from_status(404), Some(WireKind::NotFound));
538        for unmapped in [402, 403, 405, 418, 429, 451] {
539            assert_eq!(
540                WireKind::from_status(unmapped),
541                None,
542                "{unmapped} must not resolve to a daemon-chosen variant"
543            );
544        }
545    }
546
547    /// 5xx *does* fall back, because every 5xx means the same thing to a caller.
548    /// 503 is the exception and keeps its own variant: "come back in a moment" is
549    /// not "the daemon broke". 507 is the other one, below.
550    #[test]
551    fn five_hundreds_fall_back_to_server_error_except_the_bootstrap_one() {
552        assert_eq!(WireKind::from_status(500), Some(WireKind::ServerError));
553        assert_eq!(WireKind::from_status(502), Some(WireKind::ServerError));
554        assert_eq!(WireKind::from_status(506), Some(WireKind::ServerError));
555        assert_eq!(WireKind::from_status(508), Some(WireKind::ServerError));
556        assert_eq!(WireKind::from_status(599), Some(WireKind::ServerError));
557        assert_eq!(WireKind::from_status(503), Some(WireKind::NotBootstrapped));
558    }
559
560    /// A 507 is `InsufficientStorage`, `ERR_PLATFORM`, and not retryable (#256).
561    ///
562    /// The daemon answers 507 when a write would take the filesystem under its disk
563    /// reserve, and it chose 507 over 500 because a client retries a 500: right for a
564    /// defect, harmful for a full disk. The 5xx fallback would have made it
565    /// `ServerError`, one of the retryable five, on every surface at once.
566    ///
567    /// **Falsification**: delete the 507 arm from `from_status` and this is red, since
568    /// 507 then resolves to `ServerError` and its error is `ERR_RETRYABLE`.
569    #[test]
570    fn a_disk_pressure_507_is_insufficient_storage_and_not_retryable() {
571        let wire = WireKind::from_status(507);
572        assert_eq!(
573            wire,
574            Some(WireKind::InsufficientStorage),
575            "507 resolves to {wire:?}"
576        );
577        let err = Error::wire(
578            wire.expect("507 is mapped"),
579            "PUT /v1/fs/file?path=%2Fw -> 507: 4096 bytes available, below the reserve",
580        );
581        assert_eq!(err.kind(), ErrorKind::Platform);
582        assert_eq!(err.code(), "ERR_PLATFORM");
583        assert!(!err.retryable(), "a full disk is not retryable unchanged");
584        assert_eq!(WireKind::InsufficientStorage.status(), Some(507));
585        assert_eq!(
586            WireKind::InsufficientStorage.as_str(),
587            "InsufficientStorage"
588        );
589    }
590
591    /// `Error::retryable` is its kind's answer, for every kind, so a binding that asks
592    /// the kind (it carries only the code) gets the answer a Rust caller gets.
593    #[test]
594    fn an_errors_retryability_is_its_kinds() {
595        for kind in ErrorKind::ALL {
596            assert_eq!(
597                Error::new(kind, "detail").retryable(),
598                kind.retryable(),
599                "{kind}"
600            );
601        }
602        let retryable: Vec<ErrorKind> = ErrorKind::ALL
603            .into_iter()
604            .filter(|kind| kind.retryable())
605            .collect();
606        assert_eq!(retryable, [ErrorKind::Retryable]);
607    }
608
609    /// Every status in the table round-trips: the status a variant reports is the
610    /// status that produces it. Two tables that must agree are one table that will
611    /// not, and this is the cheap way to keep them one.
612    #[test]
613    fn status_and_from_status_are_inverses_where_both_are_defined() {
614        for wire in WireKind::ALL {
615            if let Some(status) = wire.status() {
616                assert_eq!(
617                    WireKind::from_status(status),
618                    Some(wire),
619                    "{wire} reports {status} but {status} resolves elsewhere"
620                );
621            }
622        }
623    }
624
625    /// A local reject has no wire kind: nothing reached the daemon, so there is no
626    /// status to report and the CLI's `data.kind` must be absent rather than
627    /// invented.
628    #[test]
629    fn a_local_reject_carries_no_wire_kind() {
630        let err = Error::invalid_arg("refused before any AWS call");
631        assert_eq!(err.kind(), ErrorKind::InvalidArg);
632        assert_eq!(err.wire_kind(), None);
633        assert_eq!(err.to_string(), "refused before any AWS call");
634    }
635
636    /// The underlying error stays reachable through `source()`, so a caller can
637    /// print a chain rather than a message that already threw the cause away.
638    #[test]
639    fn an_attached_source_survives_into_the_error_trait() {
640        let io = std::io::Error::other("connection reset");
641        let err = Error::new(ErrorKind::Retryable, "endpoint request failed").with_source(io);
642        let source = std::error::Error::source(&err).expect("source is reachable");
643        assert_eq!(source.to_string(), "connection reset");
644    }
645}