Idempotency
Use idempotency keys and a store so a retried job does not send the same SMS twice, and know where exactly-once ends.
A retried job should not text the customer twice. Give each message an idempotencyKey and give the client a store. A repeat send with the same key returns the first result instead of sending again.
Add a key and a store
import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
const sms = createSmsClient({
adapters: [
twilio({
accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
from: "+15550100001",
}),
],
idempotency: { store: memoryIdempotencyStore() },
});
const first = await sms.send({ to: "+14155550123", body: "Order 123 shipped.", idempotencyKey: "order:123:shipped:v1" });
const second = await sms.send({ to: "+14155550123", body: "Order 123 shipped.", idempotencyKey: "order:123:shipped:v1" });
console.log(second.replayed, second.providerId === first.providerId); // true true
The second call makes no request. It returns the stored result with replayed: true.
A key names the message, not the attempt: order:123:shipped:v1, appointment:88:reminder:24h. Keys are 1 to 255 characters. Bump the version suffix to deliberately send the same event again.
What happens on a repeat key
| Stored record | Same payload | Different payload or sender |
|---|---|---|
| None | Sends | Sends |
accepted |
Returns the stored result, replayed: true, no request |
IdempotencyConflictError |
rejected |
Sends again | IdempotencyConflictError |
unknown |
HandoffUnknownError (reason: "replayed_unknown"), never resends |
IdempotencyConflictError |
reserved, younger than staleReservationSec |
IdempotencyInProgressError (another process is sending) |
IdempotencyConflictError |
reserved, older than staleReservationSec |
HandoffUnknownError (reason: "stale_reservation"), never resends |
IdempotencyConflictError |
“Same payload” means the same to, from, body, mediaUrls, sendAt, validityPeriodSec, and webhookUrl, plus any providerOptions that apply to a configured adapter, compared by a SHA-256 fingerprint.
Two concurrent send() calls with the same key on one client share one request, even without a store. The second gets the same result with replayed: true. Without a store, nothing is remembered once the first call finishes.
Options
import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";
const sms = createSmsClient({
adapters: [memory()],
idempotency: {
store: memoryIdempotencyStore(),
ttlSec: 7 * 24 * 60 * 60, // keep accepted and rejected records for 7 days
staleReservationSec: 120, // treat a reservation older than 2 minutes as unknown
},
});
| Option | Default | Meaning |
|---|---|---|
store |
required | The IdempotencyStore. |
ttlSec |
86400 (24 hours) |
How long accepted and rejected records are kept. Set it longer than your longest job retry window. Unknown records are kept until you delete them. |
staleReservationSec |
300 |
Age after which an unfinished reservation counts as an unknown outcome. Set it longer than timeoutMs times the number of requests one send can make. |
Reconcile an unknown outcome
When a send throws HandoffUnknownError, the store records the key as unknown, and every later send with that key throws again. To resolve it:
- Look the message up with the provider: its console, its message list API, or a status webhook for that recipient around that time.
- If the provider has the message, stop. It went out.
- If it does not, delete the record and send again.
import { memoryIdempotencyStore } from "@opencoredev/sms-sdk";
const store = memoryIdempotencyStore();
// After confirming with the provider that the message was never created:
await store.delete("order:123:shipped:v1");
delete exists on the memory store. Your own store needs some way for an operator to clear a key.