Errors & Retries

Everything fallible in the crate returns Result<_, acdp::AcdpError>. AcdpError is a typed, exhaustive mapping of the RFC-ACDP-0007 §5 wire error codes plus a handful of local/transport errors. This page explains the variants, how registry wire errors round-trip into them, and which are safe to retry.

The wire error envelope and the canonical code registry are specified in RFC-ACDP-0007 (Capabilities & Errors).

Wire errors round-trip into typed variants

When a registry returns an error envelope, the client maps it via AcdpError::from_wire_error. Each canonical code becomes a specific variant; unknown/future codes pass through as AcdpError::Registry(WireError) so a newer registry never breaks an older client.

Wire codeAcdpError variant
invalid_signatureInvalidSignature
hash_mismatchRemoteHashMismatch
data_ref_hash_mismatchDataRefHashMismatch
schema_violationSchemaViolation
not_authorizedNotAuthorized
not_foundNotFound
rate_limitedRateLimited
payload_too_largePayloadTooLarge
embedded_too_largeEmbeddedTooLarge
key_resolution_failedKeyResolution
key_resolution_unreachableKeyResolutionUnreachable
key_not_authorizedKeyNotAuthorized
unsupported_algorithmUnsupportedAlgorithm
not_implementedNotImplemented
cursor_expiredCursorExpired
invalid_cursorInvalidCursor
duplicate_publishDuplicatePublish
cross_registry_resolution_failedCrossRegistryResolutionFailed
internal_errorRegistryInternal
superseded_targetSupersededTarget { reason, message }
(unknown)Registry(WireError)

This round-trip is exhaustively pinned by the all_20_wire_codes_round_trip test in src/error.rs. Adding a new code is a coordinated three-edit change — see below.

Local vs. remote hash mismatches

Two distinct variants exist deliberately:

  • HashMismatch { stored, recomputed }you detected it. The content_hash you recomputed locally doesn't match the one in the body. This is the tamper-detection path in the verification pipeline.
  • RemoteHashMismatch — the registry rejected your publish with hash_mismatch. The hash you sent didn't match the body you sent.

The same split applies to signatures (local verification failure vs. a registry's invalid_signature rejection — both map to InvalidSignature).

Supersession failures

A superseded_target wire error carries a details.reason sub-vocabulary, decoded into SupersessionReason:

SupersessionReasonMeaning
NotFoundthe supersedes target doesn't exist on this registry
LineageMismatchthe target's lineage_id differs from the new publication's
VersionMismatchthe new version isn't exactly previous.version + 1
AlreadySupersededthe target was already superseded by another version
CrossRegistrySupersessionUnsupportedv0.1.0 only allows same-registry supersession
LineageWalkFailedan intermediate context in the supersedes chain couldn't be retrieved
Othera reason this library version doesn't recognize (forward-compat)

Most of these are prevented up front by using supersede_body, which sets version, supersedes, and expected_lineage_id consistently.

Retryability

AcdpError::is_transient() tells you whether retrying the same request body (with the same Idempotency-Key, if any) is worthwhile. It returns true only for the variants the spec marks retryable:

Transient (is_transient() == true)Why
KeyResolutionUnreachablethe DID host was temporarily unreachable (RFC-ACDP-0001 §5.11)
RateLimitedback off and retry (RFC-ACDP-0008 §4.3)
CrossRegistryResolutionFaileda foreign hop failed transiently (RFC-ACDP-0006 §7)
RegistryInternalthe registry hit an internal error (HTTP 5xx)
Httpa connect/timeout/transport error

Everything else — InvalidSignature, SchemaViolation, HashMismatch, KeyResolution (permanent resolution failure, including DNS-rebinding refusals), NotAuthorized, NotFound, PayloadTooLarge — is permanent. Retrying won't help; fix the request or the key.

RegistryClient::publish_with_retry(req, idempotency_key, max_attempts) uses exactly this predicate, with bounded backoff (250 ms → 500 ms → 1 s → 2 s):

# #[cfg(feature = "client")]
# async fn run(client: &acdp::client::RegistryClient, req: &acdp::PublishRequest) -> Result<(), acdp::AcdpError> {
let resp = client.publish_with_retry(req, "publish-2026-06-10-abc", 4).await?;
# let _ = resp; Ok(()) }

Handling errors

AcdpError is a plain enum — match on it, or use the convenience predicates:

# fn handle(err: acdp::AcdpError) {
use acdp::AcdpError;

match &err {
    AcdpError::InvalidSignature(msg) => eprintln!("untrusted context: {msg}"),
    AcdpError::NotFound(_)            => { /* 404 — nothing to retry */ }
    e if e.is_transient()            => { /* back off and retry */ }
    other                            => eprintln!("permanent failure: {other}"),
}
# }

From conversions are provided for serde_json::Error (→ Serialization), std::io::Error (→ Http), and reqwest::Error (→ Http, distinguishing connect/timeout), so ? works naturally in client code.

Adding a new wire error code

If you contribute a new code (per CONTRIBUTING), it's three coordinated edits:

  1. A new variant in src/error.rs::AcdpError, with the RFC citation.
  2. A match arm in AcdpError::from_wire_error.
  3. Extend the all_20_wire_codes_round_trip test (and bump its count).

Also revisit is_transient (is the new code retryable?) and SupersessionReason (if the code uses a details.reason sub-vocabulary).