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}