cloudillo-types 0.8.16

Shared types, adapter traits, and error types for the Cloudillo federated collaboration platform
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// SPDX-FileCopyrightText: Szilárd Hajba
// SPDX-License-Identifier: LGPL-3.0-or-later

//! Error handling subsystem. Implements a custom Error type.

use axum::{Json, http::StatusCode, response::IntoResponse};
use tracing::warn;

use crate::types::ErrorResponse;

pub type ClResult<T> = std::result::Result<T, Error>;

#[derive(Debug)]
pub enum Error {
	// Core errors
	NotFound,
	/// 410 - the resource existed but is permanently gone (used for the IDP
	/// activation-resend endpoint when `Identity.expires_at` has passed —
	/// resending after expiry is impossible because the deadline is fixed at
	/// registration and not extended).
	Gone,
	PermissionDenied,
	Unauthorized, // 401 - missing/invalid auth token
	DbError,
	Parse,

	// Input validation and constraints
	ValidationError(String),      // 400 - invalid input data
	Conflict(String),             // 409 - constraint violation (unique, foreign key, etc)
	PreconditionRequired(String), // 428 - precondition required (e.g., PoW)

	/// 404 - settings registry has no entry for the requested key, or the
	/// registered key has no default value and no override is configured.
	/// Used by `SettingsService::*_opt` to distinguish "not configured" from
	/// "configured with the wrong type".
	SettingNotFound(String),

	// Network and external services
	NetworkError(String), // Network/federation failures
	Timeout,              // Operation timeout

	// System and configuration
	ConfigError(String),        // Missing or invalid configuration
	ServiceUnavailable(String), // 503 - temporary system failures
	Internal(String),           // Internal invariant violations, for debugging

	// Processing
	ImageError(String),  // Image processing failures
	CryptoError(String), // Cryptography/TLS configuration errors

	// File cross-context (Hand) errors
	FileSourceNotFound,    // 404: sourceFileId doesn't exist in sourceIdTag's context
	FileSourceForbidden,   // 403: Caller has no READ on the source
	FileSourceUnreachable, // 503: Source server unreachable during synchronous resolve
	FileCycleRejected,     // 400: sourceFileId is itself a cross-context row

	// externals
	Io(std::io::Error),
}

impl From<std::io::Error> for Error {
	fn from(err: std::io::Error) -> Self {
		warn!("io error: {}", err);
		Self::Io(err)
	}
}

impl Error {
	/// Whether a failed operation is worth retrying. A short denylist of
	/// *permanent* failures returns `false`; everything else returns `true`.
	///
	/// Scoped to what the task scheduler's federation tasks actually return.
	/// Permanent here means "a later attempt cannot succeed": the sender isn't
	/// following us, the JWT signature is invalid, the action is malformed, or
	/// the remote resource is gone (410). Transient failures — network/TLS
	/// errors, timeouts, a locked DB, a peer briefly returning 404 — are NOT
	/// listed and so remain retryable. Mis-classifying a rare error toward
	/// "retryable" is cheap (a few wasted attempts, then the retry limit stops
	/// it); mis-classifying toward "permanent" would silently drop recoverable
	/// federated actions, so we err toward retryable.
	/// Known cost of this choice: `Error::NotFound` is retryable so a peer
	/// briefly 404-ing can recover, but a task whose *local* target is genuinely
	/// gone (e.g. a deleted action) also returns `NotFound` and will retry to the
	/// policy limit before `on_failed` fires. `Error` carries no local-vs-remote
	/// distinction, so we accept those wasted retries rather than break federation
	/// recovery by denylisting `NotFound`.
	pub fn is_retryable(&self) -> bool {
		!matches!(
			self,
			Error::PermissionDenied
				| Error::Unauthorized
				| Error::ValidationError(_)
				| Error::Parse
				| Error::Gone
		)
	}
}

impl std::fmt::Display for Error {
	fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
		write!(f, "{:?}", self)
	}
}

impl std::error::Error for Error {}

