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
- Check connectivity and health of the configured KvStore backend and restart the gateway once it is reachable
- Verify REVOCATION_NAMESPACE and store connection settings in the gateway configuration
- Inspect the inner error in the message for backend-specific diagnostics (timeout, auth, connection refused) and fix that root cause
- 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
- Add a startup/periodic health probe of the KvStore before serving revocation traffic
- Deploy the gateway and its KV backend with liveness/readiness checks so revocations only run against a healthy store
- Monitor the inner backend error strings (timeouts, auth, connection) and alert on them
- Keep revocation keys warm/known; ensure the namespace exists before first use
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
- read device revocation
- list device revocations
- list principal revocations
- write device revocation
- write device revocation
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)