Skip to main content

reposix_sim/
error.rs

1//! Typed error types for the sim crate.
2//!
3//! Two distinct error types live here, by design:
4//!
5//! - [`ApiError`] — uniform error type for every axum handler. Implements
6//!   [`IntoResponse`] so handlers can `?` into HTTP responses. Each variant
7//!   carries the minimum information the caller needs; the full error chain
8//!   is logged via `tracing::error!` and does NOT leak into the response body
9//!   (T-02-04: no rusqlite internals to clients).
10//! - [`SimError`] — the error type returned by the crate's library surface
11//!   (`run`, `run_with_listener`, `prepare_state`). Composed of a small set
12//!   of typed variants plus `#[from]` on [`ApiError`] so internal `?` works.
13//!   The library boundary returns this; the `reposix-sim` binary adapts it
14//!   to `anyhow::Error` automatically because `SimError: std::error::Error`.
15
16use axum::{
17    http::StatusCode,
18    response::{IntoResponse, Response},
19    Json,
20};
21use serde_json::{json, Value};
22use thiserror::Error;
23
24/// Every error the sim's HTTP handlers can raise.
25#[derive(Debug, Error)]
26pub enum ApiError {
27    /// Resource absent. Produces 404.
28    #[error("not found")]
29    NotFound,
30
31    /// Client-supplied input failed validation. Produces 400.
32    #[error("bad request: {0}")]
33    BadRequest(String),
34
35    /// `If-Match` version did not match the current row's version. Produces 409.
36    #[error("version mismatch: current={current} sent={sent:?}")]
37    VersionMismatch {
38        /// Server-side current version (what the client should have sent).
39        current: u64,
40        /// Raw If-Match value as received (without RFC-7232 quotes).
41        sent: String,
42    },
43
44    /// Underlying `SQLite` error. Produces 500 (opaque body). The detailed
45    /// error is logged via `tracing::error!` server-side.
46    #[error("db error: {0}")]
47    Db(#[from] rusqlite::Error),
48
49    /// Underlying JSON error. Produces 400 (request-side) or 500
50    /// (response-side). Handler code decides via `ApiError::BadRequest` which
51    /// side of the boundary the error came from; this variant is the escape
52    /// hatch for library-level Serde failures.
53    #[error("json error: {0}")]
54    Json(#[from] serde_json::Error),
55
56    /// Internal invariant violation (e.g. schema load returned Err, or a
57    /// unicode assumption about label JSON failed). Produces 500 with an
58    /// opaque body.
59    #[error("internal error: {0}")]
60    Internal(String),
61}
62
63impl ApiError {
64    /// HTTP status for this error.
65    #[must_use]
66    pub fn status(&self) -> StatusCode {
67        match self {
68            Self::NotFound => StatusCode::NOT_FOUND,
69            Self::BadRequest(_) => StatusCode::BAD_REQUEST,
70            Self::VersionMismatch { .. } => StatusCode::CONFLICT,
71            Self::Db(_) | Self::Json(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
72        }
73    }
74
75    /// Stable error-kind string for the JSON body.
76    #[must_use]
77    pub fn kind(&self) -> &'static str {
78        match self {
79            Self::NotFound => "not_found",
80            Self::BadRequest(_) => "bad_request",
81            Self::VersionMismatch { .. } => "version_mismatch",
82            Self::Db(_) | Self::Json(_) | Self::Internal(_) => "internal",
83        }
84    }
85}
86
87impl IntoResponse for ApiError {
88    fn into_response(self) -> Response {
89        let status = self.status();
90        let kind = self.kind();
91        let body: Value = match &self {
92            Self::NotFound => json!({"error": kind, "message": "not found"}),
93            Self::BadRequest(msg) => json!({"error": kind, "message": msg}),
94            Self::VersionMismatch { current, sent } => {
95                json!({
96                    "error": kind,
97                    "current": current,
98                    "sent": sent,
99                })
100            }
101            // Do not leak internal details — log, then return opaque body.
102            Self::Db(e) => {
103                tracing::error!(error = %e, "db error");
104                json!({"error": kind, "message": "internal error"})
105            }
106            Self::Json(e) => {
107                tracing::error!(error = %e, "json error");
108                json!({"error": kind, "message": "internal error"})
109            }
110            Self::Internal(msg) => {
111                tracing::error!(error = %msg, "internal error");
112                json!({"error": kind, "message": "internal error"})
113            }
114        };
115        (status, Json(body)).into_response()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::ApiError;
122    use axum::response::IntoResponse;
123
124    #[test]
125    fn version_mismatch_is_409() {
126        let resp = ApiError::VersionMismatch {
127            current: 5,
128            sent: "bogus".into(),
129        }
130        .into_response();
131        assert_eq!(resp.status().as_u16(), 409);
132    }
133
134    #[test]
135    fn not_found_is_404() {
136        let resp = ApiError::NotFound.into_response();
137        assert_eq!(resp.status().as_u16(), 404);
138    }
139
140    #[test]
141    fn bad_request_is_400() {
142        let resp = ApiError::BadRequest("nope".into()).into_response();
143        assert_eq!(resp.status().as_u16(), 400);
144    }
145
146    #[test]
147    fn db_error_is_500() {
148        // Connection::open on a bogus path yields an rusqlite::Error.
149        let conn = rusqlite::Connection::open_in_memory().unwrap();
150        let err = conn.prepare("SELECT * FROM does_not_exist").unwrap_err();
151        let resp = ApiError::Db(err).into_response();
152        assert_eq!(resp.status().as_u16(), 500);
153    }
154}
155
156// --------------------------------------------------------------------------
157// SimError — library-surface error type for `run`, `run_with_listener`, etc.
158// --------------------------------------------------------------------------
159
160/// The error type returned by the simulator crate's public library API.
161///
162/// Distinct from [`ApiError`] (which is the per-request HTTP error type that
163/// implements [`IntoResponse`]). `SimError` is what `run`, `run_with_listener`,
164/// and `prepare_state` return; it composes the underlying typed variants
165/// (I/O, bind failures, [`ApiError`]) so callers can pattern-match if they
166/// need to and so the `reposix-sim` binary can adapt to `anyhow::Error` for
167/// free via the blanket `From<E: std::error::Error + Send + Sync + 'static>`
168/// impl on `anyhow::Error`.
169#[derive(Debug, Error)]
170#[non_exhaustive]
171pub enum SimError {
172    /// Generic I/O failure — covers `axum::serve` (which returns `io::Error`),
173    /// `TcpListener::local_addr`, and any unspecified I/O during startup.
174    #[error("io: {0}")]
175    Io(#[from] std::io::Error),
176
177    /// Failed to bind the configured listener address.
178    #[error("bind {addr}: {source}")]
179    Bind {
180        /// Address that failed to bind, for operator diagnostics.
181        addr: String,
182        /// Underlying I/O error from `TcpListener::bind`.
183        #[source]
184        source: std::io::Error,
185    },
186
187    /// An [`ApiError`] surfaced from internal helpers (`db::open_db`,
188    /// `seed::load_seed`). Wrapped instead of flattened so future
189    /// pattern-matching can recover the original variant.
190    #[error("api: {0}")]
191    Api(#[from] ApiError),
192}
193
194/// Convenience alias used inside `lib.rs`.
195pub type Result<T> = std::result::Result<T, SimError>;
196
197#[cfg(test)]
198mod sim_error_tests {
199    use super::{ApiError, SimError};
200
201    #[test]
202    fn from_io_error_preserves_kind() {
203        let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
204        let sim: SimError = io.into();
205        assert!(
206            matches!(sim, SimError::Io(ref e) if e.kind() == std::io::ErrorKind::PermissionDenied)
207        );
208    }
209
210    #[test]
211    fn from_api_error_routes_to_api_variant() {
212        let sim: SimError = ApiError::NotFound.into();
213        assert!(matches!(sim, SimError::Api(ApiError::NotFound)));
214    }
215
216    #[test]
217    fn bind_variant_renders_address() {
218        let sim = SimError::Bind {
219            addr: "127.0.0.1:7878".into(),
220            source: std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use"),
221        };
222        let rendered = sim.to_string();
223        assert!(rendered.contains("127.0.0.1:7878"), "got: {rendered}");
224    }
225
226    #[test]
227    fn anyhow_can_absorb_sim_error_via_std_error() {
228        // The binary boundary depends on this conversion working without an
229        // explicit `From<SimError> for anyhow::Error` impl.
230        fn returns_sim_err() -> Result<(), SimError> {
231            Err(SimError::Io(std::io::Error::other("boom")))
232        }
233        fn returns_anyhow() -> anyhow::Result<()> {
234            returns_sim_err()?;
235            Ok(())
236        }
237        assert!(returns_anyhow().is_err());
238    }
239}