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