Handle errors and retries
Decide which answers to retry and which to fix, back off with jitter, honour Retry-After and log the request_id.
Every error of the API is an RFC 9457 Problem (application/problem+json) with a stable code and a
request_id. Some errors go away when you wait and try again; most need a change on your side. This guide
sorts them and shows a small helper that retries the right ones. The codes are listed in
Errors.
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"code": "rate_limited",
"detail": "Too many attempts. Try again later.",
"request_id": "8b1e6f03c2d94a7e5f0a1b2c"
}The request_id is also in the X-Request-Id header of every response, including successful ones.
Retry or fix?
| Answer | Retry? | What to do |
|---|---|---|
429 rate_limited | Yes, always | Wait the number of seconds in Retry-After. The request was refused before it did anything. |
500 internal_error, 502, 503, 504 | GET and updateCondition: yes, with backoff. createLifecycleEvent: not automatically | Back off and try again; if it persists, contact support with the request_id. |
| Network error or timeout | Same as 5xx | You do not know whether the request arrived. |
400 validation_error | No | Fix the request; errors[] names each field (field, code, message). |
401 unauthenticated | No | The key is missing, unknown or revoked. Alert a person instead of looping. |
403 forbidden | No | The key lacks the operation's scope (read, condition or import). |
404 not_found | No | Wrong id, or the resource belongs to another organisation. |
409 conflict | No | The resource is in a state that does not allow this, e.g. a QR code before the identifiers are locked, or condition data for a unit of an archived model. |
Why the difference between methods: GET changes nothing, and updateCondition skips a measurement it already
has, so sending the same body again is harmless. createLifecycleEvent records a new event on every successful
call; after a timeout, check listLifecycleEvents
before you send it again. Details in Retries and idempotency.
Do not retry 401 in a loop. Unknown and revoked keys are limited to 30 attempts per 15 minutes per client
address, so a misconfigured job soon gets 429 instead of 401, which hides the real cause.
Back off with jitter
When an answer is worth retrying:
- If
Retry-Afteris present, wait that many seconds. - Otherwise wait a random time between 0 and
min(30 s, 0.5 s × 2^attempt)("full jitter"), so many clients do not retry in lockstep. - Give up after a few attempts (five is plenty) and report the error with its
request_id. - Set a client timeout (for example 30 seconds) so a hanging connection counts as a network error.
In batch jobs, a 429 means the whole organisation is over its limit of 600 requests per minute (all keys
together). Slow the job down instead of only retrying the single request; see Rate limits.
A helper for every call
The helper sends a request, returns the parsed JSON, retries what is worth retrying and raises an error that carries the Problem. Each version runs on its own; the last lines show its use.
// batterypass.mts: npx tsx batterypass.mts
const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const KEY = process.env.BATTERYPASS_API_KEY;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export class ApiError extends Error {
status: number;
problem: { code?: string; detail?: string; request_id?: string; errors?: unknown[] };
constructor(status: number, problem: ApiError['problem']) {
super(`${status} ${problem.code}: ${problem.detail} (request_id ${problem.request_id})`);
this.status = status;
this.problem = problem;
}
}
/** Sends a request; retries 429 always, and 5xx or network errors unless the method is POST. */
export async function call<T>(method: string, path: string, body?: unknown, maxAttempts = 5): Promise<T> {
for (let attempt = 1; ; attempt++) {
let res: Response | undefined;
try {
res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
if (method === 'POST' || attempt === maxAttempts) throw err; // network error or timeout
}
if (res?.ok) return (await res.json()) as T;
const retryable = !res || res.status === 429 || (res.status >= 500 && method !== 'POST');
if (res && (!retryable || attempt === maxAttempts)) {
const requestId = res.headers.get('X-Request-Id') ?? undefined;
const problem = await res.json().catch(() => ({ request_id: requestId }));
throw new ApiError(res.status, problem);
}
// Retry-After (seconds) wins; otherwise exponential backoff with full jitter, at most 30 s.
const retryAfter = Number(res?.headers.get('Retry-After'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * Math.min(30_000, 500 * 2 ** attempt));
}
}
// Usage
try {
const page = await call<{ items: { serial_number: string }[] }>('GET', '/batteries?limit=10');
console.log(page.items.map((u) => u.serial_number));
} catch (err) {
if (err instanceof ApiError) console.error(err.message); // log the request_id, never the key
else throw err;
}What to log
| Log | Never log |
|---|---|
| Operation, method and path | The Authorization header or the key |
HTTP status and code | The contents of JSON or PDF exports (they include restricted data) |
request_id (or X-Request-Id) | |
errors[] of a validation_error |
When you contact Support, quote the request_id: it finds the request in the
server logs without anyone needing your key.