impl IntoResponse for Error {
	fn into_response(self) -> axum::response::Response {
		let (status, code, message) = match self {
			Error::NotFound => (
				StatusCode::NOT_FOUND,
				"E-CORE-NOTFOUND".to_string(),
				"Resource not found".to_string(),
			),
			Error::Gone => {
				(StatusCode::GONE, "E-CORE-GONE".to_string(), "Resource is gone".to_string())
			}
			Error::PermissionDenied => (
				StatusCode::FORBIDDEN,
				"E-AUTH-NOPERM".to_string(),
				"You do not have permission to access this resource".to_string(),
			),
			Error::Unauthorized => (
				StatusCode::UNAUTHORIZED,
				"E-AUTH-UNAUTH".to_string(),
				"Authentication required or invalid token".to_string(),
			),
			Error::ValidationError(msg) => (
				StatusCode::BAD_REQUEST,
				"E-VAL-INVALID".to_string(),
				format!("Request validation failed: {}", msg),
			),
			Error::Conflict(msg) => (
				StatusCode::CONFLICT,
				"E-CORE-CONFLICT".to_string(),
				format!("Resource conflict: {}", msg),
			),
			Error::PreconditionRequired(msg) => (
				StatusCode::PRECONDITION_REQUIRED,
				"E-POW-REQUIRED".to_string(),
				format!("Precondition required: {}", msg),
			),
			Error::SettingNotFound(msg) => (
				StatusCode::NOT_FOUND,
				"E-SET-NOTFOUND".to_string(),
				format!("Setting not configured: {}", msg),
			),
			Error::Timeout => (
				StatusCode::REQUEST_TIMEOUT,
				"E-NET-TIMEOUT".to_string(),
				"Request timeout".to_string(),
			),
			Error::ServiceUnavailable(msg) => (
				StatusCode::SERVICE_UNAVAILABLE,
				"E-SYS-UNAVAIL".to_string(),
				format!("Service temporarily unavailable: {}", msg),
			),
			// Server errors (5xx) - no message exposure for security
			Error::DbError => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-CORE-DBERR".to_string(),
				"Internal server error".to_string(),
			),
			Error::Internal(msg) => {
				warn!("internal error: {}", msg);
				(
					StatusCode::INTERNAL_SERVER_ERROR,
					"E-CORE-INTERNAL".to_string(),
					"Internal server error".to_string(),
				)
			}
			Error::Parse => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-CORE-PARSE".to_string(),
				"Internal server error".to_string(),
			),
			Error::Io(_) => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-SYS-IO".to_string(),
				"Internal server error".to_string(),
			),
			Error::NetworkError(_) => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-NET-ERROR".to_string(),
				"Internal server error".to_string(),
			),
			Error::ImageError(_) => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-IMG-PROCFAIL".to_string(),
				"Internal server error".to_string(),
			),
			Error::CryptoError(_) => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-CRYPT-FAIL".to_string(),
				"Internal server error".to_string(),
			),
			Error::ConfigError(_) => (
				StatusCode::INTERNAL_SERVER_ERROR,
				"E-CONF-CFGERR".to_string(),
				"Internal server error".to_string(),
			),
			Error::FileSourceNotFound => (
				StatusCode::NOT_FOUND,
				"E-FILE-SRCNOTFOUND".to_string(),
				"source_not_found".to_string(),
			),
			Error::FileSourceForbidden => (
				StatusCode::FORBIDDEN,
				"E-FILE-SRCFORBID".to_string(),
				"source_forbidden".to_string(),
			),
			Error::FileSourceUnreachable => (
				StatusCode::SERVICE_UNAVAILABLE,
				"E-FILE-SRCUNREACH".to_string(),
				"source_unreachable".to_string(),
			),
			Error::FileCycleRejected => (
				StatusCode::BAD_REQUEST,
				"E-FILE-CYCLEREJ".to_string(),
				"cycle_rejected".to_string(),
			),
		};

		let error_response = ErrorResponse::new(code, message);
		let mut response = (status, Json(error_response)).into_response();
		response.headers_mut().insert(
			axum::http::header::CACHE_CONTROL,
			axum::http::HeaderValue::from_static("no-store"),
		);
		response
			.headers_mut()
			.insert(axum::http::header::PRAGMA, axum::http::HeaderValue::from_static("no-cache"));
		response
	}
}

impl From<std::num::ParseIntError> for Error {
	fn from(err: std::num::ParseIntError) -> Self {
		warn!("parse int error: {}", err);
		Error::Parse
	}
}

impl From<std::time::SystemTimeError> for Error {
	fn from(err: std::time::SystemTimeError) -> Self {
		warn!("system time error: {}", err);
		Error::ServiceUnavailable("system time error".into())
	}
}

impl From<axum::Error> for Error {
	fn from(err: axum::Error) -> Self {
		warn!("axum error: {}", err);
		Error::NetworkError("axum error".into())
	}
}

impl From<axum::http::Error> for Error {
	fn from(err: axum::http::Error) -> Self {
		warn!("http error: {}", err);
		Error::NetworkError("http error".into())
	}
}

impl From<axum::http::header::ToStrError> for Error {
	fn from(err: axum::http::header::ToStrError) -> Self {
		warn!("header to str error: {}", err);
		Error::Parse
	}
}

