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}