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