Client sessions, consistency, and retries
VerdictClient includes built-in revision tracking, configurable read
consistency modes, isolated request sessions, and bounded retry and timeout
policies. Understanding how these mechanisms fit together helps you choose the
right settings for both single-node and replicated deployments.
When consistency settings matter
Section titled “When consistency settings matter”Against a standalone single-instance Verdict Server (such as a single local or
hosted verdict serve process), all mutations and reads hit the same store.
Every read immediately reflects earlier writes, and the server does not return
revision tokens.
In replicated Verdict setups (such as multi-node servers with read replicas or Cloud instances), mutations commit on a writer while reads can be served by replicas that apply changes asynchronously. Without coordination, asynchronous replication introduces two consistency challenges:
- Read-after-write lag within a single caller’s workflow: Your backend
writes a new fact on the writer (for example, granting
User:aliceaccess toDocument:handbook) and immediately runsauthorizeorquery. If that read is routed to a replica that has not yet applied the write, the check can observe stale state. - Cross-client inconsistency: One client (or service instance) commits a write on the writer, and a separate client (or two clients routed to different replicas) reads before all replicas have caught up to that commit.
VerdictClient addresses single-caller read-after-write lag by tracking an
observed revision in memory, and gives you explicit read consistency
modes to balance local replica reads against writer freshness.
Observed revisions
Section titled “Observed revisions”Each VerdictClient maintains an in-memory observed revision exposed at
client.revision:
- When a read or mutation call succeeds and the instance includes a
Verdict-Revisionresponse header, the client updatesclient.revisionto the highest revision it has observed in that history, even when concurrent calls finish out of order. - A failed call never updates
client.revision. - Treat revision tokens as opaque strings; do not parse or compare them in application code.
Each client also sends a stable client.sessionId header (Verdict-Session-Id)
so replicated deployments can route a caller’s reads to a consistent replica.
sessionId is strictly a routing identifier: configuring two clients with the
same sessionId shares their routing affinity, but does not share their
in-memory client.revision.
Read consistency modes
Section titled “Read consistency modes”You can control how reads (authorize, query, listFacts, and getPolicy)
are routed and whether they wait for the client’s observed revision:
| Mode | Read routing | Observed revision requirement | When to use |
|---|---|---|---|
session (default) | Local read | Sends this client’s observed revision when available | Default for application workflows. Prevents read-after-write lag for writes observed by this client while still serving reads locally. |
eventual | Local read | Omits the observed revision requirement | Low-latency reads that can tolerate replica lag, even if this client recently observed a newer write. |
authoritative | Writer read | Sends this client’s observed revision when available | Cross-client freshness. Routes the read to the writer so it observes commits made by other clients whose revisions you have not seen. |
Set consistency in VerdictClientOptions
to configure the client’s default mode, or pass { consistency } to an
individual read call to override that default:
import { VerdictClient, identity } from "@marcporciuncula/verdict-client";
const client = new VerdictClient({ instanceUrl: "https://your-instance.example.com", apiKey: "your-instance-key", consistency: "session",});
// Read from the writer when checking after a mutation performed by another serviceconst allowed = await client.authorize( identity("User", "alice"), "read", identity("Document", "handbook"), { consistency: "authoritative" },);Successful reads in every mode (including eventual) still update
client.revision when the response includes a revision header.
Handling incompatible revision histories
Section titled “Handling incompatible revision histories”VerdictClient verifies that each returned revision belongs to the same
instance and generation history. If a successful response supplies a malformed
revision token or a revision from an incompatible history, client.revision
becomes undefined and the client marks its revision scope as inconsistent.
Once a scope is marked inconsistent:
- Subsequent reads that require consistency (
sessionorauthoritative) reject with aConnectErrorusing codeFailedPrecondition. eventualreads remain available on that client, but do not repair the inconsistent scope.- Create a fresh session via
client.session()(or construct a newVerdictClient) to require consistency again.
Isolating workflows with sessions
Section titled “Isolating workflows with sessions”When a backend process shares a single root VerdictClient across many incoming
requests, every read and write on that root client advances one shared
client.revision. That means a read for one user might wait for an unrelated
write performed by another request moments earlier.
To give an individual request or workflow its own observed-revision scope
without creating a new transport connection from scratch, call
client.session():
const requestClient = client.session();
await requestClient.insertFact([ "is_owner", identity("User", "alice"), identity("Document", "handbook"),]);
const rows = await requestClient.query([ "allow", identity("User", "alice"), "read", identity("Document", "handbook"),]);client.session(options?) returns a new VerdictClient scoped to that workflow:
- Inherited settings: Inherits connection (
instanceUrl,fetch,wireFormat), credential (apiKey), integer decoding (integerMode), and retry/timeout settings from the parent client. - Fresh revision and defaults: Starts without an observed revision
(
revisionisundefined), generates a fresh UUIDsessionId, and defaults tosessionconsistency, independently of the parent client’ssessionIdorconsistencysetting. - Custom session options: Pass
SessionOptions({ sessionId, consistency }) to specify an explicit routing identifier or default consistency mode for the session. - Concurrent execution: Sessions do not queue or serialize calls; your
application controls call ordering with
await.
Retries and timeouts
Section titled “Retries and timeouts”Mutation retries and idempotency keys
Section titled “Mutation retries and idempotency keys”Each mutation call (insertFact, deleteFact, batchFacts, and setPolicy)
generates a unique Idempotency-Key and reuses it for at most two RPC attempts:
- A second attempt is permitted only when the first attempt fails with a
ConnectErrorusing codeUnavailable,DeadlineExceeded,ResourceExhausted, orUnknown, and only if the HTTP 503 backoff path did not already handle the failure. - The client does not retry other Connect error codes through this mutation-attempt mechanism.
A mutation can commit on the instance even if its response is lost in transit. Automatic retries within the same method call reuse that call’s idempotency key, whereas calling the method again generates a new key. The instance owns idempotency key retention and replay behavior; a client retry does not guarantee that the caller receives a successful response.
HTTP 503 backoff
Section titled “HTTP 503 backoff”Bounded HTTP 503 backoff is enabled by default (retryHttp503: true) for both
reads and mutations:
- When the transport receives HTTP 503, it permits at most two additional fetch
attempts using exponential jitter, honors the
Retry-Afterresponse header, and limits accumulated backoff waiting to5,000milliseconds. - If a computed delay would exceed the
5,000-millisecond wait budget, the client returns the last 503 response immediately without waiting. - Set
retryHttp503: falseinVerdictClientOptionsto disable HTTP 503 backoff; the separate Connect-level mutation retry mechanism still applies.
Mutation timeouts
Section titled “Mutation timeouts”Mutation calls enforce both a per-attempt timeout and an overall call deadline:
mutationAttemptTimeoutMsdefaults to12,000milliseconds.mutationOverallTimeoutMsdefaults to30,000milliseconds.- Both configurable timeout values must be integer milliseconds from
1to55,000(values outside this range throwRangeError). - Each mutation attempt uses the smaller of
mutationAttemptTimeoutMsand the remaining overall mutation deadline. - These options apply only to mutations and do not set a read RPC timeout.
See VerdictClientOptions for
all configuration properties and
VerdictClient for method signatures
and return types.