ADR-013: RPC Reconnect & Idempotency Policy
Metadata
- Date: 2025-10-30
- Status: Accepted
- Tags: RPC, reconnection, idempotency, client-side resilience
Context
WebSocket connections are unreliable in practice:
- Mobile networks drop connections frequently.
- Browser tab suspension/hibernation causes disconnects.
- Server-side eviction (e.g., Cloudflare DO rehydration) orphans in-flight RPCs.
Clients need a reliable policy for handling in-flight requests across reconnects:
- Reject safely: If no idempotency guarantee, fail fast rather than silently retry.
- Smart retry: For idempotent operations (queries, read-heavy RPCs), allow client-initiated resend.
- Deduplication: Prevent accidental duplicate execution when the same request is resent.
This ADR defines the default policy and pattern for apps to opt into idempotency.
Decision
1. Default Reconnect Policy: Fail-Fast
When socket disconnects, all pending RPC promises reject with WsDisconnectedError immediately.
Rationale: Safe default for non-idempotent operations (mutations, stateful work). No silent retries.
// Example
try {
await client.request(ChargeCard, { amount: 100 });
} catch (error) {
if (error instanceof WsDisconnectedError) {
// Connection dropped; payment may or may not have gone through
// App must handle ambiguity (query backend, show manual retry UI)
}
}2. Opt-In Resend with idempotencyKey
If RPC request includes meta.idempotencyKey (string) and reconnects within RESEND_WINDOW_MS (default 5s), client auto-resends the same request.
Rationale: Developer declares "this operation is safe to retry"; client honors it automatically.
// Example: idempotent query
const users = await client.request(
ListUsers,
{ page: 1 },
{
idempotencyKey: "list-users-page-1-tab-123",
signal: abortSignal,
},
);3. Single-Flight Deduplication (Client-Side)
If the same RPC (same (pair, idempotencyKey)) is sent multiple times before the first completes, subsequent calls coalesce to the same promise.
Prevents: UI double-click fires two requests; both wait for single execution.
// Example: double-click protection
const handleClick = async () => {
// Even if clicked twice quickly, only one RPC is in-flight
const result = await client.request(SubmitForm, payload, {
idempotencyKey: "submit-form-" + Date.now(),
});
updateUI(result);
};Non-Goals: Do NOT hash payloads implicitly for dedup; key must be explicit.
4. Server-Side Idempotency Pattern (Not in Core)
Core router provides no idempotency storage. Apps implement via middleware:
// Pseudo-code: idempotency middleware
router.use(async (ctx, next) => {
if (!ctx.isRpc || !ctx.meta.idempotencyKey) return next();
// Scope: (tenant, user, rpc-type, key) to prevent cross-user replays
const key = `${ctx.data.tenant}:${ctx.data.userId}:${ctx.type}:${ctx.meta.idempotencyKey}`;
const cached = await idempotencyStorage.get(key);
if (cached) {
// Already executed: return cached result without re-running handler
return ctx.send(cached);
}
// Not seen before: execute handler
await next();
// Store result for future identical requests
const result = ctx.lastReply; // pseudo-field; TBD in implementation
await idempotencyStorage.set(key, result, ttl: 300_000); // 5 min TTL
});Storage Options: In-memory Map (single-server), Redis, Cloudflare KV, DynamoDB.
5. Resend Window & Time-Based Expiry
- Default resend window:
RESEND_WINDOW_MS = 5_000(5 seconds). - Rationale: Covers most network glitches; prevents stale retries on network partition.
- Multi-Tab Safety: Each tab has its own socket; resend only on that socket's reconnect.
If reconnect happens >5s after disconnect, pending RPCs reject (not retried). App must handle manually.
// Example: explicit resend with custom window
await client.request(MyRPC, payload, {
idempotencyKey: "op-123",
resendWindowMs: 30_000, // 30-second window for this request
});6. Scope Idempotency Keys Properly
Bad: Key = request payload hash (implicit; user doesn't know when dedup applies).
Good: Key = (user_id, operation, timestamp_or_nonce) (explicit; scoped to user, operation type, intent).
Example:
// Multi-tenant scenario: prevent user B from re-using user A's idempotency key
// Server middleware:
const key = `${ctx.data.tenantId}:${ctx.data.userId}:${ctx.type}:${ctx.meta.idempotencyKey}`;Alternatives Considered
- Automatic payload-based dedup: Pros: no explicit key needed. Cons: implicit behavior, hard to debug, breaks for mutable payloads.
- Server-side idempotency in core: Pros: guaranteed dedup. Cons: requires stateful router, not all apps need it, storage adapter overhead.
- Indefinite resend window: Pros: never lose a request. Cons: stale retries on network partition; client stuck re-sending forever.
- Mandatory idempotency key: Pros: explicit. Cons: breaks existing code, not all RPCs need it.
Consequences
Benefits
- Safe by default: Apps must opt-in to resend (fail-fast for non-idempotent ops).
- Developer control: Explicit
idempotencyKeymakes intent clear. - Flexible storage: Apps choose storage backend (Map, Redis, KV).
- Multi-tenant aware: Middleware pattern encourages proper scoping.
- Time-bounded: Resend window prevents indefinite retry loops.
Risks / Trade-offs
- Ambient responsibility: Developers must scope idempotency keys correctly (documented; exemplified in patterns).
- Duplicated work: Without idempotency middleware, same request sent twice = executed twice.
- Network partition edge case: If partition lasts >5s, pending RPCs fail; client must handle manual retry.
Maintenance
- Core: No new state tracking (idempotency is app-level pattern).
- Tests: Resend window boundaries, multi-tab safety note, middleware example tests.
- Docs: Idempotency pattern guide (Map/Redis/KV), scoping guidance, multi-tenant examples.
References
- ADR-012: Describes RPC abort, deadline, one-shot—foundation for reconnect safety.
- Client Implementation:
packages/client/src/index.ts—request()with idempotencyKey, resend logic,WsDisconnectedError. - Pattern Docs:
docs/specs/patterns.md#idempotent-rpc— middleware code, storage adapter examples. - Types:
packages/client/src/errors.ts—WsDisconnectedErrorpackages/core/src/types.ts—MessageMeta.idempotencyKey,MessageMeta.timeoutMs
- Constants:
packages/core/src/constants.ts—RESEND_WINDOW_MS,DEFAULT_RPC_TIMEOUT_MS - Example:
examples/rpc-idempotency— middleware + Map storage, Redis adapter snippet.