1use serde::{Serialize, Serializer};
16use serde_json::{json, Value};
17use uuid::Uuid;
18
19#[derive(Debug, Clone, Serialize)]
25pub struct AuthDetails {
26 pub reason: String,
28}
29
30#[derive(Debug, Clone, Serialize)]
32pub struct AccessDeniedDetails {
33 pub op: String,
34 pub resource: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct NotFoundDetails {
40 pub resource: String,
41}
42
43#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ConflictDetails {
49 pub etag_expected: String,
50 pub etag_actual: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct RateLimitedDetails {
57 pub retry_after_ms: Option<u64>,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct UnsupportedDetails {
63 pub op: String,
64 pub provider: String,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct NetworkDetails {
70 pub source: String,
72}
73
74#[derive(Debug, Clone, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct LockedDetails {
78 pub lock_id: String,
79 pub op_name: String,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct ValidationDetails {
85 pub field: String,
86 pub hint: String,
87}
88
89#[derive(Debug, Clone, Serialize)]
91pub struct ProviderSpecificDetails {
92 pub code: String,
93 pub message: String,
94}
95
96#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct InternalDetails {
100 pub trace_id: String,
102}
103
104#[derive(Debug, Clone)]
112pub enum AppError {
113 Auth { reason: String },
115 AccessDenied { op: String, resource: String },
117 NotFound { resource: String },
119 Conflict {
121 etag_expected: String,
122 etag_actual: Option<String>,
123 },
124 RateLimited { retry_after_ms: Option<u64> },
127 Unsupported { op: String, provider: String },
129 Network { source: String },
131 Cancelled,
133 Locked { lock_id: String, op_name: String },
135 Validation { field: String, hint: String },
137 ProviderSpecific { code: String, message: String },
139 Internal { trace_id: String },
141}
142
143impl AppError {
144 pub fn internal_new() -> Self {
146 Self::Internal {
147 trace_id: Uuid::new_v4().to_string(),
148 }
149 }
150
151 pub fn kind(&self) -> &'static str {
153 match self {
154 Self::Auth { .. } => "Auth",
155 Self::AccessDenied { .. } => "AccessDenied",
156 Self::NotFound { .. } => "NotFound",
157 Self::Conflict { .. } => "Conflict",
158 Self::RateLimited { .. } => "RateLimited",
159 Self::Unsupported { .. } => "Unsupported",
160 Self::Network { .. } => "Network",
161 Self::Cancelled => "Cancelled",
162 Self::Locked { .. } => "Locked",
163 Self::Validation { .. } => "Validation",
164 Self::ProviderSpecific { .. } => "ProviderSpecific",
165 Self::Internal { .. } => "Internal",
166 }
167 }
168
169 pub fn retryable(&self) -> bool {
171 match self {
172 Self::RateLimited { .. } | Self::Network { .. } => true,
174 Self::Auth { .. }
176 | Self::AccessDenied { .. }
177 | Self::NotFound { .. }
178 | Self::Conflict { .. }
179 | Self::Unsupported { .. }
180 | Self::Cancelled
181 | Self::Locked { .. }
182 | Self::Validation { .. }
183 | Self::ProviderSpecific { .. }
184 | Self::Internal { .. } => false,
185 }
186 }
187
188 pub fn message(&self) -> String {
190 match self {
191 Self::Auth { reason } => format!("Authentication failed: {reason}"),
192 Self::AccessDenied { op, resource } => {
193 format!("Access denied: cannot {op} on {resource}")
194 }
195 Self::NotFound { resource } => format!("Not found: {resource}"),
196 Self::Conflict {
197 etag_expected,
198 etag_actual,
199 } => match etag_actual {
200 Some(actual) => {
201 format!("Conflict: expected ETag {etag_expected} but found {actual}")
202 }
203 None => format!("Conflict: expected ETag {etag_expected}"),
204 },
205 Self::RateLimited { retry_after_ms } => match retry_after_ms {
206 Some(ms) => format!("Rate limited; retry after {ms} ms"),
207 None => "Rate limited; please retry later".to_string(),
208 },
209 Self::Unsupported { op, provider } => {
210 format!("Unsupported: {op} is not available on {provider}")
211 }
212 Self::Network { source } => format!("Network error: {source}"),
213 Self::Cancelled => "Operation cancelled".to_string(),
214 Self::Locked { lock_id, op_name } => {
215 format!("Resource is locked (lock {lock_id}) by operation {op_name}")
216 }
217 Self::Validation { field, hint } => {
218 format!("Validation error on field '{field}': {hint}")
219 }
220 Self::ProviderSpecific { code, message } => {
221 format!("Provider error [{code}]: {message}")
222 }
223 Self::Internal { trace_id } => {
224 format!("Internal error (trace: {trace_id})")
225 }
226 }
227 }
228
229 fn details(&self) -> Option<Value> {
231 match self {
233 Self::Auth { reason } => Some(
234 serde_json::to_value(AuthDetails {
235 reason: reason.clone(),
236 })
237 .unwrap(),
238 ),
239 Self::AccessDenied { op, resource } => Some(
240 serde_json::to_value(AccessDeniedDetails {
241 op: op.clone(),
242 resource: resource.clone(),
243 })
244 .unwrap(),
245 ),
246 Self::NotFound { resource } => Some(
247 serde_json::to_value(NotFoundDetails {
248 resource: resource.clone(),
249 })
250 .unwrap(),
251 ),
252 Self::Conflict {
253 etag_expected,
254 etag_actual,
255 } => Some(
256 serde_json::to_value(ConflictDetails {
257 etag_expected: etag_expected.clone(),
258 etag_actual: etag_actual.clone(),
259 })
260 .unwrap(),
261 ),
262 Self::RateLimited { retry_after_ms } => Some(
263 serde_json::to_value(RateLimitedDetails {
264 retry_after_ms: *retry_after_ms,
265 })
266 .unwrap(),
267 ),
268 Self::Unsupported { op, provider } => Some(
269 serde_json::to_value(UnsupportedDetails {
270 op: op.clone(),
271 provider: provider.clone(),
272 })
273 .unwrap(),
274 ),
275 Self::Network { source } => Some(
276 serde_json::to_value(NetworkDetails {
277 source: source.clone(),
278 })
279 .unwrap(),
280 ),
281 Self::Cancelled => None,
283 Self::Locked { lock_id, op_name } => Some(
284 serde_json::to_value(LockedDetails {
285 lock_id: lock_id.clone(),
286 op_name: op_name.clone(),
287 })
288 .unwrap(),
289 ),
290 Self::Validation { field, hint } => Some(
291 serde_json::to_value(ValidationDetails {
292 field: field.clone(),
293 hint: hint.clone(),
294 })
295 .unwrap(),
296 ),
297 Self::ProviderSpecific { code, message } => Some(
298 serde_json::to_value(ProviderSpecificDetails {
299 code: code.clone(),
300 message: message.clone(),
301 })
302 .unwrap(),
303 ),
304 Self::Internal { trace_id } => Some(
305 serde_json::to_value(InternalDetails {
306 trace_id: trace_id.clone(),
307 })
308 .unwrap(),
309 ),
310 }
311 }
312}
313
314impl Serialize for AppError {
319 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
320 where
321 S: Serializer,
322 {
323 let mut obj = json!({
324 "kind": self.kind(),
325 "message": self.message(),
326 "retryable": self.retryable(),
327 });
328 if let Some(details) = self.details() {
329 obj["details"] = details;
330 }
331 obj.serialize(serializer)
332 }
333}
334
335impl std::fmt::Display for AppError {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.write_str(&self.message())
342 }
343}
344
345impl std::error::Error for AppError {}
346
347#[cfg(test)]
352mod tests {
353 use super::*;
354 use serde_json::Value;
355
356 fn ser(e: &AppError) -> Value {
357 serde_json::to_value(e).expect("AppError must serialize")
358 }
359
360 fn assert_envelope(v: &Value, expected_kind: &str, expected_retryable: bool) {
361 assert_eq!(
362 v["kind"], expected_kind,
363 "kind mismatch for {expected_kind}"
364 );
365 assert!(
366 v["message"]
367 .as_str()
368 .map(|s| !s.is_empty())
369 .unwrap_or(false),
370 "message must be non-empty for {expected_kind}"
371 );
372 assert_eq!(
373 v["retryable"],
374 Value::Bool(expected_retryable),
375 "retryable mismatch for {expected_kind}"
376 );
377 }
378
379 #[test]
380 fn auth_serializes() {
381 let e = AppError::Auth {
382 reason: "expired".to_string(),
383 };
384 let v = ser(&e);
385 assert_envelope(&v, "Auth", false);
386 assert_eq!(v["details"]["reason"], "expired");
387 }
388
389 #[test]
390 fn access_denied_serializes() {
391 let e = AppError::AccessDenied {
392 op: "PutObject".to_string(),
393 resource: "my-bucket/file.txt".to_string(),
394 };
395 let v = ser(&e);
396 assert_envelope(&v, "AccessDenied", false);
397 assert_eq!(v["details"]["op"], "PutObject");
398 assert_eq!(v["details"]["resource"], "my-bucket/file.txt");
399 }
400
401 #[test]
402 fn not_found_serializes() {
403 let e = AppError::NotFound {
404 resource: "s3://bucket/key".to_string(),
405 };
406 let v = ser(&e);
407 assert_envelope(&v, "NotFound", false);
408 assert_eq!(v["details"]["resource"], "s3://bucket/key");
409 }
410
411 #[test]
412 fn conflict_with_actual_serializes() {
413 let e = AppError::Conflict {
414 etag_expected: "\"abc123\"".to_string(),
415 etag_actual: Some("\"def456\"".to_string()),
416 };
417 let v = ser(&e);
418 assert_envelope(&v, "Conflict", false);
419 assert_eq!(v["details"]["etagExpected"], "\"abc123\"");
420 assert_eq!(v["details"]["etagActual"], "\"def456\"");
421 }
422
423 #[test]
424 fn conflict_without_actual_serializes() {
425 let e = AppError::Conflict {
426 etag_expected: "\"abc123\"".to_string(),
427 etag_actual: None,
428 };
429 let v = ser(&e);
430 assert_envelope(&v, "Conflict", false);
431 assert_eq!(v["details"]["etagExpected"], "\"abc123\"");
432 assert!(v["details"]["etagActual"].is_null());
433 }
434
435 #[test]
436 fn rate_limited_with_hint_serializes() {
437 let e = AppError::RateLimited {
438 retry_after_ms: Some(5000),
439 };
440 let v = ser(&e);
441 assert_envelope(&v, "RateLimited", true);
442 assert_eq!(v["details"]["retryAfterMs"], 5000_u64);
443 }
444
445 #[test]
446 fn rate_limited_without_hint_serializes() {
447 let e = AppError::RateLimited {
448 retry_after_ms: None,
449 };
450 let v = ser(&e);
451 assert_envelope(&v, "RateLimited", true);
452 assert!(v["details"]["retryAfterMs"].is_null());
453 }
454
455 #[test]
456 fn unsupported_serializes() {
457 let e = AppError::Unsupported {
458 op: "SelectObjectContent".to_string(),
459 provider: "MinIO".to_string(),
460 };
461 let v = ser(&e);
462 assert_envelope(&v, "Unsupported", false);
463 assert_eq!(v["details"]["op"], "SelectObjectContent");
464 assert_eq!(v["details"]["provider"], "MinIO");
465 }
466
467 #[test]
468 fn network_serializes() {
469 let e = AppError::Network {
470 source: "connection refused".to_string(),
471 };
472 let v = ser(&e);
473 assert_envelope(&v, "Network", true);
474 assert_eq!(v["details"]["source"], "connection refused");
475 }
476
477 #[test]
478 fn cancelled_serializes() {
479 let e = AppError::Cancelled;
480 let v = ser(&e);
481 assert_envelope(&v, "Cancelled", false);
482 assert!(
484 v.get("details").is_none(),
485 "Cancelled must not have a details field"
486 );
487 }
488
489 #[test]
490 fn locked_serializes() {
491 let e = AppError::Locked {
492 lock_id: "lock-001".to_string(),
493 op_name: "DeleteObject".to_string(),
494 };
495 let v = ser(&e);
496 assert_envelope(&v, "Locked", false);
497 assert_eq!(v["details"]["lockId"], "lock-001");
498 assert_eq!(v["details"]["opName"], "DeleteObject");
499 }
500
501 #[test]
502 fn validation_serializes() {
503 let e = AppError::Validation {
504 field: "bucket_name".to_string(),
505 hint: "must not contain uppercase letters".to_string(),
506 };
507 let v = ser(&e);
508 assert_envelope(&v, "Validation", false);
509 assert_eq!(v["details"]["field"], "bucket_name");
510 assert_eq!(v["details"]["hint"], "must not contain uppercase letters");
511 }
512
513 #[test]
514 fn provider_specific_serializes() {
515 let e = AppError::ProviderSpecific {
516 code: "InvalidBucketState".to_string(),
517 message: "bucket is in an invalid state for this operation".to_string(),
518 };
519 let v = ser(&e);
520 assert_envelope(&v, "ProviderSpecific", false);
521 assert_eq!(v["details"]["code"], "InvalidBucketState");
522 assert_eq!(
523 v["details"]["message"],
524 "bucket is in an invalid state for this operation"
525 );
526 }
527
528 #[test]
529 fn internal_via_helper_serializes_valid_uuid() {
530 let e = AppError::internal_new();
531 let v = ser(&e);
532 assert_envelope(&v, "Internal", false);
533 let trace_id = v["details"]["traceId"]
534 .as_str()
535 .expect("traceId must be a string");
536 let parsed = Uuid::parse_str(trace_id).expect("traceId must be a valid UUID");
538 assert_eq!(parsed.get_version_num(), 4, "traceId must be a v4 UUID");
539 }
540
541 #[test]
542 fn internal_explicit_trace_id_round_trips() {
543 let trace_id = Uuid::new_v4().to_string();
544 let e = AppError::Internal {
545 trace_id: trace_id.clone(),
546 };
547 let v = ser(&e);
548 assert_envelope(&v, "Internal", false);
549 assert_eq!(v["details"]["traceId"], trace_id);
550 }
551
552 #[test]
553 fn all_non_retryable_variants_are_false() {
554 let cases: Vec<AppError> = vec![
555 AppError::Auth {
556 reason: "missing".to_string(),
557 },
558 AppError::AccessDenied {
559 op: "op".to_string(),
560 resource: "res".to_string(),
561 },
562 AppError::NotFound {
563 resource: "r".to_string(),
564 },
565 AppError::Conflict {
566 etag_expected: "e".to_string(),
567 etag_actual: None,
568 },
569 AppError::Unsupported {
570 op: "op".to_string(),
571 provider: "p".to_string(),
572 },
573 AppError::Cancelled,
574 AppError::Locked {
575 lock_id: "l".to_string(),
576 op_name: "n".to_string(),
577 },
578 AppError::Validation {
579 field: "f".to_string(),
580 hint: "h".to_string(),
581 },
582 AppError::ProviderSpecific {
583 code: "c".to_string(),
584 message: "m".to_string(),
585 },
586 AppError::internal_new(),
587 ];
588 for e in &cases {
589 assert!(!e.retryable(), "{} must not be retryable", e.kind());
590 }
591 }
592}