Idempotency
Every request that creates a record carries an Idempotency-Key, so a retry after a timeout never creates a second record. This guide explains the header and how Mikono answers a repeated key.
The Idempotency-Key header
- Every
POSTthat creates a record requires anIdempotency-Keyheader of 1 to 255 characters. Without it the call returns400 idempotency_key_required. - On
PATCHandPUTthe header is optional. Send it when you retry those calls too. - Use one key per logical request: a UUID, or a value your system already has, such as your own document number. Send the same key, with the same body, on every retry of that request.
- Store the key with the outgoing record before the first send. If your process stops after sending, or the response is lost, the retry reads the stored key and Mikono replays the first result. A key generated again on retry is a new request, and creates a second record.
- Keys are kept per API key for 24 hours.
POST /api/v1/{resource} HTTP/1.1
Authorization: Bearer mk_test_…
Content-Type: application/json
Idempotency-Key: 4b6f1f0e-5c1d-4a43-9d7e-0f3d8f0a2b61How Mikono answers a repeated key
| Situation | Response |
|---|---|
| Same key and same body, after the first request finished | The first response again, status and body, with the header Idempotent-Replayed: true. Nothing new is created. |
| Same key with a different body | 422 idempotency_key_reused. Nothing is created. |
| Same key while the first request is still running | 409 idempotency_in_progress with Retry-After: 1. Retry with the same key. |
The key is checked and stored in the same database transaction as the record it creates. A failure therefore leaves either both or neither, and a 500 server_error is always safe to retry with the same key.
Retrying safely
The helpers below take the key from the caller, which generated and stored it before the first send. They retry with that same key when the request fails in transit (a timeout or a dropped connection, where you cannot tell whether Mikono received it), and on 409 idempotency_in_progress, 429 and 5xx, waiting for Retry-After when the response has one. Do not retry other 4xx responses; fix the request instead.
When the helper gives up, the outcome is unknown, not failed. Leave the record marked as unsent with its key, and send it again later with the same key.
// Send one logical create. `idempotencyKey` is generated by the caller ONCE per
// record and stored with that record BEFORE the first send, for example
// `order-${order.number}` or a UUID saved on the outgoing row. After a crash or a
// lost response, calling this again with the stored key replays the first result
// instead of creating a second record.
export async function postIdempotent(path, body, idempotencyKey, attempts = 6) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt++) {
let response;
try {
response = await fetch(`${process.env.BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
// Network failure or timeout: Mikono may or may not have the request.
// Retrying with the same key is safe either way.
lastError = error;
}
if (response) {
if (!(await isRetryable(response))) return response;
lastError = new Error(`HTTP ${response.status} (${response.headers.get("x-request-id")})`);
}
if (attempt < attempts) await sleep(retryDelayMs(response, attempt));
}
throw new Error(`Not confirmed after ${attempts} attempts. Retry later with Idempotency-Key ${idempotencyKey}.`, {
cause: lastError,
});
}
async function isRetryable(response) {
if (response.status === 409) {
const problem = await response.clone().json().catch(() => null);
return problem?.code === "idempotency_in_progress";
}
return response.status === 429 || response.status >= 500;
}
function retryDelayMs(response, attempt) {
const retryAfter = Number(response?.headers.get("retry-after"));
if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1000;
return Math.min(2 ** attempt, 60) * 1000; // 2, 4, 8 … seconds, at most one minute
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Usage: the key is saved with the order first, then sent.
// const key = await saveIdempotencyKey(order.id, `order-${order.number}`);
// const response = await postIdempotent("/{resource}", payload, key);Natural keys
Some writes carry a key of their own, and that key outlasts the 24-hour window:
- An upsert by external code,
PUT /{resource}/by-code/{system}/{code}, creates the record the first time and updates it after that. A retry with a new Idempotency-Key still finds the same record. See External codes. - A DMS document is keyed by its document number for good. Sending it again returns its existing flag and posts nothing. See DMS integration.