Summary
The middleware deletes the idempotency record whenever the handler returns a non-2xx response, so a client that retries after receiving, say, a 409 re-runs the handler instead of getting the same 409 replayed. For some APIs that is exactly right. For others the non-2xx is the durable outcome of the request, and re-running the handler is the thing idempotency was supposed to prevent. An opt-in to persist selected non-2xx responses would cover the second case.
Where this happens
dist/index.js (v0.9.1), after next() resolves:
const res = c.res;
if (!res.ok) {
await store.delete(storeKey);
return;
}
and the store contract in types.d.ts documents the same intent:
complete() — Called after the handler returns a 2xx response.
delete() — Called when the handler throws or returns non-2xx. Allows the client to retry with the same key.
Why it matters
Consider a booking endpoint where the handler runs a single-statement conditional INSERT that returns 0 rows when a slot has just been taken by someone else, and the route answers 409. The request was well-formed; the 409 is the final answer for that key. With the current behaviour:
- Client A sends
POST /bookings with key K, loses the race, gets 409. The record for K is deleted.
- A's connection times out before the 409 arrives, so A retries with K.
lock() succeeds (K no longer exists), the handler runs the guard again, and — if the slot has since freed up — A now holds a booking it believes failed.
The middleware's own README frames the goal as "the same request produces the same result", and this is the one path where it does not.
I want to be clear that this is a design choice, not a bug: treating 4xx/5xx as "client, please fix and retry" is the common convention and the right default. The ask is narrower.
Proposal
An option — name is bikesheddable — that tells the middleware which non-2xx statuses are terminal for a key:
idempotency({
store,
// Persist and replay these statuses instead of deleting the key.
persistStatuses: [409, 422],
})
or a predicate, persistResponse: (res: Response) => boolean, which generalises the same thing.
Semantics I would expect:
- Statuses in the list go through
complete() exactly like a 2xx, and are replayed on the next request with the same key and fingerprint.
- Statuses not in the list keep today's behaviour (
delete()).
- Default stays
[], so nothing changes for existing users.
Alternatives I considered
- Handle it in the route: return the 409 as a
200 { ok: false } envelope. This works but forces a non-RESTful response shape onto every consumer just to satisfy the middleware, and it is what I would like to avoid.
- A custom store that ignores
delete(): this breaks the "throw → delete → allow retry" path that should stay retriable, so it is not a clean workaround.
Happy to send a PR if the direction is acceptable. Thanks for the library — the d1Store INSERT OR IGNORE lock is exactly the primitive I needed.
Summary
The middleware deletes the idempotency record whenever the handler returns a non-2xx response, so a client that retries after receiving, say, a 409 re-runs the handler instead of getting the same 409 replayed. For some APIs that is exactly right. For others the non-2xx is the durable outcome of the request, and re-running the handler is the thing idempotency was supposed to prevent. An opt-in to persist selected non-2xx responses would cover the second case.
Where this happens
dist/index.js(v0.9.1), afternext()resolves:and the store contract in
types.d.tsdocuments the same intent:Why it matters
Consider a booking endpoint where the handler runs a single-statement conditional
INSERTthat returns 0 rows when a slot has just been taken by someone else, and the route answers 409. The request was well-formed; the 409 is the final answer for that key. With the current behaviour:POST /bookingswith key K, loses the race, gets 409. The record for K is deleted.lock()succeeds (K no longer exists), the handler runs the guard again, and — if the slot has since freed up — A now holds a booking it believes failed.The middleware's own README frames the goal as "the same request produces the same result", and this is the one path where it does not.
I want to be clear that this is a design choice, not a bug: treating 4xx/5xx as "client, please fix and retry" is the common convention and the right default. The ask is narrower.
Proposal
An option — name is bikesheddable — that tells the middleware which non-2xx statuses are terminal for a key:
or a predicate,
persistResponse: (res: Response) => boolean, which generalises the same thing.Semantics I would expect:
complete()exactly like a 2xx, and are replayed on the next request with the same key and fingerprint.delete()).[], so nothing changes for existing users.Alternatives I considered
200 { ok: false }envelope. This works but forces a non-RESTful response shape onto every consumer just to satisfy the middleware, and it is what I would like to avoid.delete(): this breaks the "throw → delete → allow retry" path that should stay retriable, so it is not a clean workaround.Happy to send a PR if the direction is acceptable. Thanks for the library — the
d1StoreINSERT OR IGNORElock is exactly the primitive I needed.