impl From<serde_json::Error> for Error {
	fn from(err: serde_json::Error) -> Self {
		warn!("json error: {}", err);
		Error::Parse
	}
}

impl From<tokio::task::JoinError> for Error {
	fn from(err: tokio::task::JoinError) -> Self {
		warn!("tokio join error: {}", err);
		Error::ServiceUnavailable("task execution failed".into())
	}
}

// Server-specific From impls (behind "server" feature)

#[cfg(feature = "server")]
impl From<instant_acme::Error> for Error {
	fn from(err: instant_acme::Error) -> Self {
		warn!("acme error: {}", err);
		Error::ConfigError("ACME certificate error".into())
	}
}

#[cfg(feature = "server")]
impl From<pem::PemError> for Error {
	fn from(err: pem::PemError) -> Self {
		warn!("pem error: {}", err);
		Error::CryptoError("PEM parsing error".into())
	}
}

#[cfg(feature = "server")]
impl From<jsonwebtoken::errors::Error> for Error {
	fn from(err: jsonwebtoken::errors::Error) -> Self {
		warn!("jwt error: {}", err);
		Error::Unauthorized
	}
}

#[cfg(feature = "server")]
impl From<x509_parser::asn1_rs::Err<x509_parser::error::X509Error>> for Error {
	fn from(err: x509_parser::asn1_rs::Err<x509_parser::error::X509Error>) -> Self {
		warn!("x509 error: {}", err);
		Error::CryptoError("X.509 certificate error".into())
	}
}

#[cfg(feature = "server")]
impl From<rustls::Error> for Error {
	fn from(err: rustls::Error) -> Self {
		warn!("rustls error: {}", err);
		Error::CryptoError("TLS error".into())
	}
}

#[cfg(feature = "server")]
impl From<rustls_pki_types::pem::Error> for Error {
	fn from(err: rustls_pki_types::pem::Error) -> Self {
		warn!("pem error: {}", err);
		Error::CryptoError("PEM parsing error".into())
	}
}

#[cfg(feature = "server")]
impl From<hyper::Error> for Error {
	fn from(err: hyper::Error) -> Self {
		warn!("hyper error: {}", err);
		Error::NetworkError("HTTP client error".into())
	}
}

#[cfg(feature = "server")]
impl From<hyper_util::client::legacy::Error> for Error {
	fn from(err: hyper_util::client::legacy::Error) -> Self {
		warn!("hyper error: {}", err);
		Error::NetworkError("HTTP client error".into())
	}
}

#[cfg(feature = "server")]
impl From<image::error::ImageError> for Error {
	fn from(err: image::error::ImageError) -> Self {
		warn!("image error: {:?}", err);
		Error::ImageError("Image processing failed".into())
	}
}

/// Helper macro for locking mutexes with automatic internal error handling.
///
/// This macro simplifies the common pattern of locking a mutex and converting
/// poisoning errors to `Error::Internal`. It automatically adds context about
/// which mutex was poisoned.
///
/// # Examples
///
/// ```ignore
/// // Without macro:
/// let mut data = my_mutex.lock().map_err(|_| Error::Internal("mutex poisoned".into()))?;
///
/// // With macro:
/// let mut data = lock!(my_mutex)?;
/// ```
///
/// The macro also supports adding context information:
///
/// ```ignore
/// // With context:
/// let mut data = lock!(my_mutex, "task_queue")?;
/// // Produces: Error::Internal("mutex poisoned: task_queue")
/// ```
#[macro_export]
macro_rules! lock {
	// Simple version without context
	($mutex:expr) => {
		$mutex
			.lock()
			.map_err(|_| $crate::error::Error::Internal("mutex poisoned".into()))
	};
	// Version with context description
	($mutex:expr, $context:expr) => {
		$mutex
			.lock()
			.map_err(|_| $crate::error::Error::Internal(format!("mutex poisoned: {}", $context)))
	};
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn permanent_errors_are_not_retryable() {
		assert!(!Error::PermissionDenied.is_retryable());
		assert!(!Error::Unauthorized.is_retryable());
		assert!(!Error::ValidationError("x".into()).is_retryable());
		assert!(!Error::Parse.is_retryable());
		assert!(!Error::Gone.is_retryable());
	}

	#[test]
	fn transient_errors_are_retryable() {
		assert!(Error::Timeout.is_retryable());
		assert!(Error::NetworkError("x".into()).is_retryable());
		assert!(Error::DbError.is_retryable());
		assert!(Error::NotFound.is_retryable()); // 404 = peer briefly unreachable
		// TLS handshake failures (e.g. dyndns IP reallocation) must retry
		assert!(Error::CryptoError("TLS error".into()).is_retryable());
	}
}

// vim: ts=4