Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Idempotency store

The IdempotencyStore interface, its record states, the reserve and finalize contract, and memoryIdempotencyStore().

The client uses an IdempotencyStore to remember what happened to each idempotencyKey. Use the built-in memory store for one process, or implement the interface on a shared database. Guides: Idempotency, Deploy with multiple instances.

memoryIdempotencyStore()

import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";

const store = memoryIdempotencyStore();
const sms = createSmsClient({ adapters: [memory()], idempotency: { store } });

await sms.send({ to: "+14155550123", body: "Hi", idempotencyKey: "welcome:42" });
console.log(store.size, (await store.get("welcome:42"))?.state); // 1 "accepted"

Returns a MemoryIdempotencyStore, which is an IdempotencyStore with two extra members.

Member Meaning
delete(key): Promise<void> Removes a record, for example after you reconcile an unknown outcome.
size: number Number of records, including expired ones not yet removed.

It lives in one process. Records are lost on restart and not shared between instances, workers, or serverless invocations. Accepted and rejected records expire after ttlSec. unknown and reserved records never expire on their own. Exported from both @opencoredev/sms-sdk and @opencoredev/sms-sdk/testing.

IdempotencyStore

interface IdempotencyStore {
  get(key: string): Promise<IdempotencyRecord | null>;
  reserve(input: { key: string; fingerprint: string; now: number }): Promise<ReserveResult>;
  finalize(input: { key: string; reservationId: string; record: FinalIdempotencyRecord; ttlSec: number; now: number }): Promise<FinalizeResult>;
}

get(key)

Returns the current record, or null when there is none or it has expired.

reserve({ key, fingerprint, now })

Claims the key atomically, in one compare-and-set operation such as INSERT ... ON CONFLICT or Redis SET NX.

  • Succeeds, returning { kind: "reserved", reservationId }, only when there is no record, the record has expired, or the record is rejected with the same fingerprint. The new record is { state: "reserved", fingerprint, reservationId, reservedAt: now }.
  • Otherwise returns { kind: "existing", record } without changing anything.
  • A reservation must never expire into a claimable state. An abandoned reservation means the outcome is unknown.

now is epoch milliseconds.

finalize({ key, reservationId, record, ttlSec, now })

Replaces the reservation with a final record, only if reservationId still owns the key.

  • Returns { kind: "finalized" } on success, { kind: "not_owner" } otherwise.
  • Keep accepted and rejected records for ttlSec seconds.
  • Keep unknown records until an operator clears them.

If finalize throws, the client ignores the error because the send’s outcome is already decided. The key stays reserved. Later sends read it as in progress, then as an unknown outcome, so the message is never resent.

Records

type IdempotencyRecord =
  | { state: "reserved"; fingerprint: string; reservationId: string; reservedAt: number }
  | { state: "accepted"; fingerprint: string; result: SmsSendResult }
  | { state: "rejected"; fingerprint: string; error: SerializedSmsError }
  | { state: "unknown"; fingerprint: string; error: SerializedSmsError };

type FinalIdempotencyRecord = Exclude<IdempotencyRecord, { state: "reserved" }>;
State Written when A later send with the same key and payload
reserved A send starts IdempotencyInProgressError, or HandoffUnknownError (stale_reservation) once older than staleReservationSec
accepted A provider accepted Returns result with replayed: true, no request
rejected Every attempt was rejected, or beforeSend rejected or threw Sends again
unknown The outcome was unknown HandoffUnknownError (replayed_unknown), never resends

Records hold no message body. result and error are the redacted SmsSendResult and SerializedSmsError, plain JSON-serializable objects that any JSON column or key-value store can hold.

Fingerprint

fingerprint is the SHA-256 (hex) of the message’s to, from, body, mediaUrls, sendAt, validityPeriodSec, and webhookUrl, plus the parsed providerOptions entries for configured adapters, keys sorted, when any has fields. Empty entries and entries for adapters the client does not have are left out. A different fingerprint for an existing key throws IdempotencyConflictError. Store it as an opaque string.

Types

IdempotencyStore, IdempotencyRecord, FinalIdempotencyRecord, ReserveResult, FinalizeResult, and MemoryIdempotencyStore are exported as types from @opencoredev/sms-sdk.