AnswerLine Start free

, Engineering · Tutorials

API errors and retries: a policy that never double-charges

A correct retry policy for this API fits in one sentence: retry every 429, retry 5xx and timeouts only on requests that are safe to repeat (reads, and task creation where every task has an idempotencyKey), retry synchronous monitor calls only when they never left your machine, and fix every other 4xx. Successful calls are charged, so a retry that repeats work that already ran can be charged twice.

Definitions that drive the policy

Deciding whether a failed call may be sent again A failed call falls into three groups. A 4xx other than 429 is never retried: 400 and 422 mean the body is wrong, 401 the key, 403 the balance, 404 the route, and 409 that the idempotency key is already used. Any 429 is always safe to retry because the request was never admitted and never charged; SERVICE_OVERLOADED carries a Retry-After to honour, the other codes need a jittered backoff. A 500, a 502 or a timeout may be retried only on a replayable request, meaning a GET, a DELETE, or task creation where every task carries an idempotencyKey; a synchronous monitor call is final and uncharged. a call came back failed 4xx, not 429 429, any code 500, 502, timeout Do not retry Retry, always safe Retry if replayable fix the request never admitted or charged and only then 400, 422: fix the body 401: key; 403: balance 404: route; 409: key used SERVICE_OVERLOADED: wait Retry-After others: back off with jitter yes: GET, DELETE, task creation with an idempotencyKey no: sync monitor calls, already final, uncharged

The middle branch is the one worth remembering: a 429 never ran, so backing off and sending it again can never cost you a second charge.

The error taxonomy

Errors use one envelope, {"error": {"code", "message", "details", "timestamp"}}, so branch on error.code, not on the message. Validation failures are the exception: they answer {"success": false, "error": "Request validation failed", "details": [{"field", "message"}]}, so error is a string there. Code that reads body.error.code has to check the type first.

