microvms_app/control/transport.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The control plane's rest-json calls, and the seam a transport implements.
3//!
4//! # Why this is hand-rolled
5//!
6//! `lambda-microvms` has no `aws-sdk-rust` crate. The alternatives were vendoring
7//! smithy-rs codegen — Gradle, JDK 17, Kotlin, and a project that declares its external
8//! interfaces unstable — or signing 24 rest-json operations by hand with the first-party
9//! low-level crates, which is the flow the AWS SDK maintainers point at for exactly this
10//! case. rest-json is also the easiest protocol to hand-roll: plain JSON bodies,
11//! URI-bound operations, standard headers, no XML and no query-protocol encoding.
12//!
13//! # The seam, and what the fake is allowed to know
14//!
15//! [`Transport`] is a trait with one method. The real implementation signs and sends;
16//! the test implementation is a **contract recorder** that asserts on the emitted
17//! request in the shape the service model declares.
18//!
19//! The rule the recorder follows is the lesson from the `buildState` bug: a fake that
20//! shares the client's assumptions hides exactly the class of defect the fake exists to
21//! catch. So the recorder never asserts on a value the client computed, and every
22//! response it hands back is **literal JSON in the model's spelling** — see
23//! [`crate::control::ops`] for why that is not a round trip through this crate's own
24//! serializer.
25//!
26//! # Local validation is not optional
27//!
28//! botocore's `VALIDATED_METADATA_ATTRS` is `{required, min, document, union}`, so
29//! `max`, `pattern`, and `enum` violations are serialized, sent, and answered with a
30//! `ValidationException` — confirmed empirically for `max` (runHookPayload 4097,
31//! maximumDurationInSeconds 28801, ImageName 65 chars, clientToken 129 chars), for
32//! `pattern`, and for `enum`. The same holds here with more force: **nothing** in this
33//! transport reads the service model at runtime, so every constraint is checked in the
34//! caller before a request is built or it is not checked at all.
35
36use crate::error::{Error, ErrorKind};
37use crate::region::Region;
38
39/// The pinned API version, which is also the first path segment of every operation.
40///
41/// Read from [`crate::constants::MODEL_API_VERSION`] rather than written again, so the
42/// drift gate's version check and the URLs cannot disagree: a client signing
43/// `/2025-09-09/...` while claiming to implement a different model is a client whose
44/// constraint checks were verified against the wrong thing.
45const API_PATH_VERSION: &str = crate::constants::MODEL_API_VERSION;
46
47/// The SigV4 signing name, from the model's `signingName`.
48///
49/// `lambda`, not `lambda-microvms`. The `endpointPrefix` is also `lambda`, which is why
50/// an endpoint resolves for every AWS region and a client constructs happily for a region
51/// that does not carry MicroVMs (TRAP-6, `crate::region`).
52pub const SIGNING_NAME: &str = "lambda";
53
54/// One control-plane call: method, path, optional JSON body.
55///
56/// A struct rather than a long parameter list because the recorder asserts on it as a
57/// whole, and because `method` and `path` next to each other is how a reader checks a
58/// path against the model's `http` trait.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct Call {
61 /// The operation name, for diagnostics and for the recorder's ledger.
62 ///
63 /// Carried rather than derived from the path: the recorder's TRAP-11 assertion is
64 /// "an exec lifecycle emits no call named `CreateMicrovmShellAuthToken`", and
65 /// matching that on a path is a substring test that a renamed route would slip past.
66 pub operation: &'static str,
67 /// `GET`, `POST`, `PATCH`, or `DELETE` — the four the model's `http` traits use for the
68 /// operations this client implements.
69 pub method: Method,
70 /// The path **after** the host, with the API version and every URI parameter already
71 /// percent-encoded. Includes the query string when the operation has one.
72 pub path: String,
73 /// The rest-json body, or `None` for an operation the model gives no body members.
74 pub body: Option<Vec<u8>>,
75}
76
77/// The HTTP methods the model's operations use.
78///
79/// An enum so a call site cannot pass `"post"` and have the canonical request silently
80/// disagree with the wire method.
81///
82/// # `Patch` is one operation's, and only one
83///
84/// The model uses `PATCH` for exactly `UpdateMicrovmImageVersion` and `PUT` for exactly
85/// `UpdateMicrovmImage`. `Put` is deliberately absent: this client does not implement that
86/// operation, and a variant nothing constructs is a variant that suggests it does.
87/// `PATCH` is not interchangeable with `POST` here — a `POST` to the version path is a route
88/// the service does not declare, and the answer is a 404 that reads as a missing version
89/// rather than as a wrong method.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum Method {
92 Get,
93 Post,
94 Patch,
95 Delete,
96}
97
98impl Method {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Method::Get => "GET",
102 Method::Post => "POST",
103 Method::Patch => "PATCH",
104 Method::Delete => "DELETE",
105 }
106 }
107}
108
109impl Call {
110 /// A `GET` with no body.
111 pub fn get(operation: &'static str, path: impl Into<String>) -> Self {
112 Self {
113 operation,
114 method: Method::Get,
115 path: path.into(),
116 body: None,
117 }
118 }
119
120 /// A `POST` carrying a serialized rest-json body.
121 pub fn post_json<T: serde::Serialize>(
122 operation: &'static str,
123 path: impl Into<String>,
124 body: &T,
125 ) -> Result<Self, Error> {
126 let body = serde_json::to_vec(body).map_err(|error| {
127 Error::new(
128 ErrorKind::Unexpected,
129 format!("could not serialize the {operation} request body: {error}"),
130 )
131 })?;
132 Ok(Self {
133 operation,
134 method: Method::Post,
135 path: path.into(),
136 body: Some(body),
137 })
138 }
139
140 /// A `PATCH` carrying a serialized rest-json body.
141 ///
142 /// The same shape as [`Call::post_json`] and written beside it rather than folded into a
143 /// method-taking constructor, for the reason [`Method`]'s docs give: the method is part of
144 /// the route the model declares, and a `with_method(Method::Patch)` builder is one a call
145 /// site can forget to call. `UpdateMicrovmImageVersion` is the only operation that reaches
146 /// this.
147 pub fn patch_json<T: serde::Serialize>(
148 operation: &'static str,
149 path: impl Into<String>,
150 body: &T,
151 ) -> Result<Self, Error> {
152 let body = serde_json::to_vec(body).map_err(|error| {
153 Error::new(
154 ErrorKind::Unexpected,
155 format!("could not serialize the {operation} request body: {error}"),
156 )
157 })?;
158 Ok(Self {
159 operation,
160 method: Method::Patch,
161 path: path.into(),
162 body: Some(body),
163 })
164 }
165
166 /// A `POST` with no body members. `SuspendMicrovm` and `ResumeMicrovm` are both this
167 /// shape: their only member is the URI-located identifier.
168 ///
169 /// The body is `Some(b"{}")` rather than `None`, because an empty POST and a POST of
170 /// `{}` are different canonical requests and rest-json services answer the former
171 /// inconsistently.
172 pub fn post_empty(operation: &'static str, path: impl Into<String>) -> Self {
173 Self {
174 operation,
175 method: Method::Post,
176 path: path.into(),
177 body: Some(b"{}".to_vec()),
178 }
179 }
180
181 /// A `DELETE` with no body.
182 pub fn delete(operation: &'static str, path: impl Into<String>) -> Self {
183 Self {
184 operation,
185 method: Method::Delete,
186 path: path.into(),
187 body: None,
188 }
189 }
190
191 /// The body as bytes, empty when there is none — which is what gets signed.
192 pub fn body_bytes(&self) -> &[u8] {
193 self.body.as_deref().unwrap_or(&[])
194 }
195}
196
197/// What the control plane answered: a status, a raw body, and the error type the service
198/// named, if it named one.
199///
200/// Raw bytes rather than a deserialized value because the error path has to read a body
201/// the success types cannot hold, and because TRAP-6's null `message` field is only
202/// visible to something that did not already require a `String` there.
203#[derive(Clone, Debug)]
204pub struct Reply {
205 pub status: u16,
206 pub body: Vec<u8>,
207 /// The `x-amzn-ErrorType` header's error name, cut down by [`error_type_name`], or
208 /// `None` when the response carried no such header. [`classify_failure`] reads it
209 /// before the status, because the service has sent a throttle as HTTP 400.
210 pub error_type: Option<String>,
211}
212
213impl Reply {
214 /// Deserializes the body as `T`, or reports the failure with the body attached.
215 ///
216 /// The body goes in the message because a rest-json parse failure with the text
217 /// withheld is unactionable: the reader cannot tell a renamed member from a truncated
218 /// response. Truncated to 512 bytes, since a list response can be large and the first
219 /// 512 characters always contain the member that failed.
220 pub fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
221 serde_json::from_slice(&self.body).map_err(|error| {
222 let text = String::from_utf8_lossy(&self.body);
223 let shown: String = text.chars().take(512).collect();
224 Error::new(
225 ErrorKind::Platform,
226 format!(
227 "could not read the {operation} response ({error}). The service model for \
228 {API_PATH_VERSION} is what this client's shapes are transcribed from, so a \
229 member that has moved is a drift-gate failure rather than a transport bug. \
230 Body: {shown}"
231 ),
232 )
233 .with_source(error)
234 })
235 }
236
237 /// Whether the status is a success. rest-json operations here answer 200 or 201.
238 fn is_success(&self) -> bool {
239 (200..300).contains(&self.status)
240 }
241}
242
243/// The one seam every operation goes through.
244///
245/// One method, so a fake is a few lines and there is no partially-overridden transport
246/// whose behaviour is half real. `&self` rather than `&mut self` so the recorder can be
247/// shared behind an `Arc` while a lifecycle runs.
248pub trait Transport: Send + Sync {
249 /// Performs `call`, returning whatever the far side answered.
250 ///
251 /// A transport-level failure (no status at all) is an `Err`; a 4xx or 5xx is an
252 /// `Ok(Reply)` carrying the status, because classifying a status is
253 /// [`classify_failure`]'s job and a transport that classified would be a second
254 /// place the taxonomy lives.
255 fn send(
256 &self,
257 call: Call,
258 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>;
259
260 /// Resolves the credentials the next [`Transport::send`] would sign with, sending nothing
261 /// to the service (BIND-15's credentials check).
262 ///
263 /// Resolving may reach an SSO, credential-process, or instance-metadata source, none of
264 /// which bills. The default answers `Ok`: a transport that does not sign (every scripted
265 /// one) has nothing to resolve.
266 fn resolve_credentials(
267 &self,
268 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
269 Box::pin(async { Ok(()) })
270 }
271}
272
273/// Sends `call` through `transport`, retrying the retryable statuses, and turns a failure
274/// status into a classified [`Error`].
275///
276/// # Which failures retry
277///
278/// 429 (`ThrottlingException`), 500 (`InternalServerException`), and any other 5xx, plus
279/// a transport error that produced no status. AWS standard-mode semantics, matched by
280/// hand because the smithy retry pieces are coupled to an orchestrator this client does
281/// not have.
282///
283/// # Why retrying the mutating operations is safe
284///
285/// Every mutating operation this client sends carries a `clientToken`, so a retry is
286/// idempotent by the service's own contract — which is the same property that makes the
287/// token dangerous to derive (TRAP-1). `SuspendMicrovm`/`ResumeMicrovm`/`TerminateMicrovm`
288/// take no token and are retried anyway: they are state transitions whose second
289/// application is a no-op or a `ConflictException`, and a conflict is not retried.
290pub async fn send_with_retry(transport: &dyn Transport, call: Call) -> Result<Reply, Error> {
291 use backon::{ExponentialBuilder, Retryable};
292
293 let operation = call.operation;
294 let attempt = || {
295 let call = call.clone();
296 async move {
297 let reply = transport.send(call).await?;
298 if reply.is_success() {
299 return Ok(reply);
300 }
301 Err(classify_failure(operation, &reply))
302 }
303 };
304
305 attempt
306 .retry(
307 ExponentialBuilder::default()
308 .with_jitter()
309 .with_min_delay(std::time::Duration::from_millis(200))
310 .with_max_delay(std::time::Duration::from_secs(20))
311 .with_max_times(5),
312 )
313 .when(Error::retryable)
314 .await
315}
316
317/// [`send_with_retry`], answering `Ok` for the statuses in `accept` as well as for a
318/// success.
319///
320/// For a call whose failure status is an answer rather than an error: `GetMicrovmImage`'s 404
321/// is "there is no image under this name", which `ensure_image` reads as a state
322/// ([`crate::control::ensure`]). Retries are unchanged — a throttle or a 5xx is still
323/// retried — and every other status is still classified by [`classify_failure`].
324pub async fn send_accepting(
325 transport: &dyn Transport,
326 call: Call,
327 accept: &[u16],
328) -> Result<Reply, Error> {
329 use backon::{ExponentialBuilder, Retryable};
330
331 let operation = call.operation;
332 let attempt = || {
333 let call = call.clone();
334 async move {
335 let reply = transport.send(call).await?;
336 if reply.is_success() || accept.contains(&reply.status) {
337 return Ok(reply);
338 }
339 Err(classify_failure(operation, &reply))
340 }
341 };
342
343 attempt
344 .retry(
345 ExponentialBuilder::default()
346 .with_jitter()
347 .with_min_delay(std::time::Duration::from_millis(200))
348 .with_max_delay(std::time::Duration::from_secs(20))
349 .with_max_times(5),
350 )
351 .when(Error::retryable)
352 .await
353}
354
355/// Turns a failure status and body into an [`Error`] of the right kind.
356///
357/// # The statuses are the control plane's, not the daemon's
358///
359/// [`crate::error::WireKind`] is the *daemon's* status discipline and deliberately has no
360/// generic 4xx fallback, because the daemon's 400-versus-404 distinction is load-bearing.
361/// None of that applies here: these seven statuses come from the modeled exception shapes
362/// (`ValidationException` 400, `ServiceQuotaExceededException` 402,
363/// `AccessDeniedException` 403, `ResourceNotFoundException` 404, `ConflictException` 409,
364/// `ThrottlingException` 429, `InternalServerException` 500). So a control-plane failure
365/// carries an [`ErrorKind`] and **no** `WireKind` — which is what `error.rs` already
366/// documents: "`None` for every local reject and every control-plane failure — those never
367/// reached the in-VM daemon".
368///
369/// # 403 and the null message
370///
371/// A 403 whose `message` is null is the unsupported-region signature (TRAP-6). The
372/// message says so, because the alternative — forwarding a null as "AccessDenied: " — is
373/// precisely what sends someone to audit an IAM policy that is fine.
374pub fn classify_failure(operation: &str, reply: &Reply) -> Error {
375 let parsed: crate::control::ops::ServiceErrorWire =
376 serde_json::from_slice(&reply.body).unwrap_or_default();
377 let detail = parsed.message.as_deref().unwrap_or("").trim().to_string();
378
379 let (kind, explanation) = match reply.status {
380 // The error type outranks the status. A throttled `ListMicrovms` answered HTTP 400
381 // with `x-amzn-ErrorType: ThrottlingException`, where the model says 429
382 // (docs/PLATFORM.md, measured 2026-10-02), and read by status alone it was a
383 // validation refusal that nothing retried.
384 status if reply.error_type.as_deref() == Some("ThrottlingException") => (
385 ErrorKind::Retryable,
386 format!(
387 "ThrottlingException, named by the x-amzn-ErrorType header on an HTTP {status} \
388 — retry the identical request."
389 ),
390 ),
391 400 => (
392 ErrorKind::Platform,
393 "ValidationException — the service refused a value. Every constraint the pinned \
394 service model states on a member this client sends is checked locally before the \
395 call — min as well as max, pattern, and enum, because nothing else validates a \
396 request here: this client signs with aws-sigv4 and sends with reqwest, so botocore's \
397 VALIDATED_METADATA_ATTRS never applies to it. So reaching this status means a \
398 constraint this client does not yet check, and closing it is a guard in \
399 crates/microvms-app/src/control plus a line in tools/check-model-drift.py."
400 .to_string(),
401 ),
402 402 => (
403 ErrorKind::Platform,
404 "ServiceQuotaExceededException — an account limit, not a request defect.".to_string(),
405 ),
406 403 if detail.is_empty() => (
407 ErrorKind::Credentials,
408 "AccessDeniedException with an empty or null message field. Measured 2026-08-07: this \
409 is the signature of a region that does not carry MicroVMs, and it is \
410 indistinguishable from a genuine IAM denial except that a real denial names the \
411 principal and the action (docs/PLATFORM.md, 'Calling an unpriced region returns \
412 AccessDeniedException with a null message'). Check the region before the policy."
413 .to_string(),
414 ),
415 403 => (
416 ErrorKind::Credentials,
417 "AccessDeniedException, with a message — so this is a genuine denial rather than the \
418 null-message region trap."
419 .to_string(),
420 ),
421 404 => (
422 ErrorKind::Platform,
423 "ResourceNotFoundException — the image or MicroVM does not exist.".to_string(),
424 ),
425 409 => (
426 ErrorKind::Platform,
427 "ConflictException — the resource is in a state that forbids this call. An image in \
428 CREATING refuses deletion, which is why teardown retries."
429 .to_string(),
430 ),
431 429 => (
432 ErrorKind::Retryable,
433 "ThrottlingException — retry the identical request.".to_string(),
434 ),
435 status if status >= 500 => (
436 ErrorKind::Retryable,
437 "InternalServerException — a service fault, retry the identical request.".to_string(),
438 ),
439 _ => (
440 ErrorKind::Platform,
441 "an unmodeled status: the service model declares 400, 402, 403, 404, 409, 429, and \
442 500 for these operations."
443 .to_string(),
444 ),
445 };
446
447 let said = if detail.is_empty() {
448 "the service sent no message".to_string()
449 } else {
450 format!("the service said {detail:?}")
451 };
452 Error::new(
453 kind,
454 format!(
455 "{operation} failed with HTTP {}: {said}. {explanation}",
456 reply.status
457 ),
458 )
459}
460
461/// The error name in an `x-amzn-ErrorType` header value: what precedes the first `:` and
462/// follows the first `#`.
463///
464/// That is the rest-json protocol's rule, and both parts occur here: the service's throttle
465/// arrived as `ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/`,
466/// and other services send a `namespace#Name` form.
467pub fn error_type_name(header: &str) -> &str {
468 let name = header.split(':').next().unwrap_or(header);
469 name.split_once('#').map_or(name, |(_, after)| after).trim()
470}
471
472/// `https://lambda.<region>.amazonaws.com`, from the model's `endpointPrefix`.
473///
474/// The prefix is `lambda` rather than `lambda-microvms`, which is why this resolves for
475/// every AWS region including the ones that answer the null-message denial.
476pub fn endpoint_for(region: &Region) -> String {
477 format!("https://{SIGNING_NAME}.{}.amazonaws.com", region.as_str())
478}
479
480/// The operation paths, one function per `http.requestUri` in the model.
481///
482/// Here rather than inlined at each call site so a path is written once and can be read
483/// against the model in one place. Every URI parameter is percent-encoded, because an
484/// image identifier may be an ARN — which contains `:` and `/` — and an unencoded ARN in a
485/// path segment is a different resource.
486pub mod paths {
487 use super::API_PATH_VERSION;
488
489 /// Percent-encodes a URI path segment.
490 ///
491 /// Everything outside RFC 3986's unreserved set is escaped. **`/` is encoded**,
492 /// which is the whole point: an image ARN's slashes would otherwise split into
493 /// extra path segments and address something else. `:` is encoded too — legal
494 /// unencoded in a path per the RFC, but AWS's own SDKs encode it in labels and
495 /// the signature must match whatever is sent.
496 pub fn encode_segment(segment: &str) -> String {
497 use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
498 const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
499 .remove(b'-')
500 .remove(b'_')
501 .remove(b'.')
502 .remove(b'~');
503 utf8_percent_encode(segment, ESCAPED).to_string()
504 }
505
506 /// `POST /2025-09-09/microvm-images`
507 pub fn microvm_images() -> String {
508 format!("/{API_PATH_VERSION}/microvm-images")
509 }
510
511 /// `GET /2025-09-09/microvm-images`, with the listing's query members.
512 ///
513 /// `nameFilter` is the model's server-side **substring** filter — it narrows the
514 /// listing, it does not answer "the image named X", so an exact-match comparison
515 /// still happens in the caller. `nextToken` is the pagination cursor and is opaque,
516 /// which is why both values go through [`encode_segment`]: an opaque token may carry
517 /// `+` or `=`, and an unencoded one desynchronises the SigV4 canonical query from
518 /// the query actually sent.
519 pub fn microvm_images_list(name_filter: Option<&str>, next_token: Option<&str>) -> String {
520 let mut query: Vec<String> = Vec::new();
521 if let Some(filter) = name_filter {
522 query.push(format!("nameFilter={}", encode_segment(filter)));
523 }
524 if let Some(token) = next_token {
525 query.push(format!("nextToken={}", encode_segment(token)));
526 }
527 if query.is_empty() {
528 return microvm_images();
529 }
530 format!("{}?{}", microvm_images(), query.join("&"))
531 }
532
533 /// `GET|DELETE /2025-09-09/microvm-images/{imageIdentifier}`
534 pub fn microvm_image(image: &str) -> String {
535 format!(
536 "/{API_PATH_VERSION}/microvm-images/{}",
537 encode_segment(image)
538 )
539 }
540
541 /// One `nextToken=` query member, appended to `path`, or `path` unchanged.
542 ///
543 /// Shared by the three listings that take nothing else, so the encoding decision is
544 /// made once. The value goes through [`encode_segment`] for the reason
545 /// [`microvm_images_list`] states: a pagination token is **opaque**, so it may carry
546 /// `+`, `/`, or `=`, and an unencoded one desynchronises the SigV4 canonical query
547 /// from the query actually sent — a rejection that reads like bad credentials rather
548 /// than like a malformed URL.
549 fn with_next_token(path: String, next_token: Option<&str>) -> String {
550 match next_token {
551 Some(token) => format!("{path}?nextToken={}", encode_segment(token)),
552 None => path,
553 }
554 }
555
556 /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions`, with the pagination
557 /// cursor when there is one.
558 ///
559 /// `maxResults` is not sent: the model caps it at 50 and the service's own default is
560 /// what a caller reading every page wants, so naming a page size would only be a
561 /// second number to keep right. [`image_versions_paged`] is the one caller that does
562 /// name one, and says why.
563 pub fn image_versions(image: &str, next_token: Option<&str>) -> String {
564 image_versions_paged(image, next_token, None)
565 }
566
567 /// [`image_versions`] with an explicit `maxResults`, which **no production path sends**.
568 ///
569 /// # Why a page size exists at all when nothing ships one
570 ///
571 /// Because without it the cursor encoding is unfalsifiable against the real service,
572 /// and that encoding is the one place in this module where being wrong reads as a
573 /// *credentials* failure rather than a URL one. A real `nextToken` is opaque base64
574 /// carrying `+`, `/`, and `=`; an unencoded one desynchronises the SigV4 canonical
575 /// query and the service answers 403. Every test that could catch it went through an
576 /// injected transport, so the fake minted the token and the signer never saw it.
577 ///
578 /// Forcing a real cursor needs a listing with more items than one page, and the
579 /// service's default page is larger than any image's version count here — so the only
580 /// way to make the real service mint a real cursor for a resource that already exists
581 /// is to ask for a smaller page. That is what this is for, and
582 /// `crates/microvms-core/tests/live_pagination.rs` is its caller.
583 ///
584 /// # Why it is not simply a parameter on [`image_versions`]
585 ///
586 /// A page size on the production path is a second number to keep right, and a wrong
587 /// one is invisible: reading every page with `maxResults=1` is correct and slow, which
588 /// is the kind of defect that survives. Keeping it to a separately named function means
589 /// the production call sites cannot acquire one by accident, and a reader of
590 /// [`image_versions`] sees no page size to wonder about.
591 ///
592 /// `max_results` is clamped to the model's `1..=50`, because a value outside it is a
593 /// `ValidationException` and a caller of this function is trying to observe pagination
594 /// rather than to discover the bound.
595 pub fn image_versions_paged(
596 image: &str,
597 next_token: Option<&str>,
598 max_results: Option<u32>,
599 ) -> String {
600 let mut query: Vec<String> = Vec::new();
601 if let Some(size) = max_results {
602 query.push(format!("maxResults={}", size.clamp(1, 50)));
603 }
604 if let Some(token) = next_token {
605 query.push(format!("nextToken={}", encode_segment(token)));
606 }
607 let path = format!("{}/versions", microvm_image(image));
608 if query.is_empty() {
609 return path;
610 }
611 format!("{path}?{}", query.join("&"))
612 }
613
614 /// `GET|PATCH|DELETE /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}`
615 ///
616 /// One path for three operations, because the model gives them one `requestUri` and the
617 /// method is what distinguishes them: `GetMicrovmImageVersion` reads it,
618 /// `UpdateMicrovmImageVersion` patches it, `DeleteMicrovmImageVersion` deletes it. The
619 /// method lives on the [`Call`] rather than in three path functions, so a reader checking
620 /// this against `service-2.json` compares one string.
621 ///
622 /// Built from [`microvm_image`] rather than from [`image_versions`], because that one
623 /// now takes a cursor and none of the three may carry a query member.
624 pub fn image_version(image: &str, version: &str) -> String {
625 format!(
626 "{}/versions/{}",
627 microvm_image(image),
628 encode_segment(version)
629 )
630 }
631
632 /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds/{buildId}`
633 ///
634 /// The list path plus one segment. `buildId` is percent-encoded like every other URI
635 /// parameter even though a real one is a UUID: the model types it `NonBlankString`, so it
636 /// is not the encoder's business to assume the shape of a value the service mints.
637 pub fn image_build(image: &str, version: &str, build_id: &str) -> String {
638 format!(
639 "{}/builds/{}",
640 image_version(image, version),
641 encode_segment(build_id)
642 )
643 }
644
645 /// `GET /2025-09-09/managed-microvm-images`, with the pagination cursor when there is one.
646 ///
647 /// A **different route** from [`microvm_images`], not a filter on it: the managed bases
648 /// live under `managed-microvm-images` and their summaries carry a different shape
649 /// (`ManagedMicrovmImageSummary`, three members). Reading the account's own image listing
650 /// would never return one.
651 pub fn managed_microvm_images(next_token: Option<&str>) -> String {
652 with_next_token(
653 format!("/{API_PATH_VERSION}/managed-microvm-images"),
654 next_token,
655 )
656 }
657
658 /// `GET /2025-09-09/managed-microvm-images/{imageIdentifier}/versions`, with the cursor.
659 ///
660 /// `imageIdentifier` here is the managed base's **full ARN**, not its name: measured
661 /// 2026-08-16, `al2023-1` alone answers `ValidationException: Invalid ARN format:
662 /// al2023-1`. That is why [`crate::control::BaseImage::arn`] is what the caller passes —
663 /// and why the wrapper refuses a non-ARN locally, since the rejection names the value
664 /// without saying which member wanted an ARN.
665 pub fn managed_image_versions(image: &str, next_token: Option<&str>) -> String {
666 with_next_token(
667 format!(
668 "/{API_PATH_VERSION}/managed-microvm-images/{}/versions",
669 encode_segment(image)
670 ),
671 next_token,
672 )
673 }
674
675 /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds`,
676 /// with the pagination cursor when there is one.
677 pub fn image_builds(image: &str, version: &str, next_token: Option<&str>) -> String {
678 with_next_token(
679 format!("{}/builds", image_version(image, version)),
680 next_token,
681 )
682 }
683
684 /// `POST /2025-09-09/microvms`
685 ///
686 /// The bare collection path, which is what `RunMicrovm` posts to. `ListMicrovms` reads
687 /// the same route and lives in [`microvms_list`] rather than here, so a launch cannot
688 /// accidentally carry a `nextToken` — the same split [`microvm_images`] and
689 /// [`microvm_images_list`] already make.
690 pub fn microvms() -> String {
691 format!("/{API_PATH_VERSION}/microvms")
692 }
693
694 /// `GET /2025-09-09/microvms`, with the pagination cursor when there is one.
695 pub fn microvms_list(next_token: Option<&str>) -> String {
696 with_next_token(microvms(), next_token)
697 }
698
699 /// [`microvms_list`] with the model's `imageIdentifier` and `imageVersion` query filters.
700 ///
701 /// Keys are emitted in sorted order, which is also SigV4's canonical order.
702 pub fn microvms_list_matching(
703 image_identifier: Option<&str>,
704 image_version: Option<&str>,
705 next_token: Option<&str>,
706 ) -> String {
707 let query: Vec<String> = [
708 ("imageIdentifier", image_identifier),
709 ("imageVersion", image_version),
710 ("nextToken", next_token),
711 ]
712 .into_iter()
713 .filter_map(|(key, value)| value.map(|value| format!("{key}={}", encode_segment(value))))
714 .collect();
715 if query.is_empty() {
716 return microvms();
717 }
718 format!("{}?{}", microvms(), query.join("&"))
719 }
720
721 /// `GET|DELETE /2025-09-09/microvms/{microvmIdentifier}`
722 pub fn microvm(id: &str) -> String {
723 format!("/{API_PATH_VERSION}/microvms/{}", encode_segment(id))
724 }
725
726 /// `POST /2025-09-09/microvms/{microvmIdentifier}/suspend`
727 pub fn suspend(id: &str) -> String {
728 format!("{}/suspend", microvm(id))
729 }
730
731 /// `POST /2025-09-09/microvms/{microvmIdentifier}/resume`
732 pub fn resume(id: &str) -> String {
733 format!("{}/resume", microvm(id))
734 }
735
736 /// `POST /2025-09-09/microvms/{microvmIdentifier}/auth-token`
737 pub fn auth_token(id: &str) -> String {
738 format!("{}/auth-token", microvm(id))
739 }
740
741 /// `POST /2025-09-09/microvms/{microvmIdentifier}/shell-auth-token`
742 ///
743 /// The shell token's route, its own path rather than a parameter on
744 /// [`auth_token`]: the two operations take different bodies (`allowedPorts` exists
745 /// only on the ordinary one — the shell is not a port), and the exec path must never
746 /// be one boolean away from minting a shell credential (TRAP-11).
747 pub fn shell_auth_token(id: &str) -> String {
748 format!("{}/shell-auth-token", microvm(id))
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 /// The endpoint, from the model's `endpointPrefix`. `lambda` and not
757 /// `lambda-microvms` — which is the fact that makes TRAP-6 possible, since this
758 /// resolves for every AWS region.
759 #[test]
760 fn the_endpoint_uses_the_models_lambda_prefix_for_every_region() {
761 assert_eq!(
762 endpoint_for(&Region::UsEast1),
763 "https://lambda.us-east-1.amazonaws.com"
764 );
765 assert_eq!(
766 endpoint_for(&Region::ApNortheast1),
767 "https://lambda.ap-northeast-1.amazonaws.com"
768 );
769 // Including one that does not carry MicroVMs, which is the point.
770 assert_eq!(
771 endpoint_for(&Region::unlisted("eu-central-1")),
772 "https://lambda.eu-central-1.amazonaws.com"
773 );
774 }
775
776 /// Every path against the model's `http.requestUri`, as literals. Transcribed from
777 /// `service-2.json` rather than built from a template, because a template shared with
778 /// the code under test would agree with a wrong template.
779 #[test]
780 fn every_path_matches_the_models_request_uri() {
781 assert_eq!(paths::microvm_images(), "/2025-09-09/microvm-images");
782 assert_eq!(
783 paths::microvm_image("img"),
784 "/2025-09-09/microvm-images/img"
785 );
786 assert_eq!(
787 paths::image_versions("img", None),
788 "/2025-09-09/microvm-images/img/versions"
789 );
790 assert_eq!(
791 paths::image_version("img", "1"),
792 "/2025-09-09/microvm-images/img/versions/1"
793 );
794 assert_eq!(
795 paths::image_builds("img", "1", None),
796 "/2025-09-09/microvm-images/img/versions/1/builds"
797 );
798 assert_eq!(
799 paths::image_build("img", "1", "build-abc"),
800 "/2025-09-09/microvm-images/img/versions/1/builds/build-abc"
801 );
802 assert_eq!(
803 paths::managed_microvm_images(None),
804 "/2025-09-09/managed-microvm-images"
805 );
806 assert_eq!(
807 paths::managed_image_versions(
808 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
809 None
810 ),
811 "/2025-09-09/managed-microvm-images/\
812 arn%3Aaws%3Alambda%3Aus-east-1%3Aaws%3Amicrovm-image%3Aal2023-1/versions"
813 );
814 assert_eq!(paths::microvms(), "/2025-09-09/microvms");
815 assert_eq!(paths::microvms_list(None), "/2025-09-09/microvms");
816 assert_eq!(paths::microvm("mvm-1"), "/2025-09-09/microvms/mvm-1");
817 assert_eq!(
818 paths::suspend("mvm-1"),
819 "/2025-09-09/microvms/mvm-1/suspend"
820 );
821 assert_eq!(paths::resume("mvm-1"), "/2025-09-09/microvms/mvm-1/resume");
822 assert_eq!(
823 paths::auth_token("mvm-1"),
824 "/2025-09-09/microvms/mvm-1/auth-token"
825 );
826 assert_eq!(
827 paths::shell_auth_token("mvm-1"),
828 "/2025-09-09/microvms/mvm-1/shell-auth-token"
829 );
830 }
831
832 /// The listing path carries its query members in the model's spelling, percent-encoded,
833 /// and a bare listing is the plain collection path with no `?`.
834 ///
835 /// An opaque `nextToken` may carry `+`, `/`, or `=`; unencoded, those desynchronise the
836 /// SigV4 canonical query from the query actually sent, and the rejection reads like bad
837 /// credentials.
838 #[test]
839 fn the_image_listing_path_encodes_its_query_members() {
840 assert_eq!(
841 paths::microvm_images_list(None, None),
842 "/2025-09-09/microvm-images"
843 );
844 assert_eq!(
845 paths::microvm_images_list(Some("my-image"), None),
846 "/2025-09-09/microvm-images?nameFilter=my-image"
847 );
848 assert_eq!(
849 paths::microvm_images_list(Some("my-image"), Some("a+b/c=")),
850 "/2025-09-09/microvm-images?nameFilter=my-image&nextToken=a%2Bb%2Fc%3D"
851 );
852 assert_eq!(
853 paths::microvm_images_list(None, Some("token")),
854 "/2025-09-09/microvm-images?nextToken=token"
855 );
856 }
857
858 /// The other three paginated listings encode their cursor the same way, and emit no
859 /// `?` at all without one.
860 ///
861 /// Written as literals per listing rather than as a loop over a helper, because the
862 /// bug this closes is a path that forgets the cursor entirely — and a loop over the
863 /// helper would pass against three call sites that never pass one.
864 ///
865 /// **Falsification** — drop the [`paths::encode_segment`] call from
866 /// `with_next_token` and every `%2B`/`%2F`/`%3D` assertion below goes red, which is
867 /// the SigV4 canonical-query desynchronisation this encoder exists to prevent.
868 #[test]
869 fn the_other_paginated_listings_encode_their_cursor_too() {
870 assert_eq!(
871 paths::image_versions("img", Some("a+b/c=")),
872 "/2025-09-09/microvm-images/img/versions?nextToken=a%2Bb%2Fc%3D"
873 );
874 assert_eq!(
875 paths::image_builds("img", "1", Some("a+b/c=")),
876 "/2025-09-09/microvm-images/img/versions/1/builds?nextToken=a%2Bb%2Fc%3D"
877 );
878 assert_eq!(
879 paths::microvms_list(Some("a+b/c=")),
880 "/2025-09-09/microvms?nextToken=a%2Bb%2Fc%3D"
881 );
882
883 // No cursor means no query string, not an empty one: `?nextToken=` is a member
884 // present and blank, which is a different request from a member absent.
885 for path in [
886 paths::image_versions("img", None),
887 paths::image_builds("img", "1", None),
888 paths::microvms_list(None),
889 ] {
890 assert!(
891 !path.contains('?'),
892 "an absent cursor emits no query: {path}"
893 );
894 }
895 }
896
897 /// The version listing's explicit page size: ordered before the cursor, clamped to the
898 /// model's `1..=50`, and absent from the path when nothing asks for one.
899 ///
900 /// The clamp is asserted at both ends because a value outside the bound is a
901 /// `ValidationException`, and the only caller of this function
902 /// (`tests/live_pagination.rs`) is trying to *observe* a real cursor — a run that
903 /// failed on the page size instead would prove nothing about the encoding it came for.
904 ///
905 /// **Falsification** — remove the `.clamp(1, 50)` and the `0` and `999` cases go red
906 /// with `maxResults=0` and `maxResults=999`, both of which the service refuses.
907 #[test]
908 fn the_version_listings_explicit_page_size_is_ordered_and_clamped() {
909 assert_eq!(
910 paths::image_versions_paged("img", None, Some(1)),
911 "/2025-09-09/microvm-images/img/versions?maxResults=1"
912 );
913 // Both members, and `maxResults` first — the order the query is built in, which is
914 // asserted so a reordering is a visible change rather than a silent one.
915 assert_eq!(
916 paths::image_versions_paged("img", Some("a+b/c="), Some(2)),
917 "/2025-09-09/microvm-images/img/versions?maxResults=2&nextToken=a%2Bb%2Fc%3D"
918 );
919 assert_eq!(
920 paths::image_versions_paged("img", None, Some(0)),
921 "/2025-09-09/microvm-images/img/versions?maxResults=1",
922 "the model's min is 1; a 0 would be refused"
923 );
924 assert_eq!(
925 paths::image_versions_paged("img", None, Some(999)),
926 "/2025-09-09/microvm-images/img/versions?maxResults=50",
927 "the model's max is 50; a larger value would be refused"
928 );
929
930 // And the production spelling is unchanged by the new one existing: `image_versions`
931 // delegates here with no page size, so it must still emit exactly what it did.
932 assert_eq!(
933 paths::image_versions_paged("img", None, None),
934 paths::image_versions("img", None)
935 );
936 assert!(
937 !paths::image_versions("img", Some("t")).contains("maxResults"),
938 "no production path names a page size: {}",
939 paths::image_versions("img", Some("t"))
940 );
941 }
942
943 /// The **managed** routes are a different collection from the account's own, and their
944 /// cursor is encoded the same way.
945 ///
946 /// Asserted as its own case because the plausible mistake is treating the managed listing
947 /// as a filter on `microvm-images` — it is a separate `requestUri` in the model with a
948 /// different response shape, so a path built from [`paths::microvm_images`] would address
949 /// the account's images and answer 200 with the wrong items.
950 ///
951 /// **Falsification** — drop the `managed-` prefix from either function and the two
952 /// literals below go red while every request still succeeds against the real service,
953 /// which is exactly why these are literals transcribed from the model rather than a
954 /// shared template.
955 #[test]
956 fn the_managed_routes_are_their_own_collection_and_encode_their_cursor() {
957 assert!(
958 !paths::managed_microvm_images(None).contains('?'),
959 "an absent cursor emits no query member"
960 );
961 assert_eq!(
962 paths::managed_microvm_images(Some("a+b/c=")),
963 "/2025-09-09/managed-microvm-images?nextToken=a%2Bb%2Fc%3D"
964 );
965 assert_eq!(
966 paths::managed_image_versions("al2023-1", Some("a+b/c=")),
967 "/2025-09-09/managed-microvm-images/al2023-1/versions?nextToken=a%2Bb%2Fc%3D"
968 );
969
970 // Not a filter on the account's own listing: the two collections are different path
971 // segments, and reading one for the other answers 200 with the wrong items.
972 assert_ne!(
973 paths::managed_microvm_images(None),
974 paths::microvm_images_list(None, None)
975 );
976 assert!(
977 !paths::microvm_images().contains("managed"),
978 "the account's own collection must not acquire the managed prefix"
979 );
980 }
981
982 /// A `GET` of one build is the list path plus one encoded segment, and it carries no query
983 /// member.
984 ///
985 /// The `buildId` is percent-encoded even though a real one is a UUID: the model types it
986 /// `NonBlankString`, so the encoder does not get to assume the shape of a value the
987 /// service mints.
988 #[test]
989 fn one_builds_path_is_the_listing_plus_an_encoded_segment() {
990 let path = paths::image_build("img", "1", "4a4c5e30-811f-47fa-9893-260ea6a37a8f");
991 assert_eq!(
992 path,
993 "/2025-09-09/microvm-images/img/versions/1/builds/4a4c5e30-811f-47fa-9893-260ea6a37a8f"
994 );
995 assert!(!path.contains('?'), "{path}");
996 assert!(
997 path.starts_with(&paths::image_builds("img", "1", None)),
998 "the get path must extend the list path rather than be a second route: {path}"
999 );
1000 // And an identifier with a separator in it cannot split into extra segments.
1001 assert_eq!(
1002 paths::image_build("img", "1", "a/b"),
1003 "/2025-09-09/microvm-images/img/versions/1/builds/a%2Fb"
1004 );
1005 }
1006
1007 /// A `DELETE` of one version carries no query member, even though its sibling
1008 /// [`paths::image_versions`] now takes a cursor. A version path with `?nextToken=`
1009 /// appended addresses nothing.
1010 #[test]
1011 fn deleting_one_version_carries_no_pagination_cursor() {
1012 let path = paths::image_version("img", "1");
1013 assert_eq!(path, "/2025-09-09/microvm-images/img/versions/1");
1014 assert!(!path.contains('?'), "{path}");
1015 assert!(!path.contains("nextToken"), "{path}");
1016 }
1017
1018 /// A **real** image ARN in a URI parameter is percent-encoded, every colon included.
1019 /// `MicrovmImageIdentifier` is documented as "ARN or ID", so this is the ordinary case
1020 /// rather than an edge one.
1021 ///
1022 /// The literal is the shape the service actually returns, measured 2026-08-15 in
1023 /// us-east-1: `microvm-image:<name>` with a **colon**, which is also the only form the
1024 /// model's `TaggableResource` pattern admits. This test used to hold
1025 /// `microvm-image/img`, and the slash was not a harmless stand-in — `GetMicrovmImage`
1026 /// on that form answers `AccessDeniedException`, because IAM evaluates a malformed ARN
1027 /// as a resource with no matching policy.
1028 #[test]
1029 fn a_real_arn_identifier_is_percent_encoded_colons_and_all() {
1030 let arn = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
1031 let path = paths::microvm_image(arn);
1032 assert_eq!(
1033 path,
1034 "/2025-09-09/microvm-images/arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg"
1035 );
1036 assert!(
1037 !path["/2025-09-09/microvm-images/".len()..].contains(':'),
1038 "an unencoded colon in a path segment is a signature mismatch: {path}"
1039 );
1040 }
1041
1042 /// The encoder still encodes `/`, which is the property that matters even though a real
1043 /// image ARN carries none.
1044 ///
1045 /// Kept as its own case rather than folded into the test above, because the input here
1046 /// is a *shape the service does not use* and labelling it "an ARN" is what let the slash
1047 /// form look load-bearing for twelve fakes. An unencoded slash would split into extra
1048 /// path segments addressing something else, and other identifiers this encoder sees
1049 /// (`imageVersion`, a `nextToken` cursor) genuinely can contain one.
1050 #[test]
1051 fn the_segment_encoder_encodes_slashes_wherever_they_come_from() {
1052 assert_eq!(paths::encode_segment("a/b"), "a%2Fb");
1053 let path = paths::microvm_image("has/a/slash");
1054 assert_eq!(path, "/2025-09-09/microvm-images/has%2Fa%2Fslash");
1055 assert!(
1056 !path["/2025-09-09/microvm-images/".len()..].contains('/'),
1057 "an encoded identifier contributes no path separators: {path}"
1058 );
1059 }
1060
1061 /// The API version in the path is the constant the drift gate checks, not a second
1062 /// literal. Two copies of `2025-09-09` are two things to update, and the one that gets
1063 /// missed is the one nothing compiles against.
1064 #[test]
1065 fn the_path_version_is_the_pinned_model_version() {
1066 assert_eq!(API_PATH_VERSION, crate::constants::MODEL_API_VERSION);
1067 assert!(paths::microvms().starts_with("/2025-09-09/"));
1068 }
1069
1070 /// The signing name is `lambda`, from the model's `signingName`. A signature computed
1071 /// for `lambda-microvms` is rejected in a way that reads like bad credentials.
1072 #[test]
1073 fn the_signing_name_is_lambda_not_lambda_microvms() {
1074 assert_eq!(SIGNING_NAME, "lambda");
1075 }
1076
1077 /// TRAP-6's diagnostic, on the exact response shape the trap produces: 403 with a
1078 /// null message. The message has to name the region cause *and* say the field was
1079 /// null, because "AccessDeniedException" alone is what sends a reader to the IAM
1080 /// console.
1081 #[test]
1082 fn a_null_message_access_denied_names_the_region_trap() {
1083 let reply = Reply {
1084 status: 403,
1085 body: br#"{"message": null}"#.to_vec(),
1086 error_type: None,
1087 };
1088 let error = classify_failure("ListMicrovms", &reply);
1089 assert_eq!(error.kind(), ErrorKind::Credentials);
1090 let message = error.to_string();
1091 assert!(message.contains("null"), "{message}");
1092 assert!(message.contains("region"), "{message}");
1093 assert!(message.contains("does not carry MicroVMs"), "{message}");
1094 assert!(
1095 message.contains("Check the region before the policy"),
1096 "the remedy has to be the region, not the policy: {message}"
1097 );
1098 }
1099
1100 /// A 403 *with* a message is a genuine denial and says so, so the two are not
1101 /// conflated. A classifier that named the region trap on every 403 would be as
1102 /// misleading in the other direction.
1103 #[test]
1104 fn a_403_with_a_message_is_reported_as_a_genuine_denial() {
1105 let reply = Reply {
1106 status: 403,
1107 body: br#"{"message": "User: arn:aws:iam::1:user/u is not authorized"}"#.to_vec(),
1108 error_type: None,
1109 };
1110 let error = classify_failure("RunMicrovm", &reply);
1111 assert_eq!(error.kind(), ErrorKind::Credentials);
1112 let message = error.to_string();
1113 assert!(message.contains("not authorized"), "{message}");
1114 assert!(
1115 message.contains("genuine denial"),
1116 "must distinguish itself from the null-message case: {message}"
1117 );
1118 assert!(
1119 !message.contains("does not carry MicroVMs"),
1120 "must not blame the region when the service named a principal: {message}"
1121 );
1122 }
1123
1124 /// A 400 is the service refusing a value this client should have refused first, so the
1125 /// message names the validation refusal and the local checks it got past, not the
1126 /// catch-all for a status the model doesn't declare.
1127 #[test]
1128 fn a_400_names_the_validation_refusal_and_the_local_checks_it_passed() {
1129 let reply = Reply {
1130 status: 400,
1131 body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1132 error_type: None,
1133 };
1134 let error = classify_failure("RunMicrovm", &reply);
1135 assert_eq!(error.kind(), ErrorKind::Platform);
1136 let message = error.to_string();
1137 assert!(message.contains("ValidationException"), "{message}");
1138 assert!(message.contains("checked locally"), "{message}");
1139 assert!(!message.contains("unmodeled status"), "{message}");
1140 }
1141
1142 /// The service's throttle as it arrived from `ListMicrovms` on 2026-10-02: HTTP 400,
1143 /// named `ThrottlingException` only by the `x-amzn-ErrorType` header, with a null
1144 /// message. Classified by status it read as a validation refusal and was never
1145 /// retried, which failed every listing past the account's page rate.
1146 ///
1147 /// **Falsification** — run 2026-10-02. Guard the classifier's header arm with `false`, so
1148 /// the status alone decides again, and this fails on the retryability assertion with the
1149 /// `ValidationException` explanation (`guards:fire -- --only
1150 /// control-throttle-classified-by-status`). Restored.
1151 #[test]
1152 fn a_throttle_named_by_the_error_type_header_is_retryable_whatever_the_status() {
1153 let reply = Reply {
1154 status: 400,
1155 body: br#"{"message":null,"throttlingReasons":null}"#.to_vec(),
1156 error_type: Some("ThrottlingException".to_string()),
1157 };
1158 let error = classify_failure("ListMicrovms", &reply);
1159 assert!(error.retryable(), "{error}");
1160 assert_eq!(error.kind(), ErrorKind::Retryable);
1161 let message = error.to_string();
1162 assert!(message.contains("ThrottlingException"), "{message}");
1163 assert!(message.contains("HTTP 400"), "{message}");
1164 assert!(!message.contains("ValidationException"), "{message}");
1165 }
1166
1167 /// Another error name on a 400 leaves the status rule alone: the header decides only
1168 /// for the throttle, so a genuine validation refusal still isn't retried.
1169 #[test]
1170 fn a_400_naming_another_error_type_is_still_a_validation_refusal() {
1171 let reply = Reply {
1172 status: 400,
1173 body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1174 error_type: Some("ValidationException".to_string()),
1175 };
1176 let error = classify_failure("RunMicrovm", &reply);
1177 assert!(!error.retryable(), "{error}");
1178 assert!(error.to_string().contains("checked locally"), "{error}");
1179 }
1180
1181 /// The header's name is what precedes the first `:` and follows the first `#`, the
1182 /// rest-json rule, on the value the service sent and on the namespaced form.
1183 ///
1184 /// **Falsification** — run 2026-10-02. Keep the whole header instead of what precedes the
1185 /// `:`, and the service's own value comes back with its URI suffix and fails the first
1186 /// case (`guards:fire -- --only control-error-type-keeps-the-uri`). Restored.
1187 #[test]
1188 fn the_error_type_name_drops_the_uri_suffix_and_the_namespace() {
1189 let cases = [
1190 (
1191 "ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/",
1192 "ThrottlingException",
1193 ),
1194 (
1195 "aws.protocoltests.restjson#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate/",
1196 "FooError",
1197 ),
1198 (
1199 "com.amazonaws.lambda#ResourceNotFoundException",
1200 "ResourceNotFoundException",
1201 ),
1202 ("ConflictException", "ConflictException"),
1203 ];
1204 for (header, name) in cases {
1205 assert_eq!(error_type_name(header), name, "{header}");
1206 }
1207 }
1208
1209 /// Only the throttle and the 5xx family are retryable. A retried 409 would spin
1210 /// against an image in CREATING, and a retried 400 would send the same rejected value
1211 /// five more times.
1212 #[test]
1213 fn only_throttles_and_server_faults_are_retryable() {
1214 let cases = [
1215 (400, false),
1216 (402, false),
1217 (403, false),
1218 (404, false),
1219 (409, false),
1220 (429, true),
1221 (500, true),
1222 (502, true),
1223 (503, true),
1224 ];
1225 for (status, retryable) in cases {
1226 let reply = Reply {
1227 status,
1228 body: br#"{"message": "detail"}"#.to_vec(),
1229 error_type: None,
1230 };
1231 let error = classify_failure("GetMicrovm", &reply);
1232 assert_eq!(error.retryable(), retryable, "HTTP {status}");
1233 }
1234 }
1235
1236 /// A control-plane failure carries **no** `WireKind`: nothing reached the in-VM
1237 /// daemon, so there is no daemon-chosen status to report and the CLI's `data.kind`
1238 /// must be absent rather than invented.
1239 #[test]
1240 fn a_control_plane_failure_carries_no_daemon_wire_kind() {
1241 for status in [400, 403, 404, 409, 429, 500] {
1242 let reply = Reply {
1243 status,
1244 body: Vec::new(),
1245 error_type: None,
1246 };
1247 assert_eq!(
1248 classify_failure("GetMicrovm", &reply).wire_kind(),
1249 None,
1250 "HTTP {status} never reached the daemon"
1251 );
1252 }
1253 }
1254
1255 /// An unparseable error body still classifies by status rather than failing. A service
1256 /// answering an HTML error page or a truncated body must not turn a 429 into an
1257 /// unexpected error, which would stop the retry.
1258 #[test]
1259 fn an_unparseable_error_body_still_classifies_by_status() {
1260 let reply = Reply {
1261 status: 429,
1262 body: b"<html>Too Many Requests</html>".to_vec(),
1263 error_type: None,
1264 };
1265 let error = classify_failure("RunMicrovm", &reply);
1266 assert!(error.retryable(), "a throttle is a throttle");
1267 assert!(error.to_string().contains("no message"), "{error}");
1268 }
1269
1270 /// A body-parse failure names the operation and shows the body, because a rest-json
1271 /// parse error with the text withheld cannot distinguish a renamed member from a
1272 /// truncated response.
1273 #[test]
1274 fn a_response_that_does_not_parse_reports_the_body() {
1275 let reply = Reply {
1276 status: 200,
1277 body: br#"{"microvmId": 42}"#.to_vec(),
1278 error_type: None,
1279 };
1280 let error = reply
1281 .json::<crate::control::ops::MicrovmResponseWire>("GetMicrovm")
1282 .expect_err("microvmId is a string shape");
1283 let message = error.to_string();
1284 assert!(message.contains("GetMicrovm"), "{message}");
1285 assert!(message.contains("microvmId"), "{message}");
1286 assert_eq!(error.kind(), ErrorKind::Platform);
1287 }
1288
1289 /// A `POST` with no body members sends `{}` rather than nothing: an empty POST and a
1290 /// POST of `{}` are different canonical requests, and rest-json services answer the
1291 /// former inconsistently.
1292 #[test]
1293 fn a_bodyless_post_sends_an_empty_json_object() {
1294 let call = Call::post_empty("SuspendMicrovm", paths::suspend("mvm-1"));
1295 assert_eq!(call.body.as_deref(), Some(&b"{}"[..]));
1296 assert_eq!(call.method, Method::Post);
1297 }
1298
1299 /// A `GET` and a `DELETE` carry no body at all, and the signed empty payload is what
1300 /// `body_bytes` yields.
1301 #[test]
1302 fn a_get_and_a_delete_carry_no_body() {
1303 let get = Call::get("GetMicrovm", paths::microvm("mvm-1"));
1304 assert_eq!(get.body, None);
1305 assert_eq!(get.body_bytes(), b"");
1306
1307 let delete = Call::delete("TerminateMicrovm", paths::microvm("mvm-1"));
1308 assert_eq!(delete.body, None);
1309 assert_eq!(delete.method, Method::Delete);
1310 }
1311
1312 /// The methods, spelled as the canonical request needs them: uppercase.
1313 #[test]
1314 fn the_methods_are_the_ones_the_model_uses_spelled_uppercase() {
1315 assert_eq!(Method::Get.as_str(), "GET");
1316 assert_eq!(Method::Post.as_str(), "POST");
1317 assert_eq!(Method::Patch.as_str(), "PATCH");
1318 assert_eq!(Method::Delete.as_str(), "DELETE");
1319 }
1320
1321 /// A `PATCH` carries its serialized body and is **not** a `POST`.
1322 ///
1323 /// The distinction is load-bearing rather than cosmetic: `UpdateMicrovmImageVersion` is the
1324 /// model's only `PATCH`, and a `POST` to the version path is a route the service does not
1325 /// declare — so the failure would be a 404 that reads as a missing version rather than as a
1326 /// wrong method. The body assertion is here too, because a `PATCH` whose body was dropped
1327 /// would be a request with no `status` member at all.
1328 ///
1329 /// **Falsification** — set `method: Method::Post` in `patch_json` and the method assertion
1330 /// goes red; the path and body assertions would still pass, which is why the method is
1331 /// asserted separately.
1332 #[test]
1333 fn a_patch_carries_its_body_and_is_not_a_post() {
1334 let call = Call::patch_json(
1335 "UpdateMicrovmImageVersion",
1336 paths::image_version("img", "2.0"),
1337 &crate::control::ops::UpdateImageVersionWire {
1338 status: crate::control::ops::VersionStatus::Inactive,
1339 },
1340 )
1341 .expect("serialises");
1342
1343 assert_eq!(call.method, Method::Patch);
1344 assert_ne!(
1345 call.method,
1346 Method::Post,
1347 "a POST to the version path is a route the model does not declare"
1348 );
1349 assert_eq!(call.path, "/2025-09-09/microvm-images/img/versions/2.0");
1350 assert_eq!(
1351 call.body.as_deref(),
1352 Some(&br#"{"status":"INACTIVE"}"#[..]),
1353 "the one member the request has must reach the body"
1354 );
1355 assert_eq!(call.operation, "UpdateMicrovmImageVersion");
1356 }
1357}