squib_api/error.rs
1//! Error type and the wire-shape Firecracker uses for failed requests.
2//!
3//! Status code mapping (per [20-firecracker-api.md §
4//! 3](../../../specs/20-firecracker-api.md#3-error-envelope)):
5//!
6//! - **400 Bad Request** — malformed JSON, missing required fields, invalid enum values, illegal
7//! state transitions, unknown paths (Firecracker collapses 404 to 400 for the wire compat-suite
8//! shape), validation failures.
9//! - **413 Payload Too Large** — bodies above `--http-api-max-payload-size`, oversized MMDS data
10//! store mutations.
11//! - **500 Internal Server Error** — VMM event loop is gone; rare and logged at `error`.
12//! - **504 Gateway Timeout** — squib-only; emitted when a per-action-class timeout ([70-security.md
13//! § 6](../../../specs/70-security.md#6-resource-limits)) fires while the controller awaits the
14//! VMM. Upstream Firecracker has no 504; orchestrators should retry with the same idempotency
15//! key. Documented in `docs/api-deviations.md`.
16//!
17//! Successful PUT/PATCH/DELETE produce `204 No Content` directly without an `ApiError`.
18
19use axum::{
20 Json,
21 http::StatusCode,
22 response::{IntoResponse, Response},
23};
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27/// Result alias used throughout `squib-api`.
28pub type Result<T, E = ApiError> = core::result::Result<T, E>;
29
30/// The exact JSON body upstream Firecracker emits on every failed API call.
31///
32/// Wire shape:
33/// ```json
34/// {"fault_message": "Block device with ID 'rootfs' already exists"}
35/// ```
36///
37/// No additional fields, ever — sniffed verbatim by SDKs and `firectl`.
38#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
39pub struct FaultMessage {
40 /// Human-readable description of what went wrong. Squib makes a best-effort
41 /// attempt to mirror upstream's phrasing for known failure modes.
42 pub fault_message: String,
43}
44
45impl FaultMessage {
46 /// Construct a [`FaultMessage`] from a string-like value.
47 pub fn new(reason: impl Into<String>) -> Self {
48 Self {
49 fault_message: reason.into(),
50 }
51 }
52}
53
54/// Errors produced by API handlers, each carrying the HTTP status code Firecracker
55/// returns for that class of failure.
56///
57/// Status codes match upstream where they exist (200 / 204 / 400 / 413 / 500). 504 is
58/// squib-only and surfaces the per-action-class timeout taxonomy from [70-security.md
59/// § 6](../../../specs/70-security.md#6-resource-limits).
60#[derive(Debug, Error)]
61pub enum ApiError {
62 /// Generic 400 error with a custom fault message — the bulk of validation /
63 /// state-machine / parse failures land here.
64 #[error("{0}")]
65 BadRequest(String),
66
67 /// 413 Payload Too Large — used by the body-size limit middleware and by MMDS
68 /// endpoints when the data store would exceed `--mmds-size-limit`.
69 #[error("{0}")]
70 PayloadTooLarge(String),
71
72 /// 404 Not Found — the path does not match any registered route. We translate this
73 /// to a 400 in the response (axum's default 404 is overridden); Firecracker has no
74 /// 404 in its surface.
75 #[error("Resource not found: {0}")]
76 NotFound(String),
77
78 /// 504 Gateway Timeout — squib-only. Emitted when a per-action-class timeout fires
79 /// while the controller awaits the VMM. The action remains pending at the VMM
80 /// (cancelling it would leave undefined state); the controller logs at `error`.
81 /// `&'static str` because the action class name is always known at the call site.
82 #[error("VMM action timed out: {0}")]
83 Timeout(&'static str),
84
85 /// 500 Internal Server Error — used when the VMM event loop has shut down and a
86 /// handler can no longer dispatch. Rare; logged at `error`.
87 #[error("internal error: {0}")]
88 Internal(String),
89}
90
91impl ApiError {
92 /// Status code this error variant maps to.
93 pub fn status(&self) -> StatusCode {
94 match self {
95 Self::BadRequest(_) | Self::NotFound(_) => StatusCode::BAD_REQUEST,
96 Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
97 Self::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
98 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
99 }
100 }
101
102 /// The exact `fault_message` body string this error surfaces.
103 pub fn fault_message(&self) -> String {
104 match self {
105 Self::BadRequest(s) | Self::PayloadTooLarge(s) | Self::Internal(s) => s.clone(),
106 Self::NotFound(path) => format!("No such resource: {path}"),
107 Self::Timeout(class) => format!("VMM action timed out: {class}"),
108 }
109 }
110}
111
112impl IntoResponse for ApiError {
113 fn into_response(self) -> Response {
114 let status = self.status();
115 let body = FaultMessage::new(self.fault_message());
116 (status, Json(body)).into_response()
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn fault_message_serializes_with_correct_field_name() {
126 let msg = FaultMessage::new("oops");
127 let json = serde_json::to_string(&msg).unwrap();
128 assert_eq!(json, r#"{"fault_message":"oops"}"#);
129 }
130
131 #[test]
132 fn fault_message_round_trips_through_serde() {
133 let original = FaultMessage::new("kernel image not found");
134 let json = serde_json::to_string(&original).unwrap();
135 let back: FaultMessage = serde_json::from_str(&json).unwrap();
136 assert_eq!(original, back);
137 }
138
139 #[test]
140 fn bad_request_maps_to_400() {
141 let err = ApiError::BadRequest("bad".into());
142 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
143 }
144
145 #[test]
146 fn payload_too_large_maps_to_413() {
147 let err = ApiError::PayloadTooLarge("too big".into());
148 assert_eq!(err.status(), StatusCode::PAYLOAD_TOO_LARGE);
149 }
150
151 #[test]
152 fn not_found_maps_to_400_for_firecracker_parity() {
153 let err = ApiError::NotFound("/missing".into());
154 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
155 }
156
157 #[test]
158 fn timeout_maps_to_504() {
159 let err = ApiError::Timeout("PUT /actions {InstanceStart}");
160 assert_eq!(err.status(), StatusCode::GATEWAY_TIMEOUT);
161 assert!(err.fault_message().contains("InstanceStart"));
162 }
163
164 #[test]
165 fn internal_maps_to_500() {
166 let err = ApiError::Internal("event loop gone".into());
167 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
168 }
169
170 #[test]
171 fn not_found_fault_message_contains_path() {
172 let err = ApiError::NotFound("/no-such".into());
173 assert_eq!(err.fault_message(), "No such resource: /no-such");
174 }
175}