Skip to content

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.

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:

  1. Read-after-write lag within a single caller’s workflow: Your backend writes a new fact on the writer (for example, granting User:alice access to Document:handbook) and immediately runs authorize or query. If that read is routed to a replica that has not yet applied the write, the check can observe stale state.
  2. 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.

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-Revision response header, the client updates client.revision to 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.

You can control how reads (authorize, query, listFacts, and getPolicy) are routed and whether they wait for the client’s observed revision:

ModeRead routingObserved revision requirementWhen to use
session (default)Local readSends this client’s observed revision when availableDefault for application workflows. Prevents read-after-write lag for writes observed by this client while still serving reads locally.
eventualLocal readOmits the observed revision requirementLow-latency reads that can tolerate replica lag, even if this client recently observed a newer write.
authoritativeWriter readSends this client’s observed revision when availableCross-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 service
const 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.

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 (session or authoritative) reject with a ConnectError using code FailedPrecondition.
  • eventual reads remain available on that client, but do not repair the inconsistent scope.
  • Create a fresh session via client.session() (or construct a new VerdictClient) to require consistency again.

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 (revision is undefined), generates a fresh UUID sessionId, and defaults to session consistency, independently of the parent client’s sessionId or consistency setting.
  • 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.

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 ConnectError using code Unavailable, DeadlineExceeded, ResourceExhausted, or Unknown, 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.

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-After response header, and limits accumulated backoff waiting to 5,000 milliseconds.
  • If a computed delay would exceed the 5,000-millisecond wait budget, the client returns the last 503 response immediately without waiting.
  • Set retryHttp503: false in VerdictClientOptions to disable HTTP 503 backoff; the separate Connect-level mutation retry mechanism still applies.

Mutation calls enforce both a per-attempt timeout and an overall call deadline:

  • mutationAttemptTimeoutMs defaults to 12,000 milliseconds.
  • mutationOverallTimeoutMs defaults to 30,000 milliseconds.
  • Both configurable timeout values must be integer milliseconds from 1 to 55,000 (values outside this range throw RangeError).
  • Each mutation attempt uses the smaller of mutationAttemptTimeoutMs and 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.