# idempotency

repeating a request must not repeat its effect.

## what happens when it runs twice

an order with a payment processed twice charges the customer twice. that case
has one "good" trait: someone notices. there is a customer complaining. the
problem is expensive, but it is visible.

now take a button that toggles a user's access. the request goes out twice. the
server enables it, and right after disables it. the system simply ended up in
the wrong state, and it will stay that way until someone calls saying they
cannot log in (in the best case, but in a critical system it will not be that
simple).

the second case is worse than the first. a loud failure you fix on the spot; a
silent one you only find out about when the problem is already too big.

and that is why idempotency is not a subject for fintechs only, it is a subject
for any application.

## it is not the fault of whoever clicked

the natural reaction is to treat this as sloppiness on the front end: disable
the button, add a debounce, done. but the repetition may or may not come from
there.

when the client sends a `POST` and the response times out, it is left without
knowing what happened. the server may not have received it. it may have received
it and failed. it may have processed the whole thing and the response got lost
on the way back. all three cases reach the client looking exactly the same: a
timeout.

given that, retrying is the natural decision, and the responsibility for
withstanding the repetition belongs to the server. that is what idempotency
solves.

and even if the user clicks only once, the browser may send several. look at
this code, it is the most common way it happens:

```js
function setupCheckout() {
  // we are stacking this listener on the #pay button
  document.querySelector('#pay').addEventListener('click', () => {
    fetch('/api/payments', {
      method: 'POST',
      body: JSON.stringify(order),
    });
  });
}

setupCheckout();

cart.on('change', () => {
  renderCart();
  setupCheckout(); // the previous listener is still alive
});
```

every change to the cart registers one more listener on the same button, and
none of the previous ones were removed. after three changes, a single click
fires four `POST`. the user did everything right.

notice that the bug depends on `addEventListener` sitting inside something that
runs again: a re-render, a `fetch` that reloads the screen, a setup function
called more than once.

## the definition

according to MDN Web Docs:

> An HTTP method is idempotent if the intended effect on the server of making a
> single request is the same as the effect of making several identical requests.

## the simple model

with a front end, an api and a database, nothing beyond that:

1. the client generates a key and sends it in the header, something like
   `Idempotency-Key: 7c1f...`
2. the api checks whether a response is already stored for that key
3. if it is, it returns the same response, without processing again
4. if it is not, it processes, stores key and response together, and returns

on the first time nobody finds anything and the work happens:

```
 front                    api                        db
   │                       │                          │
   │ POST /payments        │                          │
   │ Idempotency-Key: 7c1f │                          │
   ├──────────────────────>│                          │
   │                       │ select where key = 7c1f  │
   │                       ├─────────────────────────>│
   │                       │          empty           │
   │                       │<─────────────────────────┤
   │                       │                          │
   │                       │ charges the card         │
   │                       │ insert (7c1f, response)  │
   │                       ├─────────────────────────>│
   │      201 created      │                          │
   │<──────────────────────┤                          │
```

on the second time the key is already there and nothing is processed:

```
 front                    api                        db
   │                       │                          │
   │ POST /payments        │                          │
   │ Idempotency-Key: 7c1f │  (the same as before)    │
   ├──────────────────────>│                          │
   │                       │ select where key = 7c1f  │
   │                       ├─────────────────────────>│
   │                       │    stored response       │
   │                       │<─────────────────────────┤
   │      201 created      │                          │
   │<──────────────────────┤   no charge at all       │
```

who generates the key? it has to be the client, because only it knows that the
second attempt is the same intent and not a new request. the server has no way
to tell whether the user wanted to pay again or whether the response got lost
and they tried once more.

in other words, the key identifies the intent, not the bytes of the request. a
new attempt at the same intent reuses the key. a new intent requires a new key.

## the key, on the client side

the browser already generates this without any library:

```js
const idempotencyKey = crypto.randomUUID();
```

