Skip to content

feat: option to persist and replay selected non-2xx responses #178

Description

@paveg

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:

  1. Client A sends POST /bookings with key K, loses the race, gets 409. The record for K is deleted.
  2. A's connection times out before the 409 arrives, so A retries with K.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions