Skip to main content

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;
38use std::time::Duration;
39
40/// An opt-in throttle budget. Other transient errors retain the default budget.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct RetryPolicy {
43    max_attempts: u32,
44    max_elapsed: Duration,
45}
46
47impl RetryPolicy {
48    /// Includes the initial request. Elapsed time includes requests and sleeps;
49    /// an in-flight request is allowed to finish, but no retry starts after the deadline.
50    pub fn new(max_attempts: u32, max_elapsed: Duration) -> Result<Self, Error> {
51        if max_attempts == 0 || max_elapsed.is_zero() {
52            return Err(Error::invalid_arg(
53                "retry max_attempts and max_elapsed must be positive",
54            ));
55        }
56        Ok(Self {
57            max_attempts,
58            max_elapsed,
59        })
60    }
61}
62
63/// Cancels pending retries without discarding an in-flight launch response.
64#[derive(Clone, Debug)]
65pub struct RetryCancellation(tokio::sync::watch::Sender<bool>);
66
67impl Default for RetryCancellation {
68    fn default() -> Self {
69        Self(tokio::sync::watch::Sender::new(false))
70    }
71}
72
73impl RetryCancellation {
74    /// Stop before the next request. An accepted launch is still recorded.
75    pub fn cancel(&self) {
76        self.0.send_replace(true);
77    }
78
79    fn is_cancelled(&self) -> bool {
80        *self.0.borrow()
81    }
82
83    pub(crate) async fn cancelled(&self) {
84        let mut receiver = self.0.subscribe();
85        let _ = receiver.wait_for(|cancelled| *cancelled).await;
86    }
87}
88
89/// Work done by one control-plane call, independent of concurrent callers.
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub struct RetryDetails {
92    pub attempt_count: u32,
93    pub backoff: Duration,
94}
95
96fn retry_delays() -> backon::ExponentialBuilder {
97    backon::ExponentialBuilder::default()
98        .with_jitter()
99        .with_min_delay(Duration::from_millis(200))
100        .with_max_delay(Duration::from_secs(20))
101        .with_max_times(5)
102}
103
104/// Sends with an optional throttle budget, measuring and sleeping on the plane's clock.
105/// The call is cloned unchanged, including its idempotency token, on each attempt.
106pub(crate) async fn send_configured(
107    transport: &dyn Transport,
108    call: Call,
109    accept: &[u16],
110    policy: Option<RetryPolicy>,
111    clock: &dyn crate::clock::Clock,
112) -> Result<(Reply, RetryDetails), Error> {
113    send_cancellable(transport, call, accept, policy, clock, None).await
114}
115
116pub(crate) async fn send_cancellable(
117    transport: &dyn Transport,
118    call: Call,
119    accept: &[u16],
120    policy: Option<RetryPolicy>,
121    clock: &dyn crate::clock::Clock,
122    cancellation: Option<&RetryCancellation>,
123) -> Result<(Reply, RetryDetails), Error> {
124    use backon::BackoffBuilder;
125    let started = clock.elapsed();
126    let mut details = RetryDetails::default();
127    let mut delays = retry_delays().without_max_times().build();
128    let mut ordinary_retries = 0;
129    loop {
130        if cancellation.is_some_and(RetryCancellation::is_cancelled) {
131            return Err(Error::new(
132                ErrorKind::Interrupted,
133                "launch retries cancelled before a VM was assigned",
134            )
135            .with_retry_details(details.attempt_count, details.backoff));
136        }
137        details.attempt_count += 1;
138        let error = match transport.send(call.clone()).await {
139            Ok(reply) if reply.is_success() || accept.contains(&reply.status) => {
140                return Ok((reply, details));
141            }
142            Ok(reply) => classify_failure_in_region(call.operation, &reply, transport.region()),
143            Err(error) => error,
144        };
145        let delay = delays.next().expect("unbounded backoff");
146        let elapsed = clock.elapsed().saturating_sub(started);
147        let allowed = if error.throttled()
148            && let Some(policy) = policy
149        {
150            details.attempt_count < policy.max_attempts
151                && elapsed.saturating_add(delay) < policy.max_elapsed
152        } else {
153            ordinary_retries += 1;
154            ordinary_retries <= 5
155        };
156        if !error.retryable() || !allowed {
157            return Err(error.with_retry_details(details.attempt_count, details.backoff));
158        }
159        if let Some(cancellation) = cancellation {
160            tokio::select! {
161                _ = cancellation.cancelled() => continue,
162                _ = clock.sleep(delay) => {}
163            }
164        } else {
165            clock.sleep(delay).await;
166        }
167        details.backoff += delay;
168        // A scheduler delay can carry the sleep past the deadline.
169        if error.throttled()
170            && policy.is_some_and(|p| clock.elapsed().saturating_sub(started) >= p.max_elapsed)
171        {
172            return Err(error.with_retry_details(details.attempt_count, details.backoff));
173        }
174    }
175}
176
177/// The pinned API version, which is also the first path segment of every operation.
178///
179/// Read from [`crate::constants::MODEL_API_VERSION`] rather than written again, so the
180/// drift gate's version check and the URLs cannot disagree: a client signing
181/// `/2025-09-09/...` while claiming to implement a different model is a client whose
182/// constraint checks were verified against the wrong thing.
183const API_PATH_VERSION: &str = crate::constants::MODEL_API_VERSION;
184
185/// The SigV4 signing name, from the model's `signingName`.
186///
187/// `lambda`, not `lambda-microvms`. The `endpointPrefix` is also `lambda`, which is why
188/// an endpoint resolves for every AWS region and a client constructs happily for a region
189/// that does not carry MicroVMs (TRAP-6, `crate::region`).
190pub const SIGNING_NAME: &str = "lambda";
191
192/// One control-plane call: method, path, optional JSON body.
193///
194/// A struct rather than a long parameter list because the recorder asserts on it as a
195/// whole, and because `method` and `path` next to each other is how a reader checks a
196/// path against the model's `http` trait.
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct Call {
199    /// The operation name, for diagnostics and for the recorder's ledger.
200    ///
201    /// Carried rather than derived from the path: the recorder's TRAP-11 assertion is
202    /// "an exec lifecycle emits no call named `CreateMicrovmShellAuthToken`", and
203    /// matching that on a path is a substring test that a renamed route would slip past.
204    pub operation: &'static str,
205    /// `GET`, `POST`, `PATCH`, or `DELETE` — the four the model's `http` traits use for the
206    /// operations this client implements.
207    pub method: Method,
208    /// The path **after** the host, with the API version and every URI parameter already
209    /// percent-encoded. Includes the query string when the operation has one.
210    pub path: String,
211    /// The rest-json body, or `None` for an operation the model gives no body members.
212    pub body: Option<Vec<u8>>,
213}
214
215/// The HTTP methods the model's operations use.
216///
217/// An enum so a call site cannot pass `"post"` and have the canonical request silently
218/// disagree with the wire method.
219///
220/// # `Patch` is one operation's, and only one
221///
222/// The model uses `PATCH` for exactly `UpdateMicrovmImageVersion` and `PUT` for exactly
223/// `UpdateMicrovmImage`. `Put` is deliberately absent: this client does not implement that
224/// operation, and a variant nothing constructs is a variant that suggests it does.
225/// `PATCH` is not interchangeable with `POST` here — a `POST` to the version path is a route
226/// the service does not declare, and the answer is a 404 that reads as a missing version
227/// rather than as a wrong method.
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum Method {
230    Get,
231    Post,
232    Patch,
233    Delete,
234}
235
236impl Method {
237    pub fn as_str(self) -> &'static str {
238        match self {
239            Method::Get => "GET",
240            Method::Post => "POST",
241            Method::Patch => "PATCH",
242            Method::Delete => "DELETE",
243        }
244    }
245}
246
247impl Call {
248    /// A `GET` with no body.
249    pub fn get(operation: &'static str, path: impl Into<String>) -> Self {
250        Self {
251            operation,
252            method: Method::Get,
253            path: path.into(),
254            body: None,
255        }
256    }
257
258    /// A `POST` carrying a serialized rest-json body.
259    pub fn post_json<T: serde::Serialize>(
260        operation: &'static str,
261        path: impl Into<String>,
262        body: &T,
263    ) -> Result<Self, Error> {
264        let body = serde_json::to_vec(body).map_err(|error| {
265            Error::new(
266                ErrorKind::Unexpected,
267                format!("could not serialize the {operation} request body: {error}"),
268            )
269        })?;
270        Ok(Self {
271            operation,
272            method: Method::Post,
273            path: path.into(),
274            body: Some(body),
275        })
276    }
277
278    /// A `PATCH` carrying a serialized rest-json body.
279    ///
280    /// The same shape as [`Call::post_json`] and written beside it rather than folded into a
281    /// method-taking constructor, for the reason [`Method`]'s docs give: the method is part of
282    /// the route the model declares, and a `with_method(Method::Patch)` builder is one a call
283    /// site can forget to call. `UpdateMicrovmImageVersion` is the only operation that reaches
284    /// this.
285    pub fn patch_json<T: serde::Serialize>(
286        operation: &'static str,
287        path: impl Into<String>,
288        body: &T,
289    ) -> Result<Self, Error> {
290        let body = serde_json::to_vec(body).map_err(|error| {
291            Error::new(
292                ErrorKind::Unexpected,
293                format!("could not serialize the {operation} request body: {error}"),
294            )
295        })?;
296        Ok(Self {
297            operation,
298            method: Method::Patch,
299            path: path.into(),
300            body: Some(body),
301        })
302    }
303
304    /// A `POST` with no body members. `SuspendMicrovm` and `ResumeMicrovm` are both this
305    /// shape: their only member is the URI-located identifier.
306    ///
307    /// The body is `Some(b"{}")` rather than `None`, because an empty POST and a POST of
308    /// `{}` are different canonical requests and rest-json services answer the former
309    /// inconsistently.
310    pub fn post_empty(operation: &'static str, path: impl Into<String>) -> Self {
311        Self {
312            operation,
313            method: Method::Post,
314            path: path.into(),
315            body: Some(b"{}".to_vec()),
316        }
317    }
318
319    /// A `DELETE` with no body.
320    pub fn delete(operation: &'static str, path: impl Into<String>) -> Self {
321        Self {
322            operation,
323            method: Method::Delete,
324            path: path.into(),
325            body: None,
326        }
327    }
328
329    /// The body as bytes, empty when there is none — which is what gets signed.
330    pub fn body_bytes(&self) -> &[u8] {
331        self.body.as_deref().unwrap_or(&[])
332    }
333}
334
335/// What the control plane answered: a status, a raw body, and the error type the service
336/// named, if it named one.
337///
338/// Raw bytes rather than a deserialized value because the error path has to read a body
339/// the success types cannot hold, and because TRAP-6's null `message` field is only
340/// visible to something that did not already require a `String` there.
341#[derive(Clone, Debug)]
342pub struct Reply {
343    pub status: u16,
344    pub body: Vec<u8>,
345    /// The `x-amzn-ErrorType` header's error name, cut down by [`error_type_name`], or
346    /// `None` when the response carried no such header. [`classify_failure`] reads it
347    /// before the status, because the service has sent a throttle as HTTP 400.
348    pub error_type: Option<String>,
349}
350
351impl Reply {
352    /// Deserializes the body as `T`, or reports the failure with the body attached.
353    ///
354    /// The body goes in the message because a rest-json parse failure with the text
355    /// withheld is unactionable: the reader cannot tell a renamed member from a truncated
356    /// response. Truncated to 512 bytes, since a list response can be large and the first
357    /// 512 characters always contain the member that failed.
358    pub fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
359        serde_json::from_slice(&self.body).map_err(|error| {
360            let text = String::from_utf8_lossy(&self.body);
361            let shown: String = text.chars().take(512).collect();
362            Error::new(
363                ErrorKind::Platform,
364                format!(
365                    "could not read the {operation} response ({error}). The service model for \
366                     {API_PATH_VERSION} is what this client's shapes are transcribed from, so a \
367                     member that has moved is a drift-gate failure rather than a transport bug. \
368                     Body: {shown}"
369                ),
370            )
371            .with_source(error)
372        })
373    }
374
375    /// Whether the status is a success. rest-json operations here answer 200 or 201.
376    fn is_success(&self) -> bool {
377        (200..300).contains(&self.status)
378    }
379}
380
381/// The one seam every operation goes through.
382///
383/// One method, so a fake is a few lines and there is no partially-overridden transport
384/// whose behaviour is half real. `&self` rather than `&mut self` so the recorder can be
385/// shared behind an `Arc` while a lifecycle runs.
386pub trait Transport: Send + Sync {
387    /// The service region, when known, for diagnostics about ambiguous denials.
388    /// Custom transports may leave this unknown without changing their behavior.
389    fn region(&self) -> Option<&Region> {
390        None
391    }
392
393    /// Performs `call`, returning whatever the far side answered.
394    ///
395    /// A transport-level failure (no status at all) is an `Err`; a 4xx or 5xx is an
396    /// `Ok(Reply)` carrying the status, because classifying a status is
397    /// [`classify_failure`]'s job and a transport that classified would be a second
398    /// place the taxonomy lives.
399    fn send(
400        &self,
401        call: Call,
402    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>;
403
404    /// Resolves the credentials the next [`Transport::send`] would sign with, sending nothing
405    /// to the service (BIND-15's credentials check).
406    ///
407    /// Resolving may reach an SSO, credential-process, or instance-metadata source, none of
408    /// which bills. The default answers `Ok`: a transport that does not sign (every scripted
409    /// one) has nothing to resolve.
410    fn resolve_credentials(
411        &self,
412    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
413        Box::pin(async { Ok(()) })
414    }
415}
416
417/// Sends `call` through `transport`, retrying the retryable statuses, and turns a failure
418/// status into a classified [`Error`].
419///
420/// # Which failures retry
421///
422/// 429 (`ThrottlingException`), 500 (`InternalServerException`), and any other 5xx, plus
423/// a transport error that produced no status. AWS standard-mode semantics, matched by
424/// hand because the smithy retry pieces are coupled to an orchestrator this client does
425/// not have.
426///
427/// # Why retrying the mutating operations is safe
428///
429/// Every mutating operation this client sends carries a `clientToken`, so a retry is
430/// idempotent by the service's own contract — which is the same property that makes the
431/// token dangerous to derive (TRAP-1). `SuspendMicrovm`/`ResumeMicrovm`/`TerminateMicrovm`
432/// take no token and are retried anyway: they are state transitions whose second
433/// application is a no-op or a `ConflictException`, and a conflict is not retried.
434pub async fn send_with_retry(transport: &dyn Transport, call: Call) -> Result<Reply, Error> {
435    use backon::Retryable;
436
437    let operation = call.operation;
438    let attempt = || {
439        let call = call.clone();
440        async move {
441            let reply = transport.send(call).await?;
442            if reply.is_success() {
443                return Ok(reply);
444            }
445            Err(classify_failure_in_region(
446                operation,
447                &reply,
448                transport.region(),
449            ))
450        }
451    };
452
453    let mut details = RetryDetails {
454        attempt_count: 1,
455        backoff: Duration::ZERO,
456    };
457    let result = attempt
458        .retry(retry_delays())
459        .when(Error::retryable)
460        .notify(|_, delay| {
461            details.attempt_count += 1;
462            details.backoff += delay;
463        })
464        .await;
465    result.map_err(|error| error.with_retry_details(details.attempt_count, details.backoff))
466}
467
468/// [`send_with_retry`], answering `Ok` for the statuses in `accept` as well as for a
469/// success.
470///
471/// For a call whose failure status is an answer rather than an error: `GetMicrovmImage`'s 404
472/// is "there is no image under this name", which `ensure_image` reads as a state
473/// ([`crate::control::ensure`]). Retries are unchanged — a throttle or a 5xx is still
474/// retried — and every other status is still classified by [`classify_failure`].
475pub async fn send_accepting(
476    transport: &dyn Transport,
477    call: Call,
478    accept: &[u16],
479) -> Result<Reply, Error> {
480    use backon::Retryable;
481
482    let operation = call.operation;
483    let attempt = || {
484        let call = call.clone();
485        async move {
486            let reply = transport.send(call).await?;
487            if reply.is_success() || accept.contains(&reply.status) {
488                return Ok(reply);
489            }
490            Err(classify_failure_in_region(
491                operation,
492                &reply,
493                transport.region(),
494            ))
495        }
496    };
497
498    let mut details = RetryDetails {
499        attempt_count: 1,
500        backoff: Duration::ZERO,
501    };
502    let result = attempt
503        .retry(retry_delays())
504        .when(Error::retryable)
505        .notify(|_, delay| {
506            details.attempt_count += 1;
507            details.backoff += delay;
508        })
509        .await;
510    result.map_err(|error| error.with_retry_details(details.attempt_count, details.backoff))
511}
512
513/// Turns a failure status and body into an [`Error`] of the right kind.
514///
515/// # The statuses are the control plane's, not the daemon's
516///
517/// [`crate::error::WireKind`] is the *daemon's* status discipline and deliberately has no
518/// generic 4xx fallback, because the daemon's 400-versus-404 distinction is load-bearing.
519/// None of that applies here: these seven statuses come from the modeled exception shapes
520/// (`ValidationException` 400, `ServiceQuotaExceededException` 402,
521/// `AccessDeniedException` 403, `ResourceNotFoundException` 404, `ConflictException` 409,
522/// `ThrottlingException` 429, `InternalServerException` 500). So a control-plane failure
523/// carries an [`ErrorKind`] and **no** `WireKind` — which is what `error.rs` already
524/// documents: "`None` for every local reject and every control-plane failure — those never
525/// reached the in-VM daemon".
526///
527/// # 403 and the null message
528///
529/// A 403 whose `message` is null is ambiguous (TRAP-6): both unsupported regions and
530/// denied implied actions can produce it. The diagnostic names both and points to
531/// CloudTrail for the service's full denial.
532pub fn classify_failure(operation: &str, reply: &Reply) -> Error {
533    classify_failure_in_region(operation, reply, None)
534}
535
536fn classify_failure_in_region(operation: &str, reply: &Reply, region: Option<&Region>) -> Error {
537    let parsed: crate::control::ops::ServiceErrorWire =
538        serde_json::from_slice(&reply.body).unwrap_or_default();
539    let detail = parsed.message.as_deref().unwrap_or("").trim().to_string();
540
541    let (kind, explanation) = match reply.status {
542        // The error type outranks the status. A throttled `ListMicrovms` answered HTTP 400
543        // with `x-amzn-ErrorType: ThrottlingException`, where the model says 429
544        // (docs/PLATFORM.md, measured 2026-10-02), and read by status alone it was a
545        // validation refusal that nothing retried.
546        status if reply.error_type.as_deref() == Some("ThrottlingException") => (
547            ErrorKind::Retryable,
548            format!(
549                "ThrottlingException, named by the x-amzn-ErrorType header on an HTTP {status} \
550                 — retry the identical request."
551            ),
552        ),
553        400 => (
554            ErrorKind::Platform,
555            "ValidationException — the service refused a value. Every constraint the pinned \
556             service model states on a member this client sends is checked locally before the \
557             call — min as well as max, pattern, and enum, because nothing else validates a \
558             request here: this client signs with aws-sigv4 and sends with reqwest, so botocore's \
559             VALIDATED_METADATA_ATTRS never applies to it. So reaching this status means a \
560             constraint this client does not yet check, and closing it is a guard in \
561             crates/microvms-app/src/control plus a line in tools/check-model-drift.py."
562                .to_string(),
563        ),
564        402 => (
565            ErrorKind::Platform,
566            "ServiceQuotaExceededException — an account limit, not a request defect.".to_string(),
567        ),
568        403 if detail.is_empty() => (
569            ErrorKind::Credentials,
570            null_message_denial(operation, region),
571        ),
572        403 => (
573            ErrorKind::Credentials,
574            "AccessDeniedException, with a message — the service reported a genuine denial."
575                .to_string(),
576        ),
577        404 => (
578            ErrorKind::Platform,
579            "ResourceNotFoundException — the image or MicroVM does not exist.".to_string(),
580        ),
581        409 => (
582            ErrorKind::Platform,
583            "ConflictException — the resource is in a state that forbids this call. An image in \
584             CREATING refuses deletion, which is why teardown retries."
585                .to_string(),
586        ),
587        429 => (
588            ErrorKind::Retryable,
589            "ThrottlingException — retry the identical request.".to_string(),
590        ),
591        status if status >= 500 => (
592            ErrorKind::Retryable,
593            "InternalServerException — a service fault, retry the identical request.".to_string(),
594        ),
595        _ => (
596            ErrorKind::Platform,
597            "an unmodeled status: the service model declares 400, 402, 403, 404, 409, 429, and \
598             500 for these operations."
599                .to_string(),
600        ),
601    };
602
603    let said = if detail.is_empty() {
604        "the service sent no message".to_string()
605    } else {
606        format!("the service said {detail:?}")
607    };
608    Error::new(
609        kind,
610        format!(
611            "{operation} failed with HTTP {}: {said}. {explanation}",
612            reply.status
613        ),
614    )
615    .with_throttled(
616        reply.status == 429 || reply.error_type.as_deref() == Some("ThrottlingException"),
617    )
618}
619
620/// The service can omit its denial detail for both regional availability and IAM errors.
621fn null_message_denial(operation: &str, region: Option<&Region>) -> String {
622    let availability = match region {
623        Some(region) if region.is_supported() => format!(
624            "{} carries MicroVMs; an IAM policy is the likely cause.",
625            region.as_str()
626        ),
627        Some(region) => format!(
628            "{} is not in the known MicroVMs region list; verify service availability as well as the IAM policy.",
629            region.as_str()
630        ),
631        None => "The cause may be an unsupported region or an IAM denial.".to_string(),
632    };
633    let region_arg = region
634        .map(|region| format!(" --region {}", region.as_str()))
635        .unwrap_or_default();
636    format!(
637        "AccessDeniedException with an empty or null message field. {availability} \
638         A create call with tags can require the implied lambda:TagResource action. \
639         Check the full denial in CloudTrail: aws cloudtrail lookup-events{region_arg} \
640         --lookup-attributes AttributeKey=EventName,AttributeValue={operation} \
641         --query 'Events[].CloudTrailEvent' --output text. Inspect errorCode and errorMessage \
642         for the denied action (docs/PLATFORM.md)."
643    )
644}
645
646/// The error name in an `x-amzn-ErrorType` header value: what precedes the first `:` and
647/// follows the first `#`.
648///
649/// That is the rest-json protocol's rule, and both parts occur here: the service's throttle
650/// arrived as `ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/`,
651/// and other services send a `namespace#Name` form.
652pub fn error_type_name(header: &str) -> &str {
653    let name = header.split(':').next().unwrap_or(header);
654    name.split_once('#').map_or(name, |(_, after)| after).trim()
655}
656
657/// `https://lambda.<region>.amazonaws.com`, from the model's `endpointPrefix`.
658///
659/// The prefix is `lambda` rather than `lambda-microvms`, which is why this resolves for
660/// every AWS region including the ones that answer the null-message denial.
661pub fn endpoint_for(region: &Region) -> String {
662    format!("https://{SIGNING_NAME}.{}.amazonaws.com", region.as_str())
663}
664
665/// The operation paths, one function per `http.requestUri` in the model.
666///
667/// Here rather than inlined at each call site so a path is written once and can be read
668/// against the model in one place. Every URI parameter is percent-encoded, because an
669/// image identifier may be an ARN — which contains `:` and `/` — and an unencoded ARN in a
670/// path segment is a different resource.
671pub mod paths {
672    use super::API_PATH_VERSION;
673
674    /// Percent-encodes a URI path segment.
675    ///
676    /// Everything outside RFC 3986's unreserved set is escaped. **`/` is encoded**,
677    /// which is the whole point: an image ARN's slashes would otherwise split into
678    /// extra path segments and address something else. `:` is encoded too — legal
679    /// unencoded in a path per the RFC, but AWS's own SDKs encode it in labels and
680    /// the signature must match whatever is sent.
681    pub fn encode_segment(segment: &str) -> String {
682        use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
683        const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
684            .remove(b'-')
685            .remove(b'_')
686            .remove(b'.')
687            .remove(b'~');
688        utf8_percent_encode(segment, ESCAPED).to_string()
689    }
690
691    /// `POST /2025-09-09/microvm-images`
692    pub fn microvm_images() -> String {
693        format!("/{API_PATH_VERSION}/microvm-images")
694    }
695
696    /// `GET /2025-09-09/microvm-images`, with the listing's query members.
697    ///
698    /// `nameFilter` is the model's server-side **substring** filter — it narrows the
699    /// listing, it does not answer "the image named X", so an exact-match comparison
700    /// still happens in the caller. `nextToken` is the pagination cursor and is opaque,
701    /// which is why both values go through [`encode_segment`]: an opaque token may carry
702    /// `+` or `=`, and an unencoded one desynchronises the SigV4 canonical query from
703    /// the query actually sent.
704    pub fn microvm_images_list(name_filter: Option<&str>, next_token: Option<&str>) -> String {
705        let mut query: Vec<String> = Vec::new();
706        if let Some(filter) = name_filter {
707            query.push(format!("nameFilter={}", encode_segment(filter)));
708        }
709        if let Some(token) = next_token {
710            query.push(format!("nextToken={}", encode_segment(token)));
711        }
712        if query.is_empty() {
713            return microvm_images();
714        }
715        format!("{}?{}", microvm_images(), query.join("&"))
716    }
717
718    /// `GET|DELETE /2025-09-09/microvm-images/{imageIdentifier}`
719    pub fn microvm_image(image: &str) -> String {
720        format!(
721            "/{API_PATH_VERSION}/microvm-images/{}",
722            encode_segment(image)
723        )
724    }
725
726    /// One `nextToken=` query member, appended to `path`, or `path` unchanged.
727    ///
728    /// Shared by the three listings that take nothing else, so the encoding decision is
729    /// made once. The value goes through [`encode_segment`] for the reason
730    /// [`microvm_images_list`] states: a pagination token is **opaque**, so it may carry
731    /// `+`, `/`, or `=`, and an unencoded one desynchronises the SigV4 canonical query
732    /// from the query actually sent — a rejection that reads like bad credentials rather
733    /// than like a malformed URL.
734    fn with_next_token(path: String, next_token: Option<&str>) -> String {
735        match next_token {
736            Some(token) => format!("{path}?nextToken={}", encode_segment(token)),
737            None => path,
738        }
739    }
740
741    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions`, with the pagination
742    /// cursor when there is one.
743    ///
744    /// `maxResults` is not sent: the model caps it at 50 and the service's own default is
745    /// what a caller reading every page wants, so naming a page size would only be a
746    /// second number to keep right. [`image_versions_paged`] is the one caller that does
747    /// name one, and says why.
748    pub fn image_versions(image: &str, next_token: Option<&str>) -> String {
749        image_versions_paged(image, next_token, None)
750    }
751
752    /// [`image_versions`] with an explicit `maxResults`, which **no production path sends**.
753    ///
754    /// # Why a page size exists at all when nothing ships one
755    ///
756    /// Because without it the cursor encoding is unfalsifiable against the real service,
757    /// and that encoding is the one place in this module where being wrong reads as a
758    /// *credentials* failure rather than a URL one. A real `nextToken` is opaque base64
759    /// carrying `+`, `/`, and `=`; an unencoded one desynchronises the SigV4 canonical
760    /// query and the service answers 403. Every test that could catch it went through an
761    /// injected transport, so the fake minted the token and the signer never saw it.
762    ///
763    /// Forcing a real cursor needs a listing with more items than one page, and the
764    /// service's default page is larger than any image's version count here — so the only
765    /// way to make the real service mint a real cursor for a resource that already exists
766    /// is to ask for a smaller page. That is what this is for, and
767    /// `crates/microvms-core/tests/live_pagination.rs` is its caller.
768    ///
769    /// # Why it is not simply a parameter on [`image_versions`]
770    ///
771    /// A page size on the production path is a second number to keep right, and a wrong
772    /// one is invisible: reading every page with `maxResults=1` is correct and slow, which
773    /// is the kind of defect that survives. Keeping it to a separately named function means
774    /// the production call sites cannot acquire one by accident, and a reader of
775    /// [`image_versions`] sees no page size to wonder about.
776    ///
777    /// `max_results` is clamped to the model's `1..=50`, because a value outside it is a
778    /// `ValidationException` and a caller of this function is trying to observe pagination
779    /// rather than to discover the bound.
780    pub fn image_versions_paged(
781        image: &str,
782        next_token: Option<&str>,
783        max_results: Option<u32>,
784    ) -> String {
785        let mut query: Vec<String> = Vec::new();
786        if let Some(size) = max_results {
787            query.push(format!("maxResults={}", size.clamp(1, 50)));
788        }
789        if let Some(token) = next_token {
790            query.push(format!("nextToken={}", encode_segment(token)));
791        }
792        let path = format!("{}/versions", microvm_image(image));
793        if query.is_empty() {
794            return path;
795        }
796        format!("{path}?{}", query.join("&"))
797    }
798
799    /// `GET|PATCH|DELETE /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}`
800    ///
801    /// One path for three operations, because the model gives them one `requestUri` and the
802    /// method is what distinguishes them: `GetMicrovmImageVersion` reads it,
803    /// `UpdateMicrovmImageVersion` patches it, `DeleteMicrovmImageVersion` deletes it. The
804    /// method lives on the [`Call`] rather than in three path functions, so a reader checking
805    /// this against `service-2.json` compares one string.
806    ///
807    /// Built from [`microvm_image`] rather than from [`image_versions`], because that one
808    /// now takes a cursor and none of the three may carry a query member.
809    pub fn image_version(image: &str, version: &str) -> String {
810        format!(
811            "{}/versions/{}",
812            microvm_image(image),
813            encode_segment(version)
814        )
815    }
816
817    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds/{buildId}`
818    ///
819    /// The list path plus one segment. `buildId` is percent-encoded like every other URI
820    /// parameter even though a real one is a UUID: the model types it `NonBlankString`, so it
821    /// is not the encoder's business to assume the shape of a value the service mints.
822    pub fn image_build(image: &str, version: &str, build_id: &str) -> String {
823        format!(
824            "{}/builds/{}",
825            image_version(image, version),
826            encode_segment(build_id)
827        )
828    }
829
830    /// `GET /2025-09-09/managed-microvm-images`, with the pagination cursor when there is one.
831    ///
832    /// A **different route** from [`microvm_images`], not a filter on it: the managed bases
833    /// live under `managed-microvm-images` and their summaries carry a different shape
834    /// (`ManagedMicrovmImageSummary`, three members). Reading the account's own image listing
835    /// would never return one.
836    pub fn managed_microvm_images(next_token: Option<&str>) -> String {
837        with_next_token(
838            format!("/{API_PATH_VERSION}/managed-microvm-images"),
839            next_token,
840        )
841    }
842
843    /// `GET /2025-09-09/managed-microvm-images/{imageIdentifier}/versions`, with the cursor.
844    ///
845    /// `imageIdentifier` here is the managed base's **full ARN**, not its name: measured
846    /// 2026-08-16, `al2023-1` alone answers `ValidationException: Invalid ARN format:
847    /// al2023-1`. That is why [`crate::control::BaseImage::arn`] is what the caller passes —
848    /// and why the wrapper refuses a non-ARN locally, since the rejection names the value
849    /// without saying which member wanted an ARN.
850    pub fn managed_image_versions(image: &str, next_token: Option<&str>) -> String {
851        with_next_token(
852            format!(
853                "/{API_PATH_VERSION}/managed-microvm-images/{}/versions",
854                encode_segment(image)
855            ),
856            next_token,
857        )
858    }
859
860    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds`,
861    /// with the pagination cursor when there is one.
862    pub fn image_builds(image: &str, version: &str, next_token: Option<&str>) -> String {
863        with_next_token(
864            format!("{}/builds", image_version(image, version)),
865            next_token,
866        )
867    }
868
869    /// `POST /2025-09-09/microvms`
870    ///
871    /// The bare collection path, which is what `RunMicrovm` posts to. `ListMicrovms` reads
872    /// the same route and lives in [`microvms_list`] rather than here, so a launch cannot
873    /// accidentally carry a `nextToken` — the same split [`microvm_images`] and
874    /// [`microvm_images_list`] already make.
875    pub fn microvms() -> String {
876        format!("/{API_PATH_VERSION}/microvms")
877    }
878
879    /// `GET /2025-09-09/microvms`, with the pagination cursor when there is one.
880    pub fn microvms_list(next_token: Option<&str>) -> String {
881        with_next_token(microvms(), next_token)
882    }
883
884    /// [`microvms_list`] with the model's `imageIdentifier` and `imageVersion` query filters.
885    ///
886    /// Keys are emitted in sorted order, which is also SigV4's canonical order.
887    pub fn microvms_list_matching(
888        image_identifier: Option<&str>,
889        image_version: Option<&str>,
890        next_token: Option<&str>,
891    ) -> String {
892        let query: Vec<String> = [
893            ("imageIdentifier", image_identifier),
894            ("imageVersion", image_version),
895            ("nextToken", next_token),
896        ]
897        .into_iter()
898        .filter_map(|(key, value)| value.map(|value| format!("{key}={}", encode_segment(value))))
899        .collect();
900        if query.is_empty() {
901            return microvms();
902        }
903        format!("{}?{}", microvms(), query.join("&"))
904    }
905
906    /// `GET|DELETE /2025-09-09/microvms/{microvmIdentifier}`
907    pub fn microvm(id: &str) -> String {
908        format!("/{API_PATH_VERSION}/microvms/{}", encode_segment(id))
909    }
910
911    /// `POST /2025-09-09/microvms/{microvmIdentifier}/suspend`
912    pub fn suspend(id: &str) -> String {
913        format!("{}/suspend", microvm(id))
914    }
915
916    /// `POST /2025-09-09/microvms/{microvmIdentifier}/resume`
917    pub fn resume(id: &str) -> String {
918        format!("{}/resume", microvm(id))
919    }
920
921    /// `POST /2025-09-09/microvms/{microvmIdentifier}/auth-token`
922    pub fn auth_token(id: &str) -> String {
923        format!("{}/auth-token", microvm(id))
924    }
925
926    /// `POST /2025-09-09/microvms/{microvmIdentifier}/shell-auth-token`
927    ///
928    /// The shell token's route, its own path rather than a parameter on
929    /// [`auth_token`]: the two operations take different bodies (`allowedPorts` exists
930    /// only on the ordinary one — the shell is not a port), and the exec path must never
931    /// be one boolean away from minting a shell credential (TRAP-11).
932    pub fn shell_auth_token(id: &str) -> String {
933        format!("{}/shell-auth-token", microvm(id))
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[tokio::test]
942    async fn throttle_budget_counts_attempts_bounds_elapsed_and_keeps_other_failures_default() {
943        use crate::clock::Clock;
944        use crate::control::fake::{Answer, FakeControlPlane, TestClock};
945        for (status, policy, expected) in [
946            (429, None, 6),
947            (
948                429,
949                Some(RetryPolicy::new(3, Duration::from_secs(3600)).unwrap()),
950                3,
951            ),
952            (
953                429,
954                Some(RetryPolicy::new(9, Duration::from_millis(1)).unwrap()),
955                1,
956            ),
957            (
958                503,
959                Some(RetryPolicy::new(9, Duration::from_secs(3600)).unwrap()),
960                6,
961            ),
962            (
963                403,
964                Some(RetryPolicy::new(9, Duration::from_secs(3600)).unwrap()),
965                1,
966            ),
967        ] {
968            let transport = FakeControlPlane::new();
969            for _ in 0..expected {
970                transport.answer("RunMicrovm", Answer::failure(status, "failure"));
971            }
972            // An extra retry reaches success, so a broken budget fails the verdict
973            // assertion instead of repeating a transient answer forever.
974            transport.answer("RunMicrovm", Answer::ok("{}"));
975            let clock = TestClock::new();
976            let error = send_configured(
977                &transport,
978                Call::get("RunMicrovm", "/run"),
979                &[],
980                policy,
981                &clock,
982            )
983            .await
984            .unwrap_err();
985            assert_eq!(transport.call_count("RunMicrovm"), expected);
986            assert_eq!(error.attempt_count(), expected as u32);
987            assert_eq!(error.throttled(), status == 429);
988            assert_eq!(error.backoff(), clock.elapsed());
989            if expected == 1 {
990                assert_eq!(clock.elapsed(), Duration::ZERO);
991            }
992        }
993        assert!(RetryPolicy::new(0, Duration::from_secs(1)).is_err());
994        assert!(RetryPolicy::new(1, Duration::ZERO).is_err());
995    }
996
997    #[tokio::test]
998    async fn a_retry_at_the_elapsed_budget_boundary_does_not_sleep() {
999        use crate::clock::Clock;
1000        use crate::control::fake::{Answer, FakeControlPlane, TestClock};
1001        let transport = FakeControlPlane::new();
1002        transport.answer("RunMicrovm", Answer::throttled_as_400());
1003        let gate = transport.hold_responses();
1004        let clock = TestClock::new();
1005        clock.set_unix_now(Duration::ZERO);
1006        let pending = send_configured(
1007            &transport,
1008            Call::get("RunMicrovm", "/run"),
1009            &[],
1010            Some(RetryPolicy::new(2, Duration::MAX).unwrap()),
1011            &clock,
1012        );
1013        tokio::pin!(pending);
1014        tokio::select! {
1015            biased;
1016            _ = &mut pending => panic!("response must wait for the gate"),
1017            _ = std::future::ready(()) => {}
1018        }
1019        assert_eq!(transport.call_count("RunMicrovm"), 1);
1020        // Saturating elapsed + jittered delay equals MAX for every possible delay.
1021        // Advancing during the request also proves request time uses this budget.
1022        clock.advance(Duration::MAX);
1023        gate.notify_one();
1024        // The delivered response must finish this call immediately. A second request
1025        // would wait on the closed gate, so fail that retry here rather than hanging.
1026        let error = tokio::select! {
1027            biased;
1028            result = &mut pending => result.unwrap_err(),
1029            _ = std::future::ready(()) => panic!("elapsed budget must stop before another request"),
1030        };
1031        assert!(error.throttled());
1032        assert_eq!(error.attempt_count(), 1);
1033        assert_eq!(transport.call_count("RunMicrovm"), 1);
1034        assert_eq!(error.backoff(), Duration::ZERO);
1035        assert_eq!(clock.elapsed(), Duration::MAX);
1036    }
1037
1038    #[tokio::test]
1039    async fn configured_retries_recover_header_throttles_with_the_identical_call() {
1040        use crate::clock::Clock;
1041        use crate::control::fake::{Answer, FakeControlPlane, TestClock};
1042        let transport = FakeControlPlane::new();
1043        for _ in 0..7 {
1044            transport.answer("RunMicrovm", Answer::throttled_as_400());
1045        }
1046        transport.answer("RunMicrovm", Answer::ok("{}"));
1047        let clock = TestClock::new();
1048        let call = Call::post_json(
1049            "RunMicrovm",
1050            "/run",
1051            &serde_json::json!({"clientToken":"same-launch"}),
1052        )
1053        .unwrap();
1054        let (_, details) = send_configured(
1055            &transport,
1056            call.clone(),
1057            &[],
1058            Some(RetryPolicy::new(8, Duration::from_secs(3600)).unwrap()),
1059            &clock,
1060        )
1061        .await
1062        .unwrap();
1063        assert_eq!(details.attempt_count, 8);
1064        assert_eq!(details.backoff, clock.elapsed());
1065        assert!(details.backoff > Duration::ZERO);
1066        assert!(transport.calls().iter().all(|sent| sent == &call));
1067        transport.answer("GetMicrovm", Answer::failure(404, "missing"));
1068        let (reply, details) = send_configured(
1069            &transport,
1070            Call::get("GetMicrovm", "/missing"),
1071            &[404],
1072            None,
1073            &clock,
1074        )
1075        .await
1076        .unwrap();
1077        assert_eq!(reply.status, 404);
1078        assert_eq!(details.attempt_count, 1);
1079        assert_eq!(details.backoff, Duration::ZERO);
1080    }
1081
1082    #[tokio::test(start_paused = true)]
1083    async fn exhausted_public_retry_reports_throttle_attempts_and_backoff() {
1084        use crate::control::fake::{Answer, FakeControlPlane};
1085        for accepts_missing in [false, true] {
1086            let transport = FakeControlPlane::new();
1087            for _ in 0..6 {
1088                transport.answer("ListMicrovms", Answer::throttled_as_400());
1089            }
1090            transport.answer("ListMicrovms", Answer::ok("{}"));
1091            let call = Call::get("ListMicrovms", "/list");
1092            let error = if accepts_missing {
1093                send_accepting(&transport, call, &[404]).await
1094            } else {
1095                send_with_retry(&transport, call).await
1096            }
1097            .unwrap_err();
1098            assert_eq!(transport.call_count("ListMicrovms"), 6);
1099            assert!(error.throttled());
1100            assert_eq!(error.attempt_count(), 6);
1101            assert!(error.backoff() >= Duration::from_millis(6200));
1102            assert!(error.backoff() < Duration::from_millis(12400));
1103        }
1104        for status in [200, 404] {
1105            let transport = FakeControlPlane::new();
1106            transport.answer("GetMicrovm", Answer::failure(status, "accepted"));
1107            let reply = send_accepting(&transport, Call::get("GetMicrovm", "/missing"), &[404])
1108                .await
1109                .unwrap();
1110            assert_eq!(reply.status, status);
1111            assert_eq!(transport.call_count("GetMicrovm"), 1);
1112        }
1113    }
1114
1115    #[tokio::test]
1116    async fn a_cancel_signal_wakes_backoff_before_another_request() {
1117        use crate::clock::testing::YieldingClock;
1118        use crate::control::fake::{Answer, FakeControlPlane};
1119        let transport = FakeControlPlane::new();
1120        transport.answer("RunMicrovm", Answer::throttled_as_400());
1121        let clock = YieldingClock::new();
1122        let cancellation = RetryCancellation::default();
1123        let call = Call::get("RunMicrovm", "/run");
1124        let pending = send_cancellable(
1125            &transport,
1126            call,
1127            &[],
1128            Some(RetryPolicy::new(100, Duration::from_secs(3600)).unwrap()),
1129            &clock,
1130            Some(&cancellation),
1131        );
1132        tokio::pin!(pending);
1133        tokio::select! { biased; _ = &mut pending => panic!("backoff should yield"), _ = std::future::ready(()) => {} }
1134        cancellation.cancel();
1135        let error = pending.await.unwrap_err();
1136        assert_eq!(error.kind(), ErrorKind::Interrupted);
1137        assert_eq!(transport.call_count("RunMicrovm"), 1);
1138    }
1139
1140    #[tokio::test]
1141    async fn dropping_a_retry_future_prevents_another_request() {
1142        use crate::clock::testing::YieldingClock;
1143        use crate::control::fake::{Answer, FakeControlPlane};
1144        let transport = FakeControlPlane::new();
1145        transport.answer("RunMicrovm", Answer::throttled_as_400());
1146        let clock = YieldingClock::new();
1147        let mut future = Box::pin(send_configured(
1148            &transport,
1149            Call::get("RunMicrovm", "/run"),
1150            &[],
1151            Some(RetryPolicy::new(100, Duration::from_secs(3600)).unwrap()),
1152            &clock,
1153        ));
1154        tokio::select! { biased; _ = &mut future => panic!("retry sleep should yield"), _ = std::future::ready(()) => {} }
1155        drop(future);
1156        // Let any accidentally detached retry run after its backoff before checking.
1157        clock.advance(Duration::from_secs(3600));
1158        tokio::task::yield_now().await;
1159        tokio::task::yield_now().await;
1160        assert_eq!(transport.call_count("RunMicrovm"), 1);
1161    }
1162
1163    /// The endpoint, from the model's `endpointPrefix`. `lambda` and not
1164    /// `lambda-microvms` — which is the fact that makes TRAP-6 possible, since this
1165    /// resolves for every AWS region.
1166    #[test]
1167    fn the_endpoint_uses_the_models_lambda_prefix_for_every_region() {
1168        assert_eq!(
1169            endpoint_for(&Region::UsEast1),
1170            "https://lambda.us-east-1.amazonaws.com"
1171        );
1172        assert_eq!(
1173            endpoint_for(&Region::ApNortheast1),
1174            "https://lambda.ap-northeast-1.amazonaws.com"
1175        );
1176        // Including one that does not carry MicroVMs, which is the point.
1177        assert_eq!(
1178            endpoint_for(&Region::unlisted("eu-central-1")),
1179            "https://lambda.eu-central-1.amazonaws.com"
1180        );
1181    }
1182
1183    /// Every path against the model's `http.requestUri`, as literals. Transcribed from
1184    /// `service-2.json` rather than built from a template, because a template shared with
1185    /// the code under test would agree with a wrong template.
1186    #[test]
1187    fn every_path_matches_the_models_request_uri() {
1188        assert_eq!(paths::microvm_images(), "/2025-09-09/microvm-images");
1189        assert_eq!(
1190            paths::microvm_image("img"),
1191            "/2025-09-09/microvm-images/img"
1192        );
1193        assert_eq!(
1194            paths::image_versions("img", None),
1195            "/2025-09-09/microvm-images/img/versions"
1196        );
1197        assert_eq!(
1198            paths::image_version("img", "1"),
1199            "/2025-09-09/microvm-images/img/versions/1"
1200        );
1201        assert_eq!(
1202            paths::image_builds("img", "1", None),
1203            "/2025-09-09/microvm-images/img/versions/1/builds"
1204        );
1205        assert_eq!(
1206            paths::image_build("img", "1", "build-abc"),
1207            "/2025-09-09/microvm-images/img/versions/1/builds/build-abc"
1208        );
1209        assert_eq!(
1210            paths::managed_microvm_images(None),
1211            "/2025-09-09/managed-microvm-images"
1212        );
1213        assert_eq!(
1214            paths::managed_image_versions(
1215                "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1216                None
1217            ),
1218            "/2025-09-09/managed-microvm-images/\
1219             arn%3Aaws%3Alambda%3Aus-east-1%3Aaws%3Amicrovm-image%3Aal2023-1/versions"
1220        );
1221        assert_eq!(paths::microvms(), "/2025-09-09/microvms");
1222        assert_eq!(paths::microvms_list(None), "/2025-09-09/microvms");
1223        assert_eq!(paths::microvm("mvm-1"), "/2025-09-09/microvms/mvm-1");
1224        assert_eq!(
1225            paths::suspend("mvm-1"),
1226            "/2025-09-09/microvms/mvm-1/suspend"
1227        );
1228        assert_eq!(paths::resume("mvm-1"), "/2025-09-09/microvms/mvm-1/resume");
1229        assert_eq!(
1230            paths::auth_token("mvm-1"),
1231            "/2025-09-09/microvms/mvm-1/auth-token"
1232        );
1233        assert_eq!(
1234            paths::shell_auth_token("mvm-1"),
1235            "/2025-09-09/microvms/mvm-1/shell-auth-token"
1236        );
1237    }
1238
1239    /// The listing path carries its query members in the model's spelling, percent-encoded,
1240    /// and a bare listing is the plain collection path with no `?`.
1241    ///
1242    /// An opaque `nextToken` may carry `+`, `/`, or `=`; unencoded, those desynchronise the
1243    /// SigV4 canonical query from the query actually sent, and the rejection reads like bad
1244    /// credentials.
1245    #[test]
1246    fn the_image_listing_path_encodes_its_query_members() {
1247        assert_eq!(
1248            paths::microvm_images_list(None, None),
1249            "/2025-09-09/microvm-images"
1250        );
1251        assert_eq!(
1252            paths::microvm_images_list(Some("my-image"), None),
1253            "/2025-09-09/microvm-images?nameFilter=my-image"
1254        );
1255        assert_eq!(
1256            paths::microvm_images_list(Some("my-image"), Some("a+b/c=")),
1257            "/2025-09-09/microvm-images?nameFilter=my-image&nextToken=a%2Bb%2Fc%3D"
1258        );
1259        assert_eq!(
1260            paths::microvm_images_list(None, Some("token")),
1261            "/2025-09-09/microvm-images?nextToken=token"
1262        );
1263    }
1264
1265    /// The other three paginated listings encode their cursor the same way, and emit no
1266    /// `?` at all without one.
1267    ///
1268    /// Written as literals per listing rather than as a loop over a helper, because the
1269    /// bug this closes is a path that forgets the cursor entirely — and a loop over the
1270    /// helper would pass against three call sites that never pass one.
1271    ///
1272    /// **Falsification** — drop the [`paths::encode_segment`] call from
1273    /// `with_next_token` and every `%2B`/`%2F`/`%3D` assertion below goes red, which is
1274    /// the SigV4 canonical-query desynchronisation this encoder exists to prevent.
1275    #[test]
1276    fn the_other_paginated_listings_encode_their_cursor_too() {
1277        assert_eq!(
1278            paths::image_versions("img", Some("a+b/c=")),
1279            "/2025-09-09/microvm-images/img/versions?nextToken=a%2Bb%2Fc%3D"
1280        );
1281        assert_eq!(
1282            paths::image_builds("img", "1", Some("a+b/c=")),
1283            "/2025-09-09/microvm-images/img/versions/1/builds?nextToken=a%2Bb%2Fc%3D"
1284        );
1285        assert_eq!(
1286            paths::microvms_list(Some("a+b/c=")),
1287            "/2025-09-09/microvms?nextToken=a%2Bb%2Fc%3D"
1288        );
1289
1290        // No cursor means no query string, not an empty one: `?nextToken=` is a member
1291        // present and blank, which is a different request from a member absent.
1292        for path in [
1293            paths::image_versions("img", None),
1294            paths::image_builds("img", "1", None),
1295            paths::microvms_list(None),
1296        ] {
1297            assert!(
1298                !path.contains('?'),
1299                "an absent cursor emits no query: {path}"
1300            );
1301        }
1302    }
1303
1304    /// The version listing's explicit page size: ordered before the cursor, clamped to the
1305    /// model's `1..=50`, and absent from the path when nothing asks for one.
1306    ///
1307    /// The clamp is asserted at both ends because a value outside the bound is a
1308    /// `ValidationException`, and the only caller of this function
1309    /// (`tests/live_pagination.rs`) is trying to *observe* a real cursor — a run that
1310    /// failed on the page size instead would prove nothing about the encoding it came for.
1311    ///
1312    /// **Falsification** — remove the `.clamp(1, 50)` and the `0` and `999` cases go red
1313    /// with `maxResults=0` and `maxResults=999`, both of which the service refuses.
1314    #[test]
1315    fn the_version_listings_explicit_page_size_is_ordered_and_clamped() {
1316        assert_eq!(
1317            paths::image_versions_paged("img", None, Some(1)),
1318            "/2025-09-09/microvm-images/img/versions?maxResults=1"
1319        );
1320        // Both members, and `maxResults` first — the order the query is built in, which is
1321        // asserted so a reordering is a visible change rather than a silent one.
1322        assert_eq!(
1323            paths::image_versions_paged("img", Some("a+b/c="), Some(2)),
1324            "/2025-09-09/microvm-images/img/versions?maxResults=2&nextToken=a%2Bb%2Fc%3D"
1325        );
1326        assert_eq!(
1327            paths::image_versions_paged("img", None, Some(0)),
1328            "/2025-09-09/microvm-images/img/versions?maxResults=1",
1329            "the model's min is 1; a 0 would be refused"
1330        );
1331        assert_eq!(
1332            paths::image_versions_paged("img", None, Some(999)),
1333            "/2025-09-09/microvm-images/img/versions?maxResults=50",
1334            "the model's max is 50; a larger value would be refused"
1335        );
1336
1337        // And the production spelling is unchanged by the new one existing: `image_versions`
1338        // delegates here with no page size, so it must still emit exactly what it did.
1339        assert_eq!(
1340            paths::image_versions_paged("img", None, None),
1341            paths::image_versions("img", None)
1342        );
1343        assert!(
1344            !paths::image_versions("img", Some("t")).contains("maxResults"),
1345            "no production path names a page size: {}",
1346            paths::image_versions("img", Some("t"))
1347        );
1348    }
1349
1350    /// The **managed** routes are a different collection from the account's own, and their
1351    /// cursor is encoded the same way.
1352    ///
1353    /// Asserted as its own case because the plausible mistake is treating the managed listing
1354    /// as a filter on `microvm-images` — it is a separate `requestUri` in the model with a
1355    /// different response shape, so a path built from [`paths::microvm_images`] would address
1356    /// the account's images and answer 200 with the wrong items.
1357    ///
1358    /// **Falsification** — drop the `managed-` prefix from either function and the two
1359    /// literals below go red while every request still succeeds against the real service,
1360    /// which is exactly why these are literals transcribed from the model rather than a
1361    /// shared template.
1362    #[test]
1363    fn the_managed_routes_are_their_own_collection_and_encode_their_cursor() {
1364        assert!(
1365            !paths::managed_microvm_images(None).contains('?'),
1366            "an absent cursor emits no query member"
1367        );
1368        assert_eq!(
1369            paths::managed_microvm_images(Some("a+b/c=")),
1370            "/2025-09-09/managed-microvm-images?nextToken=a%2Bb%2Fc%3D"
1371        );
1372        assert_eq!(
1373            paths::managed_image_versions("al2023-1", Some("a+b/c=")),
1374            "/2025-09-09/managed-microvm-images/al2023-1/versions?nextToken=a%2Bb%2Fc%3D"
1375        );
1376
1377        // Not a filter on the account's own listing: the two collections are different path
1378        // segments, and reading one for the other answers 200 with the wrong items.
1379        assert_ne!(
1380            paths::managed_microvm_images(None),
1381            paths::microvm_images_list(None, None)
1382        );
1383        assert!(
1384            !paths::microvm_images().contains("managed"),
1385            "the account's own collection must not acquire the managed prefix"
1386        );
1387    }
1388
1389    /// A `GET` of one build is the list path plus one encoded segment, and it carries no query
1390    /// member.
1391    ///
1392    /// The `buildId` is percent-encoded even though a real one is a UUID: the model types it
1393    /// `NonBlankString`, so the encoder does not get to assume the shape of a value the
1394    /// service mints.
1395    #[test]
1396    fn one_builds_path_is_the_listing_plus_an_encoded_segment() {
1397        let path = paths::image_build("img", "1", "4a4c5e30-811f-47fa-9893-260ea6a37a8f");
1398        assert_eq!(
1399            path,
1400            "/2025-09-09/microvm-images/img/versions/1/builds/4a4c5e30-811f-47fa-9893-260ea6a37a8f"
1401        );
1402        assert!(!path.contains('?'), "{path}");
1403        assert!(
1404            path.starts_with(&paths::image_builds("img", "1", None)),
1405            "the get path must extend the list path rather than be a second route: {path}"
1406        );
1407        // And an identifier with a separator in it cannot split into extra segments.
1408        assert_eq!(
1409            paths::image_build("img", "1", "a/b"),
1410            "/2025-09-09/microvm-images/img/versions/1/builds/a%2Fb"
1411        );
1412    }
1413
1414    /// A `DELETE` of one version carries no query member, even though its sibling
1415    /// [`paths::image_versions`] now takes a cursor. A version path with `?nextToken=`
1416    /// appended addresses nothing.
1417    #[test]
1418    fn deleting_one_version_carries_no_pagination_cursor() {
1419        let path = paths::image_version("img", "1");
1420        assert_eq!(path, "/2025-09-09/microvm-images/img/versions/1");
1421        assert!(!path.contains('?'), "{path}");
1422        assert!(!path.contains("nextToken"), "{path}");
1423    }
1424
1425    /// A **real** image ARN in a URI parameter is percent-encoded, every colon included.
1426    /// `MicrovmImageIdentifier` is documented as "ARN or ID", so this is the ordinary case
1427    /// rather than an edge one.
1428    ///
1429    /// The literal is the shape the service actually returns, measured 2026-08-15 in
1430    /// us-east-1: `microvm-image:<name>` with a **colon**, which is also the only form the
1431    /// model's `TaggableResource` pattern admits. This test used to hold
1432    /// `microvm-image/img`, and the slash was not a harmless stand-in — `GetMicrovmImage`
1433    /// on that form answers `AccessDeniedException`, because IAM evaluates a malformed ARN
1434    /// as a resource with no matching policy.
1435    #[test]
1436    fn a_real_arn_identifier_is_percent_encoded_colons_and_all() {
1437        let arn = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
1438        let path = paths::microvm_image(arn);
1439        assert_eq!(
1440            path,
1441            "/2025-09-09/microvm-images/arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg"
1442        );
1443        assert!(
1444            !path["/2025-09-09/microvm-images/".len()..].contains(':'),
1445            "an unencoded colon in a path segment is a signature mismatch: {path}"
1446        );
1447    }
1448
1449    /// The encoder still encodes `/`, which is the property that matters even though a real
1450    /// image ARN carries none.
1451    ///
1452    /// Kept as its own case rather than folded into the test above, because the input here
1453    /// is a *shape the service does not use* and labelling it "an ARN" is what let the slash
1454    /// form look load-bearing for twelve fakes. An unencoded slash would split into extra
1455    /// path segments addressing something else, and other identifiers this encoder sees
1456    /// (`imageVersion`, a `nextToken` cursor) genuinely can contain one.
1457    #[test]
1458    fn the_segment_encoder_encodes_slashes_wherever_they_come_from() {
1459        assert_eq!(paths::encode_segment("a/b"), "a%2Fb");
1460        let path = paths::microvm_image("has/a/slash");
1461        assert_eq!(path, "/2025-09-09/microvm-images/has%2Fa%2Fslash");
1462        assert!(
1463            !path["/2025-09-09/microvm-images/".len()..].contains('/'),
1464            "an encoded identifier contributes no path separators: {path}"
1465        );
1466    }
1467
1468    /// The API version in the path is the constant the drift gate checks, not a second
1469    /// literal. Two copies of `2025-09-09` are two things to update, and the one that gets
1470    /// missed is the one nothing compiles against.
1471    #[test]
1472    fn the_path_version_is_the_pinned_model_version() {
1473        assert_eq!(API_PATH_VERSION, crate::constants::MODEL_API_VERSION);
1474        assert!(paths::microvms().starts_with("/2025-09-09/"));
1475    }
1476
1477    /// The signing name is `lambda`, from the model's `signingName`. A signature computed
1478    /// for `lambda-microvms` is rejected in a way that reads like bad credentials.
1479    #[test]
1480    fn the_signing_name_is_lambda_not_lambda_microvms() {
1481        assert_eq!(SIGNING_NAME, "lambda");
1482    }
1483
1484    /// TRAP-6: a missing message cannot distinguish service availability from IAM.
1485    #[test]
1486    fn a_null_message_access_denied_names_both_possible_causes() {
1487        let reply = Reply {
1488            status: 403,
1489            body: br#"{"message": null}"#.to_vec(),
1490            error_type: None,
1491        };
1492        let error = classify_failure("ListMicrovms", &reply);
1493        assert_eq!(error.kind(), ErrorKind::Credentials);
1494        let message = error.to_string();
1495        assert!(message.contains("null"), "{message}");
1496        assert!(message.contains("region"), "{message}");
1497        assert!(
1498            message.contains("unsupported region or an IAM denial"),
1499            "{message}"
1500        );
1501        assert!(message.contains("lambda:TagResource"), "{message}");
1502        assert!(message.contains("cloudtrail lookup-events"), "{message}");
1503    }
1504
1505    /// TRAP-6: known service regions prioritize IAM and preserve the event name for lookup.
1506    #[tokio::test]
1507    async fn supported_region_null_denials_point_to_iam_in_both_send_paths() {
1508        use crate::control::fake::{Answer, FakeControlPlane};
1509        let transport = FakeControlPlane::in_region(Region::UsEast1);
1510        transport.answer(
1511            "CreateMicrovmImage",
1512            Answer {
1513                status: 403,
1514                body: r#"{"message":null}"#.to_string(),
1515                error_type: Some("AccessDeniedException".to_string()),
1516            },
1517        );
1518        for accept in [false, true] {
1519            let call = Call::get("CreateMicrovmImage", "/images");
1520            let error = if accept {
1521                send_accepting(&transport, call, &[404]).await.unwrap_err()
1522            } else {
1523                send_with_retry(&transport, call).await.unwrap_err()
1524            };
1525            assert_eq!(error.kind(), ErrorKind::Credentials);
1526            assert!(!error.retryable());
1527            let message = error.to_string();
1528            for detail in [
1529                "us-east-1 carries MicroVMs",
1530                "policy is the likely cause",
1531                "lambda:TagResource",
1532                "cloudtrail lookup-events --region us-east-1",
1533                "AttributeValue=CreateMicrovmImage",
1534                "errorMessage",
1535            ] {
1536                assert!(message.contains(detail), "{message}");
1537            }
1538            assert!(!message.contains("Check the region before the policy"));
1539        }
1540        assert_eq!(transport.call_count("CreateMicrovmImage"), 2);
1541    }
1542
1543    /// TRAP-6: an explicit unlisted region does not rule out an IAM denial.
1544    #[test]
1545    fn unlisted_region_null_denials_keep_both_causes_visible() {
1546        let reply = Reply {
1547            status: 403,
1548            body: b"{}".to_vec(),
1549            error_type: None,
1550        };
1551        let error = classify_failure_in_region(
1552            "ListMicrovms",
1553            &reply,
1554            Some(&Region::unlisted("eu-central-1")),
1555        );
1556        let message = error.to_string();
1557        assert!(message.contains("verify service availability as well as the IAM policy"));
1558        assert!(message.contains("--region eu-central-1"));
1559        assert!(!message.contains("policy is the likely cause"));
1560    }
1561
1562    /// A 403 *with* a message is a genuine denial and says so, so the two are not
1563    /// conflated. A classifier that named the region trap on every 403 would be as
1564    /// misleading in the other direction.
1565    #[test]
1566    fn a_403_with_a_message_is_reported_as_a_genuine_denial() {
1567        let reply = Reply {
1568            status: 403,
1569            body: br#"{"message": "User: arn:aws:iam::1:user/u is not authorized"}"#.to_vec(),
1570            error_type: None,
1571        };
1572        let error = classify_failure("RunMicrovm", &reply);
1573        assert_eq!(error.kind(), ErrorKind::Credentials);
1574        let message = error.to_string();
1575        assert!(message.contains("not authorized"), "{message}");
1576        assert!(
1577            message.contains("genuine denial"),
1578            "must distinguish itself from the null-message case: {message}"
1579        );
1580        assert!(
1581            !message.contains("does not carry MicroVMs"),
1582            "must not blame the region when the service named a principal: {message}"
1583        );
1584    }
1585
1586    /// A 400 is the service refusing a value this client should have refused first, so the
1587    /// message names the validation refusal and the local checks it got past, not the
1588    /// catch-all for a status the model doesn't declare.
1589    #[test]
1590    fn a_400_names_the_validation_refusal_and_the_local_checks_it_passed() {
1591        let reply = Reply {
1592            status: 400,
1593            body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1594            error_type: None,
1595        };
1596        let error = classify_failure("RunMicrovm", &reply);
1597        assert_eq!(error.kind(), ErrorKind::Platform);
1598        let message = error.to_string();
1599        assert!(message.contains("ValidationException"), "{message}");
1600        assert!(message.contains("checked locally"), "{message}");
1601        assert!(!message.contains("unmodeled status"), "{message}");
1602    }
1603
1604    /// The service's throttle as it arrived from `ListMicrovms` on 2026-10-02: HTTP 400,
1605    /// named `ThrottlingException` only by the `x-amzn-ErrorType` header, with a null
1606    /// message. Classified by status it read as a validation refusal and was never
1607    /// retried, which failed every listing past the account's page rate.
1608    ///
1609    /// **Falsification** — run 2026-10-02. Guard the classifier's header arm with `false`, so
1610    /// the status alone decides again, and this fails on the retryability assertion with the
1611    /// `ValidationException` explanation (`guards:fire -- --only
1612    /// control-throttle-classified-by-status`). Restored.
1613    #[test]
1614    fn a_throttle_named_by_the_error_type_header_is_retryable_whatever_the_status() {
1615        let reply = Reply {
1616            status: 400,
1617            body: br#"{"message":null,"throttlingReasons":null}"#.to_vec(),
1618            error_type: Some("ThrottlingException".to_string()),
1619        };
1620        let error = classify_failure("ListMicrovms", &reply);
1621        assert!(error.retryable(), "{error}");
1622        assert!(error.throttled(), "the header identifies the throttle");
1623        assert_eq!(error.kind(), ErrorKind::Retryable);
1624        let message = error.to_string();
1625        assert!(message.contains("ThrottlingException"), "{message}");
1626        assert!(message.contains("HTTP 400"), "{message}");
1627        assert!(!message.contains("ValidationException"), "{message}");
1628    }
1629
1630    /// Another error name on a 400 leaves the status rule alone: the header decides only
1631    /// for the throttle, so a genuine validation refusal still isn't retried.
1632    #[test]
1633    fn a_400_naming_another_error_type_is_still_a_validation_refusal() {
1634        let reply = Reply {
1635            status: 400,
1636            body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1637            error_type: Some("ValidationException".to_string()),
1638        };
1639        let error = classify_failure("RunMicrovm", &reply);
1640        assert!(!error.retryable(), "{error}");
1641        assert!(error.to_string().contains("checked locally"), "{error}");
1642    }
1643
1644    /// The header's name is what precedes the first `:` and follows the first `#`, the
1645    /// rest-json rule, on the value the service sent and on the namespaced form.
1646    ///
1647    /// **Falsification** — run 2026-10-02. Keep the whole header instead of what precedes the
1648    /// `:`, and the service's own value comes back with its URI suffix and fails the first
1649    /// case (`guards:fire -- --only control-error-type-keeps-the-uri`). Restored.
1650    #[test]
1651    fn the_error_type_name_drops_the_uri_suffix_and_the_namespace() {
1652        let cases = [
1653            (
1654                "ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/",
1655                "ThrottlingException",
1656            ),
1657            (
1658                "aws.protocoltests.restjson#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate/",
1659                "FooError",
1660            ),
1661            (
1662                "com.amazonaws.lambda#ResourceNotFoundException",
1663                "ResourceNotFoundException",
1664            ),
1665            ("ConflictException", "ConflictException"),
1666        ];
1667        for (header, name) in cases {
1668            assert_eq!(error_type_name(header), name, "{header}");
1669        }
1670    }
1671
1672    /// Only the throttle and the 5xx family are retryable. A retried 409 would spin
1673    /// against an image in CREATING, and a retried 400 would send the same rejected value
1674    /// five more times.
1675    #[test]
1676    fn only_throttles_and_server_faults_are_retryable() {
1677        let cases = [
1678            (400, false),
1679            (402, false),
1680            (403, false),
1681            (404, false),
1682            (409, false),
1683            (429, true),
1684            (500, true),
1685            (502, true),
1686            (503, true),
1687        ];
1688        for (status, retryable) in cases {
1689            let reply = Reply {
1690                status,
1691                body: br#"{"message": "detail"}"#.to_vec(),
1692                error_type: None,
1693            };
1694            let error = classify_failure("GetMicrovm", &reply);
1695            assert_eq!(error.retryable(), retryable, "HTTP {status}");
1696            assert_eq!(error.throttled(), status == 429, "HTTP {status}");
1697        }
1698    }
1699
1700    /// A control-plane failure carries **no** `WireKind`: nothing reached the in-VM
1701    /// daemon, so there is no daemon-chosen status to report and the CLI's `data.kind`
1702    /// must be absent rather than invented.
1703    #[test]
1704    fn a_control_plane_failure_carries_no_daemon_wire_kind() {
1705        for status in [400, 403, 404, 409, 429, 500] {
1706            let reply = Reply {
1707                status,
1708                body: Vec::new(),
1709                error_type: None,
1710            };
1711            assert_eq!(
1712                classify_failure("GetMicrovm", &reply).wire_kind(),
1713                None,
1714                "HTTP {status} never reached the daemon"
1715            );
1716        }
1717    }
1718
1719    /// An unparseable error body still classifies by status rather than failing. A service
1720    /// answering an HTML error page or a truncated body must not turn a 429 into an
1721    /// unexpected error, which would stop the retry.
1722    #[test]
1723    fn an_unparseable_error_body_still_classifies_by_status() {
1724        let reply = Reply {
1725            status: 429,
1726            body: b"<html>Too Many Requests</html>".to_vec(),
1727            error_type: None,
1728        };
1729        let error = classify_failure("RunMicrovm", &reply);
1730        assert!(error.retryable(), "a throttle is a throttle");
1731        assert!(error.to_string().contains("no message"), "{error}");
1732    }
1733
1734    /// A body-parse failure names the operation and shows the body, because a rest-json
1735    /// parse error with the text withheld cannot distinguish a renamed member from a
1736    /// truncated response.
1737    #[test]
1738    fn a_response_that_does_not_parse_reports_the_body() {
1739        let reply = Reply {
1740            status: 200,
1741            body: br#"{"microvmId": 42}"#.to_vec(),
1742            error_type: None,
1743        };
1744        let error = reply
1745            .json::<crate::control::ops::MicrovmResponseWire>("GetMicrovm")
1746            .expect_err("microvmId is a string shape");
1747        let message = error.to_string();
1748        assert!(message.contains("GetMicrovm"), "{message}");
1749        assert!(message.contains("microvmId"), "{message}");
1750        assert_eq!(error.kind(), ErrorKind::Platform);
1751    }
1752
1753    /// A `POST` with no body members sends `{}` rather than nothing: an empty POST and a
1754    /// POST of `{}` are different canonical requests, and rest-json services answer the
1755    /// former inconsistently.
1756    #[test]
1757    fn a_bodyless_post_sends_an_empty_json_object() {
1758        let call = Call::post_empty("SuspendMicrovm", paths::suspend("mvm-1"));
1759        assert_eq!(call.body.as_deref(), Some(&b"{}"[..]));
1760        assert_eq!(call.method, Method::Post);
1761    }
1762
1763    /// A `GET` and a `DELETE` carry no body at all, and the signed empty payload is what
1764    /// `body_bytes` yields.
1765    #[test]
1766    fn a_get_and_a_delete_carry_no_body() {
1767        let get = Call::get("GetMicrovm", paths::microvm("mvm-1"));
1768        assert_eq!(get.body, None);
1769        assert_eq!(get.body_bytes(), b"");
1770
1771        let delete = Call::delete("TerminateMicrovm", paths::microvm("mvm-1"));
1772        assert_eq!(delete.body, None);
1773        assert_eq!(delete.method, Method::Delete);
1774    }
1775
1776    /// The methods, spelled as the canonical request needs them: uppercase.
1777    #[test]
1778    fn the_methods_are_the_ones_the_model_uses_spelled_uppercase() {
1779        assert_eq!(Method::Get.as_str(), "GET");
1780        assert_eq!(Method::Post.as_str(), "POST");
1781        assert_eq!(Method::Patch.as_str(), "PATCH");
1782        assert_eq!(Method::Delete.as_str(), "DELETE");
1783    }
1784
1785    /// A `PATCH` carries its serialized body and is **not** a `POST`.
1786    ///
1787    /// The distinction is load-bearing rather than cosmetic: `UpdateMicrovmImageVersion` is the
1788    /// model's only `PATCH`, and a `POST` to the version path is a route the service does not
1789    /// declare — so the failure would be a 404 that reads as a missing version rather than as a
1790    /// wrong method. The body assertion is here too, because a `PATCH` whose body was dropped
1791    /// would be a request with no `status` member at all.
1792    ///
1793    /// **Falsification** — set `method: Method::Post` in `patch_json` and the method assertion
1794    /// goes red; the path and body assertions would still pass, which is why the method is
1795    /// asserted separately.
1796    #[test]
1797    fn a_patch_carries_its_body_and_is_not_a_post() {
1798        let call = Call::patch_json(
1799            "UpdateMicrovmImageVersion",
1800            paths::image_version("img", "2.0"),
1801            &crate::control::ops::UpdateImageVersionWire {
1802                status: crate::control::ops::VersionStatus::Inactive,
1803            },
1804        )
1805        .expect("serialises");
1806
1807        assert_eq!(call.method, Method::Patch);
1808        assert_ne!(
1809            call.method,
1810            Method::Post,
1811            "a POST to the version path is a route the model does not declare"
1812        );
1813        assert_eq!(call.path, "/2025-09-09/microvm-images/img/versions/2.0");
1814        assert_eq!(
1815            call.body.as_deref(),
1816            Some(&br#"{"status":"INACTIVE"}"#[..]),
1817            "the one member the request has must reach the body"
1818        );
1819        assert_eq!(call.operation, "UpdateMicrovmImageVersion");
1820    }
1821}