`crypto.randomUUID()` returns a UUID v4, which is the format the industry
adopted. stripe, for instance, accepts any string up to 255 characters, but UUID
is the de facto standard because it has enough entropy never to collide and
carries no information at all about the order.

it is a good idea to prefix the key with context, because a bare UUID in a log
says nothing:

```
user42-payments-9f3c1b7e2a4d...
```

the prefix is there to make our investigations easier, not for the system. when
the double charge shows up on a sunday night, `user42-payments-...` tells you
who and where in a single `grep`, and the bare UUID tells you that a key exists.

but a prefix is not a security boundary. `user{id}-payments-...` is predictable,
and in a model that returns a stored response by key, a guessed key becomes
someone else's response (you do not want that). what protects you is the server,
always scoping the lookup by whoever is authenticated:

```sql
select * from idempotency_keys
where user_id = :authenticated_user and key = :key
```

with that, user A's key never matches user B's record, even if they get the
whole string right. the `user42-` in the prefix goes back to being what it
should be: a help for us developers, or even for support.

what really matters is where the key is born. it belongs to the intent, so it is
generated once, when the user decides to pay, and reused on every attempt:

```js
const idempotencyKey = crypto.randomUUID();

async function pay(order, attempt = 0) {
  const res = await fetch('/api/payments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey, // the same on every attempt
    },
    body: JSON.stringify(order),
  });

  if (!res.ok && attempt < 3) return pay(order, attempt + 1);
  return res.json();
}
```

the mistake that cancels all of it is generating the key inside the call:

```js
const res = await fetch('/api/payments', {
  method: 'POST',
  headers: {
    'Idempotency-Key': crypto.randomUUID(), // wrong
  },
  body: JSON.stringify(order),
});
```

here every attempt becomes a new intent in the server's eyes. the header is
there, the backend implementation is correct, and the customer is still charged
once per retry. it is the kind of bug that passes code review because the line
looks right.

## same key, different body

what if the same key arrives with a completely different payload?

it happens through a client bug, through a key generated once and reused by
accident, or through someone probing your api. the way described above, the
server would return the old response silently, and the second request would just
vanish without any error.

the solution is to store a hash of the body alongside the key. same key and a
different hash means it is not the same intent, and the request is rejected (or
not, you can also just warn if that makes sense for your application).

it is computed from the body as it arrived, before anything parses it:

```js
import { createHash } from 'node:crypto';

const bodyHash = createHash('sha256').update(rawBody).digest('hex');
```

note `rawBody`, not the parsed object. `JSON.stringify` is not canonical:
`{"a":1,"b":2}` and `{"b":2,"a":1}` are the same payload and produce different
strings, so hashing the parsed object would reject a client that merely ordered
its keys differently. hash the bytes that came off the wire.

with that in hand the check has four outcomes instead of two:

```
row = select ... where user_id = :user and key = :key

  no row                              -> process, insert, return
  row and hash matches                -> replay row.response
  row and hash does not match         -> 422, this key is not yours to reuse
  row match but hash does not match   -> you decide

```

## what this model does not cover

it has a window, and it shows up when you put the two side by side:

```
 time     request A                      request B
   │
   1      select 7c1f  ->  empty
   │                              ┐
   2                              │      select 7c1f  ->  empty
   │                              │
   3      charges the card        │  the window: A has not written yet,
   │                              │  so B does not find anything either
   4                              │      charges the card
   │                              │
   5      insert (7c1f, ...)      ┘
   │
   6                                     insert (7c1f, ...)
   │
   ▼      two charges, and the key worked exactly as designed
```

the interval between checking and writing is where both slip through. writing
faster does not help, and checking again before the insert does not help either:
however small it gets, the window keeps existing, because these are two separate
operations.

this model covers repetition separated in time, which is the timeout and retry
case. it does not cover simultaneous requests, which is the case of the stacked
listener firing four `POST` at the same instant.

solving that requires a second layer, and it is in
[idempotency middleware](../../backend/laravel/idempotency-middleware.md).
