Idempotency and concurrency
This page describes how to repeat a request without applying it twice when the network drops (idempotency, that is, replay safety), and how to handle two clients changing the same resource at the same time with optimistic version checking (optimistic locking). Use these two rules on every call that grants money, items, or rewards.
Idempotency-Key
A modifying request (POST, some PUT/PATCH/DELETE) can or must carry the Idempotency-Key header. The key’s rules:
| Rule | Value |
|---|---|
| Length | 1 to 200 characters (cannot be empty) |
| Character set | Printable ASCII without whitespace (the SDK generates and accepts only this) |
| Recommended value | A random UUID (v4); some endpoints require UUID format (clan, group, and tournament endpoints: it must be a valid UUID) |
| Scope | In core commands the key is unique across the environment: if two different callers in the same environment use the same key, they collide. In plugin commands the key is per plugin and caller (player or server). Therefore always use a random UUID, and do not use predictable values such as player-1 |
| Record retention | 24 hours (command records); in plugin commands the operation’s receiptRetentionSeconds value (86,400 s for now) |
The endpoint reference shows on which endpoint the key is required and on which it is optional. Example: it is optional on character commands, and required on wallet transfers, inventory and item operations, trading, store, and plugin commands.
How the key works
- The first request arrives with the key; the server applies the operation and stores the result bound to the key.
- If the same key + the same content arrives again, the operation is not applied again; the recorded result is returned. Some responses signal this with the
replayed: truefield (character commands, store purchase). - The same key + different content gets
422andidempotency mismatch(idempotency_mismatchin plugins). - If the first request is still being processed, the second gets
409(idempotent request in progressoridempotency_in_flight); try again shortly with the same key. - If the record retention has expired,
410is returned. - In core commands the key is “burned” only on success: an authorization rejection (for example a server command with a client key), a validation error, or a rolled-back operation does not consume the key; you can fix it and resend with the same key.
Warning
Do not repeat a request with an uncertain outcome with a new key. A new key means a new operation and creates a double reward or double spend. Generate the key once, store it (so that it stays the same across the request and the retries), and query the result if needed.
When to generate a new key
- When the user deliberately restarts the same operation (for example a second store purchase).
- When you change the state and retry after a business rule rejection (
422). - After
410(the key expired), after reading the state and making sure the operation was really not applied.
Example: a safe retry
The helper below generates the key once, retries with the same key on network errors and on 408, 429, 5xx responses, and waits out Retry-After:
/** `Retry-After` değerini milisaniyeye çevirir; saniye ya da HTTP tarihi olabilir, bilinmiyorsa `null`. */
function retryAfterMs(header: string | null): number | null {
if (header === null) return null;
if (/^\d+$/.test(header)) return Number(header) * 1000;
const date = Date.parse(header);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
/**
* Bir değiştirici isteği AYNI `Idempotency-Key` ile güvenle yeniden dener.
* Anahtarı bir kez üretin ve her denemede aynen gönderin; yeni anahtar yeni işlem demektir.
* 429 ve 503'te sunucunun `Retry-After` süresini bekler; yoksa üstel geri çekilme kullanır.
*/
export async function postWithRetry(url: string, body: unknown, headers: Record<string, string>, maxAttempts = 4): Promise<Response> {
const idempotencyKey = crypto.randomUUID();
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response: Response | undefined;
try {
response = await fetch(url, {
method: "POST",
credentials: "include",
headers: { ...headers, "content-type": "application/json", "idempotency-key": idempotencyKey },
body: JSON.stringify(body),
});
} catch (error) {
lastError = error; // ağ hatası: istek uygulanmış olabilir, aynı anahtarla tekrar güvenlidir
}
if (response !== undefined) {
if (!RETRYABLE_STATUS.has(response.status)) return response; // başarı ya da kalıcı hata
lastError = new Error(`HTTP ${response.status}`);
if (attempt === maxAttempts) return response;
const wait = retryAfterMs(response.headers.get("retry-after")) ?? Math.min(200 * 2 ** (attempt - 1), 5000);
await new Promise((resolve) => setTimeout(resolve, wait));
continue;
}
if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, Math.min(200 * 2 ** (attempt - 1), 5000)));
}
throw lastError;
}
The TypeScript SDK does this for you: CrxClient (server key) retries GET requests and requests that carry an Idempotency-Key on the 408, 429, 500, 502, 503, 504 statuses (default 2 attempts, 0 to 5 with maxRetries) with exponential backoff and respecting Retry-After. On plugin commands, PlayerClient generates the key at preparation time and writes it to the browser journal; even if the page is refreshed, it continues with the same key (bindings).
Querying the result (plugin commands)
For a plugin command, if you never received the response, query the result with GET …/commands/{message_type}/result?idempotency_key=… without resending. Only on a 404 (no record) response resend with the same key (calling plugins). There is no separate query endpoint for core commands; resending with the same key and body returns the same recorded result.
Optimistic version checking
The server keeps an increasing version (version) on modifiable resources (character, inventory, plugin resource, store listing). If you say in your request “I saw this version and made my decision”, and in the meantime someone else changed the resource, the request is not applied and a conflict is returned. This way two clients do not silently overwrite each other’s change.
| Where used | How it is sent |
|---|---|
Character commands (POST /v1/characters/{id}/commands) | If-Match: 3 (a quoted "3" is also accepted) |
Inventory and item operations (endpoints that support If-Match) | If-Match |
| Store administration endpoints (page, listing changes) | If-Match (optional) |
| Plugin commands (HTTP) | If-Match: "12" |
Plugin commands (realtime Command frame) | expectedVersion: "12" (decimal text) |
Player purchase (/v1/player/store/listings/{id}/buy) | If-Match is not used; the listing version is the expected_version field in the body (if you send the header, 400) |
The If-Match format: an integer version number. * and weak validators (W/"3") are rejected (400); a negative number and a non-numeric value get 400. If the header is not given, no version check is made (the last writer wins); we recommend sending it on critical resources such as money and items.
When a conflict comes
| Endpoint family | Response |
|---|---|
| Core REST | 409 and version conflict: expected 3, actual 5 |
| Plugin | 409 and {"code":"conflict","message":"version conflict: expected 3, actual 5",…} |
| Store player purchase | 409 and {"code":"listing_changed",…} |
| Realtime command | An Error frame InvalidPayload (retryable: false); the detail is not given over the wire |
The resolution loop:
- Re-read the resource (with the current
version). - Decide whether the user’s intent is still valid (for example whether there is still enough balance).
- Send again with a new key (this is a new attempt) with the current version.
Note: a version conflict is not a business rule but a result of concurrency; blindly sending the same request again will conflict again. The idempotency key is for repetition, and version checking is for conflict; you can use both together: a repeat sent with the same key + the same If-Match returns the recorded result.
Summary rule list
- Add a key to every request that grants money, items, rewards, or sends a command; generate the key once and do not change it on retries.
- Query an uncertain outcome or repeat it with the same key; never with a new key.
- When writing a shared resource, send
If-Match/expectedVersion; on409, re-read. 422is a business rule rejection; repeating does not change the outcome.- For the detailed error table, see the error reference.