Status Code Retry? What to do
400 validation failure on /v1/monitor/*, or BAD_REQUEST No Read details and fix the body
422 validation failure on /v1/async/* No Same, for task and batch bodies
401 MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_OR_EXPIRED_API_KEY No Check the Authorization: Bearer header and whether the key was revoked
403 INSUFFICIENT_CREDITS No Top up the balance, then resubmit
404 RESOURCE_NOT_FOUND No Wrong route or task id
405 METHOD_NOT_ALLOWED No Wrong HTTP method for the path
409 RESOURCE_CONFLICT No The idempotency key is taken: the task already exists
413 PAYLOAD_TOO_LARGE No Shrink the body
415 UNSUPPORTED_MEDIA_TYPE No Send Content-Type: application/json
429 RATE_LIMIT_EXCEEDED Yes Too many unknown API keys from one address; check the key, wait Retry-After seconds
429 CONCURRENT_LIMIT_EXCEEDED Yes Back off; clears when a running job finishes. details.limit is your slot count
429 QUEUE_LIMIT_EXCEEDED Yes, later The submission would overflow the queue; let it drain or split the batch
429 SERVICE_OVERLOADED Yes Wait at least Retry-After seconds
500 INTERNAL_SERVER_ERROR or “Maximum retries exceeded” Only if replayable Uncharged
502 EXTERNAL_SERVICE_ERROR Only if replayable Uncharged

403 has one documented code, INSUFFICIENT_CREDITS; keys have no scopes.

The four 429s are not the same

None was admitted, so all are safe to retry. They differ in what to wait for.

Synchronous calls: the one case where 5xx is final

A synchronous POST /v1/monitor/* call can run for up to five minutes and is charged on success. If your connection drops mid-call, the collection may still be running on the server and may still succeed and be charged. Retrying starts a second collection. So:

  1. Retry a synchronous call after a network error only when the request never reached the server: connection refused, host not resolved, connect timeout.
  2. Do not retry it after a read timeout or a connection reset: it was sent.
  3. Do not retry a 500 or 502 from it. The server has already retried, and the failure was not charged.

If you cannot afford to lose a result to a dropped connection, create an async task with an idempotency key and a webhook instead; see sync, async and webhooks.

Async task creation: replayable only with keys

A timeout on POST /v1/async/task is ambiguous: the task may or may not exist. With an idempotencyKey it does not matter: resend the same body; if the first attempt was created, the repeat answers 409 RESOURCE_CONFLICT (in a batch, that item fails with RESOURCE_ALREADY_EXISTS) and creates nothing. Without a key, the same retry creates a second task and a second charge. The idempotency keys post covers how to derive keys that survive a process restart.

Treat a 409 on a retry as success: the task exists.

Reads and queue clears

GET /v1/async/task/{taskId}, GET /v1/async/status, GET /v1/credits, GET /v1/countries and GET /v1/states are safe to retry after any 5xx, timeout or connection error. Retrying DELETE /v1/async/queue is also allowed, but it deletes every task still QUEUED when it runs, so a repeat also removes tasks you queued after the first attempt. The official SDKs are stricter here and retry a clear only when it was never sent; if you submit tasks concurrently with clearing, do the same.

Batch item errors live inside a 200

POST /v1/async/task/batch takes 1 to 500 tasks and validates and admits each on its own. A mixed result is still a 200:

{
  "success": true,
  "summary": { "total": 3, "succeeded": 1, "failed": 2 },
  "results": [
    { "success": true, "index": 0, "task": { "id": "…", "status": "QUEUED", "idempotencyKey": "run-0-US-2026-09-21" } },
    { "success": false, "index": 1, "error": { "code": "RESOURCE_ALREADY_EXISTS", "message": "…" } },
    { "success": false, "index": 2, "error": { "code": "INSUFFICIENT_CREDITS", "message": "…" } }
  ]
}

An HTTP retry wrapper never sees these. Handle them per item, by index:

Only a queue overflow rejects the whole batch, with 429 QUEUE_LIMIT_EXCEEDED. The batch pipeline patterns are in rank tracking pipelines with batches.

Task failures are not HTTP errors

A task that reaches FAILED was created successfully; the failure is reported in the task, not in the create response. Both terminal failure modes are uncharged:

The failed task keeps its idempotency key for 24 hours after it failed, so a retry inside that window needs a new key, such as the original plus an attempt suffix. Treat only COMPLETED and FAILED as final: a poller can see PROCESSING followed by QUEUED.

A hand-rolled retry wrapper

The TypeScript SDK implements this policy. A client that calls HTTP directly needs these steps:

  1. Classify the request before sending: GET or DELETE, keyed task creation, or everything else (synchronous calls).
  2. Send with a per-attempt timeout. Synchronous calls need one longer than their five-minute run; the SDK also gives task creation that longer timeout.
  3. On a response, return 2xx; on 429 wait Retry-After if present, else backoff; on 5xx back off only if replayable; raise everything else.
  4. On a network error, retry if the request was never sent, or if it was sent and the request is replayable; otherwise raise.
  5. Stop after a fixed number of retries (the SDKs default to 2) and raise with the status, code, body and X-Request-Id.

Backoff is capped exponential with full jitter: the delay for attempt n is a random value between zero and min(cap, base × 2^n). The values below (500 ms base, 8 s cap) match the SDK defaults.

Python (requests)

import os
import random
import time

import requests
from urllib3.exceptions import NewConnectionError

BASE_URL = "https://api.answerline.dev"
MAX_RETRIES = 2
BASE_DELAY_S = 0.5
MAX_DELAY_S = 8.0
TIMEOUT_S = 30
LONG_TIMEOUT_S = 360  # synchronous monitor calls run for up to five minutes

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['API_KEY']}"


class ApiError(Exception):
    def __init__(self, status, body, request_id):
        err = body.get("error") if isinstance(body, dict) else None
        self.code = err.get("code") if isinstance(err, dict) else None
        self.status, self.body, self.request_id = status, body, request_id
        super().__init__(f"{status} {self.code or 'error'} (request {request_id})")


def _has_key(task):
    return isinstance(task, dict) and isinstance(task.get("idempotencyKey"), str) and task["idempotencyKey"] != ""


def _replayable(method, path, body):
    if method in ("GET", "DELETE"):
        return True
    if path == "/v1/async/task":
        return _has_key(body)
    if path == "/v1/async/task/batch":
        return isinstance(body, list) and len(body) > 0 and all(_has_key(t) for t in body)
    return False  # synchronous monitor calls


def _not_sent(exc):
    """Connection refused, DNS failure or connect timeout: the request never reached the server."""
    if isinstance(exc, requests.exceptions.ConnectTimeout):
        return True
    reason = getattr(exc.args[0], "reason", None) if exc.args else None
    return isinstance(exc, requests.exceptions.ConnectionError) and isinstance(reason, NewConnectionError)


def _backoff(attempt):
    return random.uniform(0, min(MAX_DELAY_S, BASE_DELAY_S * 2**attempt))


def call(method, path, body=None, request_id=None):
    replayable = _replayable(method, path, body)
    long_call = path.startswith("/v1/monitor/") or (method == "POST" and path.startswith("/v1/async/task"))
    headers = {"X-Request-Id": request_id} if request_id else {}
    attempt = 0
    while True:
        try:
            res = session.request(method, BASE_URL + path, json=body, headers=headers,
                                  timeout=(10, LONG_TIMEOUT_S if long_call else TIMEOUT_S))
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
            if attempt == MAX_RETRIES or not (_not_sent(exc) or replayable):
                raise
            delay = _backoff(attempt)
        else:
            if res.ok:
                return res.json()
            try:
                payload = res.json()
            except ValueError:
                payload = res.text
            error = ApiError(res.status_code, payload, res.headers.get("X-Request-Id"))
            retry = res.status_code == 429 or (res.status_code >= 500 and replayable)
            if attempt == MAX_RETRIES or not retry:
                raise error
            after = res.headers.get("Retry-After", "")
            delay = int(after) if after.isdigit() else _backoff(attempt)
        time.sleep(delay)
        attempt += 1


task = call("POST", "/v1/async/task", {
    "taskType": "CHATGPT",
    "payload": {"prompt": "Best CRM for a 10-person agency", "country": "US"},
    "idempotencyKey": "crm-agency-US-2026-09-21",
})

A 409 on a keyed create surfaces as ApiError with code == "RESOURCE_CONFLICT"; treat the task as created.

TypeScript (fetch)

const BASE_URL = "https://api.answerline.dev";
const MAX_RETRIES = 2;
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 8_000;
const TIMEOUT_MS = 30_000;
const LONG_TIMEOUT_MS = 360_000; // synchronous monitor calls run for up to five minutes
/** Connection refused, host not resolved or connect timeout: the request never left. */
const NOT_SENT = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string | undefined,
    readonly body: unknown,
    readonly requestId: string | null,
  ) {
    super(`${status} ${code ?? "error"} (request ${requestId})`);
  }
}

