1use crate::primitives::ContentHash;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum AcdpError {
16 #[error("JCS canonicalization failed: {0}")]
19 Canonicalization(String),
20
21 #[error("content_hash mismatch\n stored: {stored}\n recomputed: {recomputed}")]
24 HashMismatch {
25 stored: ContentHash,
27 recomputed: ContentHash,
29 },
30
31 #[error("registry rejected hash_mismatch: {0}")]
39 RemoteHashMismatch(String),
40
41 #[error("data_ref hash mismatch: {0}")]
50 DataRefHashMismatch(String),
51
52 #[error("invalid signature: {0}")]
55 InvalidSignature(String),
56
57 #[error("key resolution failed: {0}")]
60 KeyResolution(String),
61
62 #[error("key resolution unreachable (transient): {0}")]
64 KeyResolutionUnreachable(String),
65
66 #[error("key not authorized: {0}")]
68 KeyNotAuthorized(String),
69
70 #[error("invalid body: {0}")]
73 InvalidBody(String),
74
75 #[error("missing required field: {0}")]
77 MissingField(&'static str),
78
79 #[error("schema violation: {0}")]
82 SchemaViolation(String),
83
84 #[error("payload too large: {0}")]
86 PayloadTooLarge(String),
87
88 #[error("embedded data reference too large: {0}")]
91 EmbeddedTooLarge(String),
92
93 #[error("unsupported algorithm: {0}")]
96 UnsupportedAlgorithm(String),
97
98 #[error("not implemented: {0}")]
101 NotImplemented(String),
102
103 #[error("not found: {0}")]
106 NotFound(String),
107
108 #[error("not authorized: {0}")]
111 NotAuthorized(String),
112
113 #[error("rate limited: {0}")]
115 RateLimited(String),
116
117 #[error("search cursor expired")]
120 CursorExpired,
121
122 #[error("invalid cursor: {0}")]
124 InvalidCursor(String),
125
126 #[error("superseded target rejected ({reason:?}): {message}")]
130 SupersededTarget {
131 reason: SupersessionReason,
133 message: String,
135 },
136
137 #[error("duplicate publish: {0}")]
140 DuplicatePublish(String),
141
142 #[error("cross-registry resolution failed: {0}")]
145 CrossRegistryResolutionFailed(String),
146
147 #[error("invalid registry receipt: {0}")]
154 InvalidReceipt(String),
155
156 #[error("registry internal error: {0}")]
159 RegistryInternal(String),
160
161 #[error("registry returned error: {0:?}")]
166 Registry(crate::wire_error::WireError),
167
168 #[error("serialization failed: {0}")]
170 Serialization(String),
171
172 #[error("HTTP error: {0}")]
174 Http(String),
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum SupersessionReason {
182 NotFound,
184 LineageMismatch,
186 VersionMismatch,
188 AlreadySuperseded,
190 CrossRegistrySupersessionUnsupported,
193 LineageWalkFailed,
196 #[serde(other)]
198 Other,
199}
200
201impl AcdpError {
202 pub fn is_transient(&self) -> bool {
218 matches!(
219 self,
220 AcdpError::KeyResolutionUnreachable(_)
221 | AcdpError::RateLimited(_)
222 | AcdpError::CrossRegistryResolutionFailed(_)
223 | AcdpError::RegistryInternal(_)
224 | AcdpError::Http(_)
225 )
226 }
227
228 pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
234 let code = wire.error.code.as_str();
235 let msg = wire.error.message.clone();
236
237 match code {
238 "invalid_signature" => AcdpError::InvalidSignature(msg),
239 "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
240 "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
241 "schema_violation" => AcdpError::SchemaViolation(msg),
242 "not_authorized" => AcdpError::NotAuthorized(msg),
243 "not_found" => AcdpError::NotFound(msg),
244 "rate_limited" => AcdpError::RateLimited(msg),
245 "payload_too_large" => AcdpError::PayloadTooLarge(msg),
246 "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
247 "key_resolution_failed" => AcdpError::KeyResolution(msg),
248 "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
249 "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
250 "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
251 "not_implemented" => AcdpError::NotImplemented(msg),
252 "cursor_expired" => AcdpError::CursorExpired,
253 "invalid_cursor" => AcdpError::InvalidCursor(msg),
254 "duplicate_publish" => AcdpError::DuplicatePublish(msg),
255 "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
256 "invalid_receipt" => AcdpError::InvalidReceipt(msg),
257 "internal_error" => AcdpError::RegistryInternal(msg),
258 "superseded_target" => {
259 let reason = wire
260 .error
261 .details
262 .as_ref()
263 .and_then(|d| d.get("reason"))
264 .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
265 .unwrap_or(SupersessionReason::Other);
266 AcdpError::SupersededTarget {
267 reason,
268 message: msg,
269 }
270 }
271 _ => AcdpError::Registry(wire),
273 }
274 }
275}
276
277impl From<serde_json::Error> for AcdpError {
278 fn from(e: serde_json::Error) -> Self {
279 AcdpError::Serialization(e.to_string())
280 }
281}
282
283impl From<std::io::Error> for AcdpError {
284 fn from(e: std::io::Error) -> Self {
285 AcdpError::Http(format!("io error: {e}"))
286 }
287}
288
289#[cfg(feature = "reqwest")]
290impl From<reqwest::Error> for AcdpError {
291 fn from(e: reqwest::Error) -> Self {
292 if e.is_connect() || e.is_timeout() {
293 AcdpError::Http(format!("connection failed: {e}"))
294 } else {
295 AcdpError::Http(e.to_string())
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::wire_error::{WireError, WireErrorBody};
304 use serde_json::json;
305
306 fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
307 WireError {
308 error: WireErrorBody {
309 code: code.into(),
310 message: message.into(),
311 details,
312 },
313 }
314 }
315
316 #[test]
317 fn all_21_wire_codes_round_trip() {
318 type Check = fn(&AcdpError) -> bool;
323 let cases: &[(&str, Check)] = &[
324 ("invalid_signature", |e| {
325 matches!(e, AcdpError::InvalidSignature(_))
326 }),
327 ("hash_mismatch", |e| {
328 matches!(e, AcdpError::RemoteHashMismatch(_))
329 }),
330 ("data_ref_hash_mismatch", |e| {
331 matches!(e, AcdpError::DataRefHashMismatch(_))
332 }),
333 ("schema_violation", |e| {
334 matches!(e, AcdpError::SchemaViolation(_))
335 }),
336 ("not_authorized", |e| {
337 matches!(e, AcdpError::NotAuthorized(_))
338 }),
339 ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
340 ("superseded_target", |e| {
341 matches!(e, AcdpError::SupersededTarget { .. })
342 }),
343 ("unsupported_algorithm", |e| {
344 matches!(e, AcdpError::UnsupportedAlgorithm(_))
345 }),
346 ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
347 ("payload_too_large", |e| {
348 matches!(e, AcdpError::PayloadTooLarge(_))
349 }),
350 ("embedded_too_large", |e| {
351 matches!(e, AcdpError::EmbeddedTooLarge(_))
352 }),
353 ("key_resolution_failed", |e| {
354 matches!(e, AcdpError::KeyResolution(_))
355 }),
356 ("key_resolution_unreachable", |e| {
357 matches!(e, AcdpError::KeyResolutionUnreachable(_))
358 }),
359 ("key_not_authorized", |e| {
360 matches!(e, AcdpError::KeyNotAuthorized(_))
361 }),
362 ("not_implemented", |e| {
363 matches!(e, AcdpError::NotImplemented(_))
364 }),
365 ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
366 ("invalid_cursor", |e| {
367 matches!(e, AcdpError::InvalidCursor(_))
368 }),
369 ("duplicate_publish", |e| {
370 matches!(e, AcdpError::DuplicatePublish(_))
371 }),
372 ("cross_registry_resolution_failed", |e| {
373 matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
374 }),
375 ("invalid_receipt", |e| {
376 matches!(e, AcdpError::InvalidReceipt(_))
377 }),
378 ("internal_error", |e| {
379 matches!(e, AcdpError::RegistryInternal(_))
380 }),
381 ];
382 assert_eq!(cases.len(), 21);
385 for (code, expected) in cases {
386 let err = AcdpError::from_wire_error(wire(code, "msg", None));
387 assert!(
388 expected(&err),
389 "code '{code}' did not map to its typed variant: got {err:?}"
390 );
391 }
392 }
393
394 #[test]
395 fn superseded_target_with_reason_details() {
396 let w = wire(
397 "superseded_target",
398 "lineage mismatch",
399 Some(json!({"reason": "lineage_mismatch"})),
400 );
401 match AcdpError::from_wire_error(w) {
402 AcdpError::SupersededTarget { reason, .. } => {
403 assert_eq!(reason, SupersessionReason::LineageMismatch);
404 }
405 other => panic!("expected SupersededTarget, got {other:?}"),
406 }
407 }
408
409 #[test]
410 fn superseded_target_without_details_falls_back_to_other() {
411 let w = wire("superseded_target", "?", None);
412 match AcdpError::from_wire_error(w) {
413 AcdpError::SupersededTarget { reason, .. } => {
414 assert_eq!(reason, SupersessionReason::Other);
415 }
416 other => panic!("got {other:?}"),
417 }
418 }
419
420 #[test]
421 fn unknown_code_passes_through_as_registry() {
422 let w = wire("immutable_field", "reserved future code", None);
423 assert!(matches!(
424 AcdpError::from_wire_error(w),
425 AcdpError::Registry(_)
426 ));
427 }
428
429 #[test]
432 fn lineage_walk_failed_reason_roundtrip() {
433 let w = wire(
434 "superseded_target",
435 "intermediate not retrievable",
436 Some(json!({
437 "reason": "lineage_walk_failed",
438 "unreachable_ctx_id":
439 "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
440 })),
441 );
442 match AcdpError::from_wire_error(w) {
443 AcdpError::SupersededTarget { reason, .. } => {
444 assert_eq!(reason, SupersessionReason::LineageWalkFailed);
445 }
446 other => panic!("got {other:?}"),
447 }
448 }
449
450 #[test]
452 fn is_transient_for_known_retryables() {
453 assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
454 assert!(AcdpError::RateLimited("x".into()).is_transient());
455 assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
456 assert!(AcdpError::RegistryInternal("x".into()).is_transient());
457 assert!(AcdpError::Http("x".into()).is_transient());
458 assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
459 assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
460 assert!(!AcdpError::NotFound("x".into()).is_transient());
461 assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
465 assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
467 }
468}