microvms_core/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 matches!(self.kind, ErrorKind::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 /// 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: spawn failure, io failure, a panicking task.
250 ServerError,
251 /// The request never produced a status: connection refused, reset, timeout.
252 ///
253 /// Retryable because it says nothing about the daemon's state. A VM that has
254 /// just reached RUNNING commonly refuses a connection or two before the proxy
255 /// path is wired up.
256 Transport,
257 /// `CreateMicrovmAuthToken` failed.
258 ///
259 /// Retryable, and load-bearing rather than optimistic: proxy tokens expire at
260 /// 60 minutes, so a long run *will* mint mid-flight, and a throttle at that
261 /// moment must not kill a trial that is otherwise healthy.
262 AuthTokenMint,
263 /// A client-side wait or stream deadline elapsed. The exec is untouched.
264 ExecTimeout,
265 /// Output bytes are gone for good — the replay ring evicted them, or this
266 /// subscriber lagged the live channel.
267 OutputGap,
268}
269
270impl WireKind {
271 /// Every variant, so a test can assert the mapping is total.
272 pub const ALL: [WireKind; 13] = [
273 WireKind::Unauthorized,
274 WireKind::ProtocolError,
275 WireKind::NotFound,
276 WireKind::Conflict,
277 WireKind::StdinClosed,
278 WireKind::TooLarge,
279 WireKind::RequestTimeout,
280 WireKind::NotBootstrapped,
281 WireKind::ServerError,
282 WireKind::Transport,
283 WireKind::AuthTokenMint,
284 WireKind::ExecTimeout,
285 WireKind::OutputGap,
286 ];
287
288 /// The stable name the CLI puts in `data.kind`, spelled as the Python class.
289 ///
290 /// The conformance oracle compares against these strings, so they are the
291 /// exception class names from `errors.py` and not a re-spelling of them.
292 pub fn as_str(self) -> &'static str {
293 match self {
294 WireKind::Unauthorized => "Unauthorized",
295 WireKind::ProtocolError => "ProtocolError",
296 WireKind::NotFound => "NotFound",
297 WireKind::Conflict => "Conflict",
298 WireKind::StdinClosed => "StdinClosed",
299 WireKind::TooLarge => "TooLarge",
300 WireKind::RequestTimeout => "RequestTimeout",
301 WireKind::NotBootstrapped => "NotBootstrapped",
302 WireKind::ServerError => "ServerError",
303 WireKind::Transport => "Transport",
304 WireKind::AuthTokenMint => "AuthTokenMint",
305 WireKind::ExecTimeout => "ExecTimeout",
306 WireKind::OutputGap => "OutputGap",
307 }
308 }
309
310 /// The status that produces this variant, for the nine that come from one.
311 ///
312 /// `None` for the four that have no status: two never got a response
313 /// ([`WireKind::Transport`], [`WireKind::AuthTokenMint`]) and two are client-side
314 /// facts ([`WireKind::ExecTimeout`], [`WireKind::OutputGap`]).
315 pub fn status(self) -> Option<u16> {
316 match self {
317 WireKind::ProtocolError => Some(400),
318 WireKind::Unauthorized => Some(401),
319 WireKind::NotFound => Some(404),
320 WireKind::RequestTimeout => Some(408),
321 WireKind::Conflict => Some(409),
322 WireKind::StdinClosed => Some(410),
323 WireKind::TooLarge => Some(413),
324 WireKind::ServerError => Some(500),
325 WireKind::NotBootstrapped => Some(503),
326 WireKind::Transport
327 | WireKind::AuthTokenMint
328 | WireKind::ExecTimeout
329 | WireKind::OutputGap => None,
330 }
331 }
332
333 /// The variant a response status means, or `None` for a status the daemon does
334 /// not use to mean anything.
335 ///
336 /// The table is explicit and there is deliberately **no generic 4xx fallback**.
337 /// The daemon's whole point is that 400 and 404 mean different things, and a
338 /// fallback that mapped "some 4xx" to one variant would reintroduce the
339 /// phantom-missing-file defect that hid in the Python client for a review
340 /// round. 5xx does fall back — to [`WireKind::ServerError`], which is what
341 /// `errors.py` does — because every 5xx means the same thing to a caller:
342 /// the daemon broke, try again.
343 pub fn from_status(status: u16) -> Option<WireKind> {
344 match status {
345 400 => Some(WireKind::ProtocolError),
346 401 => Some(WireKind::Unauthorized),
347 404 => Some(WireKind::NotFound),
348 408 => Some(WireKind::RequestTimeout),
349 409 => Some(WireKind::Conflict),
350 410 => Some(WireKind::StdinClosed),
351 413 => Some(WireKind::TooLarge),
352 503 => Some(WireKind::NotBootstrapped),
353 s if s >= 500 => Some(WireKind::ServerError),
354 _ => None,
355 }
356 }
357
358 /// The exit-code class this collapses onto.
359 ///
360 /// The ordering `cli.py`'s `classify` documents as "the contract" is a `match`
361 /// here rather than a sequence of `isinstance` tests, which removes the way it
362 /// could be got wrong: [`WireKind::Unauthorized`] is an HTTP error whose remedy
363 /// is a credential rather than a wait, and in Python it had to be *checked
364 /// before* the generic retryable test to avoid being swallowed by it. A match on
365 /// a closed enum has no order to get wrong.
366 pub fn error_kind(self) -> ErrorKind {
367 match self {
368 // A credential, not a wait. Retrying a 401 forever is one of the two
369 // mistakes the Python ordering existed to prevent.
370 WireKind::Unauthorized => ErrorKind::Credentials,
371 // The five retryable conditions from `errors.py`. Failing a launch that
372 // was 200 ms from ready is the other mistake.
373 WireKind::Transport
374 | WireKind::AuthTokenMint
375 | WireKind::NotBootstrapped
376 | WireKind::RequestTimeout
377 | WireKind::ServerError => ErrorKind::Retryable,
378 // The daemon rejected the request on its merits. Four classes, one exit
379 // code: a shell cannot act differently on them, and a caller that can
380 // reads `data.kind`.
381 WireKind::ProtocolError
382 | WireKind::NotFound
383 | WireKind::Conflict
384 | WireKind::StdinClosed
385 | WireKind::TooLarge => ErrorKind::Protocol,
386 // A client-side deadline. `cli.py` reaches ERR_PLATFORM for this by
387 // accident of `isinstance` ordering — `ExecTimeout` is an `AgentdError`
388 // and is tested before the `TimeoutError` branch — but ERR_TIMEOUT's own
389 // row reads "a client-side deadline elapsed; the VM and the exec are
390 // untouched", which is `ExecTimeout`'s docstring. Corrected here.
391 WireKind::ExecTimeout => ErrorKind::Timeout,
392 // No better row exists: a gap is not the caller's argument, not the
393 // daemon refusing the request, and not retryable — the bytes are gone.
394 // `cli.py` reaches ERR_PLATFORM for it too, and this one is right.
395 WireKind::OutputGap => ErrorKind::Platform,
396 }
397 }
398
399 /// Whether retrying the identical request could plausibly succeed.
400 ///
401 /// The `errors.py` contract, restated as an independent table so the test below
402 /// can compare it against [`Error::retryable`] rather than trusting the two
403 /// agree. Test-only and deliberately not public: a second public answer to
404 /// "is this retryable" is a second thing to keep in step, and the production
405 /// answer must stay derived from [`WireKind::error_kind`] so a new variant
406 /// cannot be retryable in one place and fatal in the other.
407 #[cfg(test)]
408 fn retryable(self) -> bool {
409 matches!(
410 self,
411 WireKind::Transport
412 | WireKind::AuthTokenMint
413 | WireKind::NotBootstrapped
414 | WireKind::RequestTimeout
415 | WireKind::ServerError
416 )
417 }
418}
419
420impl fmt::Display for WireKind {
421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422 f.write_str(self.as_str())
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 /// The thirteen codes, spelled as `cli.py` spells them. Written out rather
431 /// than generated, because a generated list would agree with a typo.
432 #[test]
433 fn every_kind_carries_its_python_err_code() {
434 let codes: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
435 assert_eq!(
436 codes,
437 [
438 "ERR_UNEXPECTED",
439 "ERR_INVALID_ARG",
440 "ERR_RETRYABLE",
441 "ERR_CREDENTIALS",
442 "ERR_PROTOCOL",
443 "ERR_BUILD_WEDGED",
444 "ERR_LAUNCH_DIED",
445 "ERR_WINDOW_CLOSED",
446 "ERR_PLATFORM",
447 "ERR_TIMEOUT",
448 "ERR_INTERRUPTED",
449 "ERR_PRECONDITION",
450 "ERR_EXEC_FAILED",
451 ]
452 );
453 }
454
455 /// Thirteen distinct codes for thirteen kinds. Two kinds sharing a code would
456 /// make the CLI's failure envelope ambiguous, which is the one thing the string
457 /// beside the integer exists to prevent.
458 #[test]
459 fn no_two_kinds_share_a_code() {
460 let mut seen: Vec<&str> = ErrorKind::ALL.iter().map(|k| k.code()).collect();
461 seen.sort_unstable();
462 let before = seen.len();
463 seen.dedup();
464 assert_eq!(seen.len(), before, "duplicate ERR_* code");
465 }
466
467 /// `retryable()` reads the kind, and the kind is derived from the wire kind, so
468 /// the only way the two can disagree is a wrong row in `error_kind`. Compared
469 /// against the `errors.py` table restated independently in `WireKind::retryable`.
470 #[test]
471 fn retryable_agrees_with_the_python_exception_contract() {
472 for wire in WireKind::ALL {
473 let err = Error::wire(wire, "detail");
474 assert_eq!(
475 err.retryable(),
476 wire.retryable(),
477 "{wire} disagrees with errors.py on retryability"
478 );
479 }
480 }
481
482 /// Exactly the five `errors.py` marks retryable, named so a sixth added by
483 /// mistake fails here rather than in a retry loop that never terminates.
484 #[test]
485 fn exactly_five_wire_kinds_are_retryable() {
486 let retryable: Vec<&str> = WireKind::ALL
487 .iter()
488 .filter(|w| w.retryable())
489 .map(|w| w.as_str())
490 .collect();
491 assert_eq!(
492 retryable,
493 [
494 "RequestTimeout",
495 "NotBootstrapped",
496 "ServerError",
497 "Transport",
498 "AuthTokenMint"
499 ]
500 );
501 }
502
503 /// A 401 is a credential and never a protocol error, whatever order the
504 /// classification is written in. This is the `isinstance`-ordering trap from
505 /// `cli.py` made unreachable.
506 #[test]
507 fn unauthorized_is_a_credential_failure_not_a_protocol_one() {
508 let err = Error::wire(WireKind::Unauthorized, "401");
509 assert_eq!(err.kind(), ErrorKind::Credentials);
510 assert_eq!(err.code(), "ERR_CREDENTIALS");
511 assert!(!err.retryable());
512 }
513
514 /// The load-bearing distinction: 400 and 404 are different variants, and no
515 /// generic 4xx fallback can produce either. 402 is a 4xx the daemon never
516 /// chooses, and it must map to nothing at all rather than to the nearest
517 /// neighbour.
518 #[test]
519 fn no_generic_four_hundred_fallback_can_produce_a_protocol_error() {
520 assert_eq!(WireKind::from_status(400), Some(WireKind::ProtocolError));
521 assert_eq!(WireKind::from_status(404), Some(WireKind::NotFound));
522 for unmapped in [402, 403, 405, 418, 429, 451] {
523 assert_eq!(
524 WireKind::from_status(unmapped),
525 None,
526 "{unmapped} must not resolve to a daemon-chosen variant"
527 );
528 }
529 }
530
531 /// 5xx *does* fall back, because every 5xx means the same thing to a caller.
532 /// 503 is the exception and keeps its own variant: "come back in a moment" is
533 /// not "the daemon broke".
534 #[test]
535 fn five_hundreds_fall_back_to_server_error_except_the_bootstrap_one() {
536 assert_eq!(WireKind::from_status(500), Some(WireKind::ServerError));
537 assert_eq!(WireKind::from_status(502), Some(WireKind::ServerError));
538 assert_eq!(WireKind::from_status(599), Some(WireKind::ServerError));
539 assert_eq!(WireKind::from_status(503), Some(WireKind::NotBootstrapped));
540 }
541
542 /// Every status in the table round-trips: the status a variant reports is the
543 /// status that produces it. Two tables that must agree are one table that will
544 /// not, and this is the cheap way to keep them one.
545 #[test]
546 fn status_and_from_status_are_inverses_where_both_are_defined() {
547 for wire in WireKind::ALL {
548 if let Some(status) = wire.status() {
549 assert_eq!(
550 WireKind::from_status(status),
551 Some(wire),
552 "{wire} reports {status} but {status} resolves elsewhere"
553 );
554 }
555 }
556 }
557
558 /// A local reject has no wire kind: nothing reached the daemon, so there is no
559 /// status to report and the CLI's `data.kind` must be absent rather than
560 /// invented.
561 #[test]
562 fn a_local_reject_carries_no_wire_kind() {
563 let err = Error::invalid_arg("refused before any AWS call");
564 assert_eq!(err.kind(), ErrorKind::InvalidArg);
565 assert_eq!(err.wire_kind(), None);
566 assert_eq!(err.to_string(), "refused before any AWS call");
567 }
568
569 /// The underlying error stays reachable through `source()`, so a caller can
570 /// print a chain rather than a message that already threw the cause away.
571 #[test]
572 fn an_attached_source_survives_into_the_error_trait() {
573 let io = std::io::Error::other("connection reset");
574 let err = Error::new(ErrorKind::Retryable, "endpoint request failed").with_source(io);
575 let source = std::error::Error::source(&err).expect("source is reachable");
576 assert_eq!(source.to_string(), "connection reset");
577 }
578}