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