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}