const hasKey = (t: unknown) => typeof (t as { idempotencyKey?: unknown } | null)?.idempotencyKey === "string";

function replayable(method: string, path: string, body: unknown): boolean {
  if (method === "GET" || method === "DELETE") return true;
  if (path === "/v1/async/task") return hasKey(body);
  if (path === "/v1/async/task/batch") return Array.isArray(body) && body.length > 0 && body.every(hasKey);
  return false; // synchronous monitor calls
}

const backoff = (attempt: number) => Math.random() * Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
  const canReplay = replayable(method, path, body);
  const long = path.startsWith("/v1/monitor/") || (method === "POST" && path.startsWith("/v1/async/task"));
  for (let attempt = 0; ; attempt++) {
    let delay = 0;
    try {
      const res = await fetch(BASE_URL + path, {
        method,
        headers: {
          Authorization: `Bearer ${process.env.API_KEY}`,
          ...(body === undefined ? {} : { "Content-Type": "application/json" }),
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(long ? LONG_TIMEOUT_MS : TIMEOUT_MS),
      });
      const text = await res.text();
      let parsed: unknown = text;
      try {
        parsed = text ? JSON.parse(text) : undefined;
      } catch {}
      if (res.ok) return parsed as T;
      const err = (parsed as { error?: unknown } | undefined)?.error;
      const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined;
      const error = new ApiError(res.status, code, parsed, res.headers.get("x-request-id"));
      const retry = res.status === 429 || (res.status >= 500 && canReplay);
      if (!retry || attempt === MAX_RETRIES) throw error;
      const after = Number(res.headers.get("retry-after"));
      delay = after > 0 ? after * 1000 : backoff(attempt);
    } catch (e) {
      if (e instanceof ApiError) throw e;
      const cause = (e as { cause?: { code?: string } }).cause?.code;
      const notSent = cause !== undefined && NOT_SENT.has(cause);
      if (!(notSent || canReplay) || attempt === MAX_RETRIES) throw e;
      delay = backoff(attempt);
    }
    await sleep(delay);
  }
}

