# Idempotency

> Retry any write safely with an Idempotency-Key.

Source: https://docs.delivr.no/conventions/idempotency/

Networks fail. A request can time out after Delivr has booked the delivery, and a naive retry would book a second one. Idempotency keys make retries safe.

Every write except `POST /v1/merchant/quotes` needs one `Idempotency-Key` header with a UUID. Without one, with two, or with one that is not a UUID, you get `400 idempotency_key_required`.

```bash
curl -X POST https://driver-staging-api.delivr.no/v1/merchant/orders \
  -H "Authorization: Bearer $DELIVR_API_KEY" \
  -H "Idempotency-Key: 0b9f0c4e-6f1d-4a5e-9a3c-2d7e8f1a6b42" \
  -H "Content-Type: application/json" \
  -d @order.json
```

## Rules

Make **one key per logical action**, store it with the action, and send the same key on every retry of it. A new key per HTTP attempt defeats the point.

| You send                                                                                                            | You get                                                              |
| ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| The same key and the same request (method, path and JSON body; key order and spacing do not matter) within 24 hours | The first successful answer again, with `Idempotent-Replayed: true`. |
| The same key with a different request                                                                               | `422 idempotency_key_reused`.                                        |
| The same key while the first request is still running                                                               | `409 idempotency_key_in_flight`. Retry shortly with the same key.    |
| A retry after the first attempt was refused or failed                                                               | A fresh attempt: refused and failed requests free the key.           |

## Built-in idempotency for orders

On top of the key, placing an order is idempotent by `quoteId`, or by `externalRef` when there is no quote. Even with a new key, a second placement for the same quote answers the order already placed.

## Retry strategy

- Retry network errors and timeouts, `409 idempotency_key_in_flight`, `429`, `500` and `503` with the **same key**, with exponential backoff and jitter.
- Respect `Retry-After` on `429`.
- Do not retry other `4xx` answers unchanged: fix the request first.

## Example

A Node.js helper (Node 18 or later) that does all of the above. Create the key once, store it with your own record before the first attempt, and pass the same key whenever you retry the action, even after a restart.

```js
const RETRY_CODES = new Set(["idempotency_key_in_flight", "rate_limited", "internal", "unavailable"]);

export async function delivrWrite(method, path, body, idempotencyKey, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    let res, data;
    try {
      res = await fetch(process.env.DELIVR_API_URL + path, {
        method,
        headers: {
          Authorization: `Bearer ${process.env.DELIVR_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      });
      data = await res.json();
    } catch (err) {
      if (attempt >= attempts) throw err; // network error, timeout or cut-off body: the same key is safe to retry
      await sleep(backoff(attempt));
      continue;
    }
    if (!RETRY_CODES.has(data.error?.code) || attempt >= attempts) return { status: res.status, data };
    const retryAfter = Number(res.headers.get("Retry-After"));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
```

```js
// One key per order placement, saved before the first attempt.
order.delivrPlaceKey ??= crypto.randomUUID();
await db.orders.save(order);
const { status, data } = await delivrWrite("POST", "/v1/merchant/orders", placeRequest, order.delivrPlaceKey);
```