Concurrency and correctness
Handle external I/O and retries
Expect interleaving around fetch calls and make outbound side effects safe to repeat after failure.
A Durable Object gives you serialized access to its own storage. The moment your method calls an external API, you leave that safe zone. While the object awaits network I/O, another event can run. And a retry can repeat the external side effect.
Timeouts are the sharpest edge. A charge request that times out has three possible truths. The provider never received it. It received it and failed. It received it and succeeded, and the response got lost. Your code can’t tell which. Assume “timeout means nothing happened” and you will eventually charge someone twice.
Persist intent before acting
The pattern: write down the operation and its state before the call, pass an idempotency key to the provider, then record completion.
async charge(orderId, amountCents) {
const operationId = `charge-${orderId}`
const claimed = this.ctx.storage.sql.exec(
`insert into operations (id, state, amount_cents)
values (?, 'pending', ?)
on conflict (id) do nothing`,
operationId, amountCents
).rowsWritten
if (claimed === 0) return this.operationStatus(operationId)
The insert claims the operation exactly once. A duplicate delivery of the same order finds the row already there and reads its status instead of charging again.
Then make the provider deduplicate too:
try {
await fetch('https://api.pay.example.com/charges', {
method: 'POST',
headers: { 'Idempotency-Key': operationId },
body: JSON.stringify({ amount: amountCents }),
signal: AbortSignal.timeout(10_000),
})
this.ctx.storage.sql.exec(
`update operations set state = 'confirmed' where id = ?`, operationId)
} catch (e) {
this.ctx.storage.sql.exec(
`update operations set state = 'uncertain' where id = ?`, operationId)
}
}
The Idempotency-Key header means that even if your retry reaches the provider twice, it charges once. Serious payment APIs support this. If yours doesn’t, the operation ID in your storage is the only guard you have.
Uncertain is a real state
Notice the failure path writes uncertain, not failed. A timeout is not a failure. It’s a question you haven’t answered yet.
So reconcile later instead of guessing. An alarm works well here: it queries the provider by idempotency key and settles the row to confirmed or failed based on what happened.
Try this on paper: design a payment-like call that gets delivered twice and times out once, without charging twice. Walk the three-state table through both events. If every path ends with one charge and a settled row, the design holds.
Lesson completed