Errors, retries & idempotency
Error codes, safe retries, idempotency keys, and rate limits.
Errors are typed and carry a machine-readable code plus a retryability signal. Treating every failure as retryable — or as a negative business result — is the fast path to duplicate charges and wrong decisions.
Error shape
Failures return a JSON error body, and some carry retry metadata.
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after the interval in Retry-After."
},
"retryable": true,
"retryAfterSeconds": 30
}{
"error": {
"code": "STORAGE_UNAVAILABLE",
"message": "No successful save could be confirmed."
},
"retryable": true
}Codes
| Field | Type | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed, expired, or revoked key. Reissue a key. |
SCOPE_FORBIDDEN | 403 | The key lacks the operation's scope. |
ENVIRONMENT_FORBIDDEN | 403 | The key belongs to the other environment. |
INVALID_INPUT | 400 | The input, freshness policy, or source capability is invalid for this request. |
IDEMPOTENCY_CONFLICT | 409 | The key was reused with a different payload, or the operation is still unfinished. Read the code before retrying. |
RESULT_EXPIRED | 410 | The saved result expired or was source-suppressed. |
RATE_LIMITED | 429 | Admission backpressure. Honour Retry-After, add jitter, retry with the same key. |
QUOTA_EXCEEDED | 429 | The monthly allowance is used. Wait for the next UTC monthly reset or review the confirmed entitlement. Top-up purchases are unavailable in this release. |
INTERNAL_ERROR | 500 | Unexpected server error. No assumed result. |
ACCESS_UNAVAILABLE | 503 | Access checks are temporarily down. Nothing was performed. |
STORAGE_UNAVAILABLE | 503 | No successful save could be confirmed. Treat as an interrupted operation. |
Idempotency
Every write operation accepts an Idempotency-Key header: 8–128 characters matching [A-Za-z0-9][A-Za-z0-9._:-]*. One key per logical check, not per HTTP attempt. A replay of the same key and payload returns the original operation and settles at zero units.
- Generate one key per logical check
A random UUID is enough. Persist it with your own record before you send, so a retry after a crash can reuse it.
- Reuse it only for the same payload
Reusing a key with a different payload is
409. A new check gets a new key. - Read 409 and 410 before retrying
These are not blind retries. Inspect the code: a conflict, or an expired or source-suppressed result, needs a decision.
const idempotencyKey = crypto.randomUUID(); // persist this before sending
async function callWithRetry(body: unknown) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("/api/v2/validations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VAT_TOOLS_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.ok) return response.json();
if (response.status !== 429) return response.json(); // 400/401/403/409/410 need a decision
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
const jitter = Math.random() * 400;
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 + jitter));
}
throw new Error("VAT Tools request did not settle");
}Rate limits and quota
Accepted responses carry RateLimit-Limit, RateLimit-Remaining (when the counter is readable), and RateLimit-Reset in seconds. Two 429s mean different things:
RATE_LIMITEDis admission backpressure. Wait forRetry-After, add jitter, and reuse the idempotency key.QUOTA_EXCEEDEDis the monthly allowance. Stop, surface the state, and let the next UTC monthly reset or a review of the confirmed entitlement resolve it. Top-up purchases are unavailable in this release. Never auto-overage.