Retrying safely after a timeout
A timeout tells you nothing about whether the work happened. This is how you find out without doing it twice.
Three operations spend money or dial a stranger, and for those a timeout is the dangerous case — not because it failed, but because you cannot tell whether it did.
| Operation | Idempotency-Key |
|---|---|
POST /v1/numbers/purchase | Required — 400 without it |
POST /v1/campaigns/{id}/launch | Required — 400 without it |
POST /v1/calls | Optional, strongly recommended |
The rule
Generate a key per logical operation, not per attempt. Retry with the same key.
const key = crypto.randomUUID(); // once, outside the retry loop
async function buy(e164) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API}/v1/numbers/purchase`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.TONE_KEY}`,
'content-type': 'application/json',
'idempotency-key': key, // ← the SAME key every attempt
},
body: JSON.stringify({ e164 }),
});
if (res.ok) return res.json();
const { error } = await res.json();
if (error.code === 'idempotency_key_in_use') { // 409 — first one still running
await sleep(1000 * 2 ** attempt);
continue;
}
if (res.status >= 500 || error.code === 'provider_unavailable') {
await sleep(1000 * 2 ** attempt);
continue;
}
throw new Error(`${error.code}: ${error.message}`);
}
}Persist the key alongside whatever prompted the operation. A key held only in memory is gone exactly when the process crashed mid-request — the one case it existed for.
What each response means
2xx with Idempotent-Replayed: true | The first attempt succeeded. This is its stored response — not new work |
409 idempotency_key_in_use | The first attempt is still running. Wait and retry |
422 idempotency_key_reused_with_different_params | Same key, different body. Almost always a bug: a key reused across two genuinely different operations |
503 provider_unavailable | 🔴 The carrier's answer left the outcome unknown — not "it failed". Retry with the same key. A fresh key risks buying two numbers |
Keys are remembered for 24 hours. A handler that throws releases its key, so a genuine error can be retried after fixing the request.
Where it does not help
Idempotency protects the operation, not your bookkeeping. If you generate a new key because your own record of the first attempt was lost, you get two numbers and two rentals. The durable key is the whole mechanism.