In Node, fetch reports network failures with the socket error in cause.code; a timeout has none, so it is retried only when replayable.

The same policy in the SDK

With @answerline/sdk you configure the same policy rather than write it. maxRetries sets retries after the first attempt (default 2), timeoutMs the per-attempt timeout of ordinary calls (default 30 seconds), and syncTimeoutMs the longer per-attempt timeout for synchronous calls. Task creation without an idempotencyKey gets a generated key, so the SDK’s own retries of a create are always replayable. That generated key does not protect you across a process restart; supply your own for that.

import { ApiError, Client } from "@answerline/sdk";

const client = new Client({ apiKey: process.env.API_KEY!, maxRetries: 3 });

try {
  const answer = await client.chatgpt({ prompt: "Best CRM for a 10-person agency", country: "US" });
  console.log((answer.result as { text: string }).text);
} catch (err) {
  if (!(err instanceof ApiError)) throw err;
  console.error(err.status, err.code, err.meta.requestId, err.body);
}

Errors the SDK gives up on are thrown as ApiError with status, code, body and meta.requestId.

Request ids and support

Every response carries X-Request-Id. Send your own (1 to 128 letters, digits, -, _, . or :) to tie a request to your logs, log it on every failure, and quote it when you contact support.

Pitfalls

Full error codes and headers are in the authentication and errors docs and rate limits.

Questions

Is a 429 from this API always safe to retry?

Yes. A 429 means the request was not admitted, so nothing ran and nothing was charged. Wait the Retry-After seconds on SERVICE_OVERLOADED and RATE_LIMIT_EXCEEDED; the other 429s carry no Retry-After, so back off with jitter.

Should I retry a synchronous monitor call that returned 500 or 502?

No. The server has already retried it, so a 500 or 502 from a synchronous call is final and uncharged. Retry a synchronous call only when the request never reached the server, such as a refused connection or a DNS failure.

When is it safe to retry async task creation after a timeout?

When every task in the request carries an idempotencyKey. A repeat of a task that was already created answers 409 RESOURCE_CONFLICT, or RESOURCE_ALREADY_EXISTS for a batch item, and creates nothing.

Does a batch request fail as a whole when one task is invalid?

No. Each task is admitted on its own and failures come back per item inside a 200 response, with codes VALIDATION_ERROR, RESOURCE_ALREADY_EXISTS or INSUFFICIENT_CREDITS. Only a queue overflow rejects the whole batch, with 429 QUEUE_LIMIT_EXCEEDED.

Are failed requests charged?

No. Requests that fail with 500 or 502 are not charged, and a 429 was never admitted. The balance is charged only when a request or task succeeds.

What should I send to support when a request fails?

The X-Request-Id response header. Every response carries one, and you can send your own id of 1 to 128 letters, digits, hyphens, underscores, dots or colons to tie requests to your logs.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading