Skip to main content

cloudillo_types/
error.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Error handling subsystem. Implements a custom Error type.
5
6use axum::{Json, http::StatusCode, response::IntoResponse};
7use tracing::warn;
8
9use crate::types::ErrorResponse;
10
11pub type ClResult<T> = std::result::Result<T, Error>;
12
13#[derive(Debug)]
14pub enum Error {
15	// Core errors
16	NotFound,
17	/// 410 - the resource existed but is permanently gone (used for the IDP
18	/// activation-resend endpoint when `Identity.expires_at` has passed —
19	/// resending after expiry is impossible because the deadline is fixed at
20	/// registration and not extended).
21	Gone,
22	PermissionDenied,
23	Unauthorized, // 401 - missing/invalid auth token
24	DbError,
25	Parse,
26
27	// Input validation and constraints
28	ValidationError(String),      // 400 - invalid input data
29	Conflict(String),             // 409 - constraint violation (unique, foreign key, etc)
30	PreconditionRequired(String), // 428 - precondition required (e.g., PoW)
31
32	/// 404 - settings registry has no entry for the requested key, or the
33	/// registered key has no default value and no override is configured.
34	/// Used by `SettingsService::*_opt` to distinguish "not configured" from
35	/// "configured with the wrong type".
36	SettingNotFound(String),
37
38	// Network and external services
39	NetworkError(String), // Network/federation failures
40	Timeout,              // Operation timeout
41
42	// System and configuration
43	ConfigError(String),        // Missing or invalid configuration
44	ServiceUnavailable(String), // 503 - temporary system failures
45	Internal(String),           // Internal invariant violations, for debugging
46
47	// Processing
48	ImageError(String),  // Image processing failures
49	CryptoError(String), // Cryptography/TLS configuration errors
50
51	// File cross-context (Hand) errors
52	FileSourceNotFound,    // 404: sourceFileId doesn't exist in sourceIdTag's context
53	FileSourceForbidden,   // 403: Caller has no READ on the source
54	FileSourceUnreachable, // 503: Source server unreachable during synchronous resolve
55	FileCycleRejected,     // 400: sourceFileId is itself a cross-context row
56
57	// externals
58	Io(std::io::Error),
59}
60
61impl From<std::io::Error> for Error {
62	fn from(err: std::io::Error) -> Self {
63		warn!("io error: {}", err);
64		Self::Io(err)
65	}
66}
67
68impl Error {
69	/// Whether a failed operation is worth retrying. A short denylist of
70	/// *permanent* failures returns `false`; everything else returns `true`.
71	///
72	/// Scoped to what the task scheduler's federation tasks actually return.
73	/// Permanent here means "a later attempt cannot succeed": the sender isn't
74	/// following us, the JWT signature is invalid, the action is malformed, or
75	/// the remote resource is gone (410). Transient failures — network/TLS
76	/// errors, timeouts, a locked DB, a peer briefly returning 404 — are NOT
77	/// listed and so remain retryable. Mis-classifying a rare error toward
78	/// "retryable" is cheap (a few wasted attempts, then the retry limit stops
79	/// it); mis-classifying toward "permanent" would silently drop recoverable
80	/// federated actions, so we err toward retryable.
81	/// Known cost of this choice: `Error::NotFound` is retryable so a peer
82	/// briefly 404-ing can recover, but a task whose *local* target is genuinely
83	/// gone (e.g. a deleted action) also returns `NotFound` and will retry to the
84	/// policy limit before `on_failed` fires. `Error` carries no local-vs-remote
85	/// distinction, so we accept those wasted retries rather than break federation
86	/// recovery by denylisting `NotFound`.
87	pub fn is_retryable(&self) -> bool {
88		!matches!(
89			self,
90			Error::PermissionDenied
91				| Error::Unauthorized
92				| Error::ValidationError(_)
93				| Error::Parse
94				| Error::Gone
95		)
96	}
97}
98
99impl std::fmt::Display for Error {
100	fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
101		write!(f, "{:?}", self)
102	}
103}
104
105impl std::error::Error for Error {}
106
107impl IntoResponse for Error {
108	fn into_response(self) -> axum::response::Response {
109		let (status, code, message) = match self {
110			Error::NotFound => (
111				StatusCode::NOT_FOUND,
112				"E-CORE-NOTFOUND".to_string(),
113				"Resource not found".to_string(),
114			),
115			Error::Gone => {
116				(StatusCode::GONE, "E-CORE-GONE".to_string(), "Resource is gone".to_string())
117			}
118			Error::PermissionDenied => (
119				StatusCode::FORBIDDEN,
120				"E-AUTH-NOPERM".to_string(),
121				"You do not have permission to access this resource".to_string(),
122			),
123			Error::Unauthorized => (
124				StatusCode::UNAUTHORIZED,
125				"E-AUTH-UNAUTH".to_string(),
126				"Authentication required or invalid token".to_string(),
127			),
128			Error::ValidationError(msg) => (
129				StatusCode::BAD_REQUEST,
130				"E-VAL-INVALID".to_string(),
131				format!("Request validation failed: {}", msg),
132			),
133			Error::Conflict(msg) => (
134				StatusCode::CONFLICT,
135				"E-CORE-CONFLICT".to_string(),
136				format!("Resource conflict: {}", msg),
137			),
138			Error::PreconditionRequired(msg) => (
139				StatusCode::PRECONDITION_REQUIRED,
140				"E-POW-REQUIRED".to_string(),
141				format!("Precondition required: {}", msg),
142			),
143			Error::SettingNotFound(msg) => (
144				StatusCode::NOT_FOUND,
145				"E-SET-NOTFOUND".to_string(),
146				format!("Setting not configured: {}", msg),
147			),
148			Error::Timeout => (
149				StatusCode::REQUEST_TIMEOUT,
150				"E-NET-TIMEOUT".to_string(),
151				"Request timeout".to_string(),
152			),
153			Error::ServiceUnavailable(msg) => (
154				StatusCode::SERVICE_UNAVAILABLE,
155				"E-SYS-UNAVAIL".to_string(),
156				format!("Service temporarily unavailable: {}", msg),
157			),
158			// Server errors (5xx) - no message exposure for security
159			Error::DbError => (
160				StatusCode::INTERNAL_SERVER_ERROR,
161				"E-CORE-DBERR".to_string(),
162				"Internal server error".to_string(),
163			),
164			Error::Internal(msg) => {
165				warn!("internal error: {}", msg);
166				(
167					StatusCode::INTERNAL_SERVER_ERROR,
168					"E-CORE-INTERNAL".to_string(),
169					"Internal server error".to_string(),
170				)
171			}
172			Error::Parse => (
173				StatusCode::INTERNAL_SERVER_ERROR,
174				"E-CORE-PARSE".to_string(),
175				"Internal server error".to_string(),
176			),
177			Error::Io(_) => (
178				StatusCode::INTERNAL_SERVER_ERROR,
179				"E-SYS-IO".to_string(),
180				"Internal server error".to_string(),
181			),
182			Error::NetworkError(_) => (
183				StatusCode::INTERNAL_SERVER_ERROR,
184				"E-NET-ERROR".to_string(),
185				"Internal server error".to_string(),
186			),
187			Error::ImageError(_) => (
188				StatusCode::INTERNAL_SERVER_ERROR,
189				"E-IMG-PROCFAIL".to_string(),
190				"Internal server error".to_string(),
191			),
192			Error::CryptoError(_) => (
193				StatusCode::INTERNAL_SERVER_ERROR,
194				"E-CRYPT-FAIL".to_string(),
195				"Internal server error".to_string(),
196			),
197			Error::ConfigError(_) => (
198				StatusCode::INTERNAL_SERVER_ERROR,
199				"E-CONF-CFGERR".to_string(),
200				"Internal server error".to_string(),
201			),
202			Error::FileSourceNotFound => (
203				StatusCode::NOT_FOUND,
204				"E-FILE-SRCNOTFOUND".to_string(),
205				"source_not_found".to_string(),
206			),
207			Error::FileSourceForbidden => (
208				StatusCode::FORBIDDEN,
209				"E-FILE-SRCFORBID".to_string(),
210				"source_forbidden".to_string(),
211			),
212			Error::FileSourceUnreachable => (
213				StatusCode::SERVICE_UNAVAILABLE,
214				"E-FILE-SRCUNREACH".to_string(),
215				"source_unreachable".to_string(),
216			),
217			Error::FileCycleRejected => (
218				StatusCode::BAD_REQUEST,
219				"E-FILE-CYCLEREJ".to_string(),
220				"cycle_rejected".to_string(),
221			),
222		};
223
224		let error_response = ErrorResponse::new(code, message);
225		let mut response = (status, Json(error_response)).into_response();
226		response.headers_mut().insert(
227			axum::http::header::CACHE_CONTROL,
228			axum::http::HeaderValue::from_static("no-store"),
229		);
230		response
231			.headers_mut()
232			.insert(axum::http::header::PRAGMA, axum::http::HeaderValue::from_static("no-cache"));
233		response
234	}
235}
236
237impl From<std::num::ParseIntError> for Error {
238	fn from(err: std::num::ParseIntError) -> Self {
239		warn!("parse int error: {}", err);
240		Error::Parse
241	}
242}
243
244impl From<std::time::SystemTimeError> for Error {
245	fn from(err: std::time::SystemTimeError) -> Self {
246		warn!("system time error: {}", err);
247		Error::ServiceUnavailable("system time error".into())
248	}
249}
250
251impl From<axum::Error> for Error {
252	fn from(err: axum::Error) -> Self {
253		warn!("axum error: {}", err);
254		Error::NetworkError("axum error".into())
255	}
256}
257
258impl From<axum::http::Error> for Error {
259	fn from(err: axum::http::Error) -> Self {
260		warn!("http error: {}", err);
261		Error::NetworkError("http error".into())
262	}
263}
264
265impl From<axum::http::header::ToStrError> for Error {
266	fn from(err: axum::http::header::ToStrError) -> Self {
267		warn!("header to str error: {}", err);
268		Error::Parse
269	}
270}
271
272impl From<serde_json::Error> for Error {
273	fn from(err: serde_json::Error) -> Self {
274		warn!("json error: {}", err);
275		Error::Parse
276	}
277}
278
279impl From<tokio::task::JoinError> for Error {
280	fn from(err: tokio::task::JoinError) -> Self {
281		warn!("tokio join error: {}", err);
282		Error::ServiceUnavailable("task execution failed".into())
283	}
284}
285
286// Server-specific From impls (behind "server" feature)
287
288#[cfg(feature = "server")]
289impl From<instant_acme::Error> for Error {
290	fn from(err: instant_acme::Error) -> Self {
291		warn!("acme error: {}", err);
292		Error::ConfigError("ACME certificate error".into())
293	}
294}
295
296#[cfg(feature = "server")]
297impl From<pem::PemError> for Error {
298	fn from(err: pem::PemError) -> Self {
299		warn!("pem error: {}", err);
300		Error::CryptoError("PEM parsing error".into())
301	}
302}
303
304#[cfg(feature = "server")]
305impl From<jsonwebtoken::errors::Error> for Error {
306	fn from(err: jsonwebtoken::errors::Error) -> Self {
307		warn!("jwt error: {}", err);
308		Error::Unauthorized
309	}
310}
311
312#[cfg(feature = "server")]
313impl From<x509_parser::asn1_rs::Err<x509_parser::error::X509Error>> for Error {
314	fn from(err: x509_parser::asn1_rs::Err<x509_parser::error::X509Error>) -> Self {
315		warn!("x509 error: {}", err);
316		Error::CryptoError("X.509 certificate error".into())
317	}
318}
319
320#[cfg(feature = "server")]
321impl From<rustls::Error> for Error {
322	fn from(err: rustls::Error) -> Self {
323		warn!("rustls error: {}", err);
324		Error::CryptoError("TLS error".into())
325	}
326}
327
328#[cfg(feature = "server")]
329impl From<rustls_pki_types::pem::Error> for Error {
330	fn from(err: rustls_pki_types::pem::Error) -> Self {
331		warn!("pem error: {}", err);
332		Error::CryptoError("PEM parsing error".into())
333	}
334}
335
336#[cfg(feature = "server")]
337impl From<hyper::Error> for Error {
338	fn from(err: hyper::Error) -> Self {
339		warn!("hyper error: {}", err);
340		Error::NetworkError("HTTP client error".into())
341	}
342}
343
344#[cfg(feature = "server")]
345impl From<hyper_util::client::legacy::Error> for Error {
346	fn from(err: hyper_util::client::legacy::Error) -> Self {
347		warn!("hyper error: {}", err);
348		Error::NetworkError("HTTP client error".into())
349	}
350}
351
352#[cfg(feature = "server")]
353impl From<image::error::ImageError> for Error {
354	fn from(err: image::error::ImageError) -> Self {
355		warn!("image error: {:?}", err);
356		Error::ImageError("Image processing failed".into())
357	}
358}
359
360/// Helper macro for locking mutexes with automatic internal error handling.
361///
362/// This macro simplifies the common pattern of locking a mutex and converting
363/// poisoning errors to `Error::Internal`. It automatically adds context about
364/// which mutex was poisoned.
365///
366/// # Examples
367///
368/// ```ignore
369/// // Without macro:
370/// let mut data = my_mutex.lock().map_err(|_| Error::Internal("mutex poisoned".into()))?;
371///
372/// // With macro:
373/// let mut data = lock!(my_mutex)?;
374/// ```
375///
376/// The macro also supports adding context information:
377///
378/// ```ignore
379/// // With context:
380/// let mut data = lock!(my_mutex, "task_queue")?;
381/// // Produces: Error::Internal("mutex poisoned: task_queue")
382/// ```
383#[macro_export]
384macro_rules! lock {
385	// Simple version without context
386	($mutex:expr) => {
387		$mutex
388			.lock()
389			.map_err(|_| $crate::error::Error::Internal("mutex poisoned".into()))
390	};
391	// Version with context description
392	($mutex:expr, $context:expr) => {
393		$mutex
394			.lock()
395			.map_err(|_| $crate::error::Error::Internal(format!("mutex poisoned: {}", $context)))
396	};
397}
398
399#[cfg(test)]
400mod tests {
401	use super::*;
402
403	#[test]
404	fn permanent_errors_are_not_retryable() {
405		assert!(!Error::PermissionDenied.is_retryable());
406		assert!(!Error::Unauthorized.is_retryable());
407		assert!(!Error::ValidationError("x".into()).is_retryable());
408		assert!(!Error::Parse.is_retryable());
409		assert!(!Error::Gone.is_retryable());
410	}
411
412	#[test]
413	fn transient_errors_are_retryable() {
414		assert!(Error::Timeout.is_retryable());
415		assert!(Error::NetworkError("x".into()).is_retryable());
416		assert!(Error::DbError.is_retryable());
417		assert!(Error::NotFound.is_retryable()); // 404 = peer briefly unreachable
418		// TLS handshake failures (e.g. dyndns IP reallocation) must retry
419		assert!(Error::CryptoError("TLS error".into()).is_retryable());
420	}
421}
422
423// vim: ts=4