astrid-runtime/astrid · error · anyhow::Error

read principal revocation

Error message

read principal revocation {principal}: {error}

What it means

record_principal_max reads the current revocation epoch for a principal from the KV store before a CAS-style monotonic update. This error wraps any failure of that read (store.get on REVOCATION_NAMESPACE). It is thrown because the function cannot safely compute max(current, wanted) without knowing the current durable epoch; on a read failure it fails closed rather than guessing.

Solutions

  1. Check connectivity and health of the configured KvStore backend and restart the gateway once it is reachable
  2. Verify REVOCATION_NAMESPACE and store connection settings in the gateway configuration
  3. Inspect the inner error in the message for backend-specific diagnostics (timeout, auth, connection refused) and fix that root cause
  4. Retry record_principal_max; the CAS loop is designed to be retried once the store is healthy

Example fix

// before: propagate read error
.map_err(|error| anyhow::anyhow!("read principal revocation {principal}: {error}"))?
// after: retry transient store failures before failing
let current = retry_transient(3, || store.get(REVOCATION_NAMESPACE, &key))
    .await
    .map_err(|error| anyhow::anyhow!("read principal revocation {principal}: {error}"))?;
Defensive patterns

Strategy: retry

Validate before calling

// Rust: check store health before recording revocations
pub async fn assert_store_healthy(store: &dyn KvStore) -> anyhow::Result<()> {
    store.get(REVOCATION_NAMESPACE, "__health_probe__").await
        .map(|_| ())
        .map_err(|e| anyhow::anyhow!("revocation store unavailable: {e}"))
}

Try / catch

match record_principal_max(&store, principal, epoch).await {
    Ok(published) => info!(principal, published, "revocation recorded"),
    Err(e) if is_transient(&e) => backoff_retry(3, || record_principal_max(&store, principal, epoch)).await?,
    Err(e) => { error!(principal, %e, "store read failed; fail closed locally"); local_fence.lock().insert(principal.clone(), epoch); }
}

Prevention

When it happens

Trigger: Calling record_principal_max (via migrate_legacy_file or spawn_watcher) when the underlying KvStore backend is unreachable, times out, or returns an internal error for a get() on the principal revocation key.

Common situations: Redis/etcd/KV backend down or restarting at gateway startup; network partition between gateway and store; wrong namespace/config so the backend rejects the get; TLS or auth misconfiguration on the KV client.

Understand the failure class

Background: Database query failed: Internal Server Error 500s wrapping SQL, Prisma, and connection failures — what to check first — this error's family across 16 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/a50e314363500c85. Report an issue: GitHub.

Appendix: source

Thrown at crates/astrid-gateway/src/revocations.rs:129

fn encode_epoch(epoch: u64) -> Vec<u8> {
    epoch.to_le_bytes().to_vec()
}

/// Record the maximum principal revocation epoch durably. The returned value
/// is the epoch now authoritative in storage (which may be newer than the
/// requested event when another writer won the CAS race).
pub async fn record_principal_max(
    store: &dyn KvStore,
    principal: &PrincipalId,
    epoch: u64,
) -> anyhow::Result<u64> {
    let key = format!("{PRINCIPAL_PREFIX}{principal}");
    loop {
        let current = store
            .get(REVOCATION_NAMESPACE, &key)
            .await
            .map_err(|error| anyhow::anyhow!("read principal revocation {principal}: {error}"))?;
        let current_epoch = current
            .as_deref()
            .map(|bytes| decode_epoch(bytes, &key))
            .transpose()?;
        let wanted = current_epoch.map_or(epoch, |current| current.max(epoch));
        if current_epoch == Some(wanted) {
            return Ok(wanted);
        }
        match store
            .compare_and_swap(
                REVOCATION_NAMESPACE,
                &key,
                current.as_deref(),
                encode_epoch(wanted),
            )
            .await
        {
            Ok(true) => return Ok(wanted),

View on GitHub (pinned to affd8760f4)