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 isrejectedwith the samefingerprint. 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
acceptedandrejectedrecords forttlSecseconds. - Keep
unknownrecords 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.