Skip to content

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.

429
{
  "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?

AnswerRetry?What to do
429 rate_limitedYes, alwaysWait the number of seconds in Retry-After. The request was refused before it did anything.
500 internal_error, 502, 503, 504GET and updateCondition: yes, with backoff. createLifecycleEvent: not automaticallyBack off and try again; if it persists, contact support with the request_id.
Network error or timeoutSame as 5xxYou do not know whether the request arrived.
400 validation_errorNoFix the request; errors[] names each field (field, code, message).
401 unauthenticatedNoThe key is missing, unknown or revoked. Alert a person instead of looping.
403 forbiddenNoThe key lacks the operation's scope (read, condition or import).
404 not_foundNoWrong id, or the resource belongs to another organisation.
409 conflictNoThe 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:

  1. If Retry-After is present, wait that many seconds.
  2. 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.
  3. Give up after a few attempts (five is plenty) and report the error with its request_id.
  4. 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

LogNever log
Operation, method and pathThe Authorization header or the key
HTTP status and codeThe 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.

On this page