Skip to main content

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}