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
- Admitted: the API accepted the request. A request that was not admitted cannot have run.
- Replayable: sending the request twice has the same effect as sending it once. GET is replayable. Task creation is replayable only when each task carries an
idempotencyKey, because the second attempt is refused instead of creating a duplicate. - Final: the server already retried and gave up.
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.
RATE_LIMIT_EXCEEDEDonly happens when one address presents too many unknown API keys. There is no per-account request rate limit. It carriesRetry-Afterin whole seconds.CONCURRENT_LIMIT_EXCEEDEDonly happens on synchronous calls: a synchronous call takes a concurrency slot for its whole run or fails at once when none is free. Async tasks never get this error; they wait in the queue instead. NoRetry-After.QUEUE_LIMIT_EXCEEDEDrejects a task or a whole batch that would overflow your queue. ItsdetailscarryqueuedCount,batchSize,maxQueueSizeandremainingCapacity, which tell you whether a smaller batch would fit now. NoRetry-After; a blind fast retry will usually fail again.SERVICE_OVERLOADEDmeans the API is shedding load; you did not exceed a limit. It carries noX-RateLimit-*headers, but has aRetry-Afterin whole seconds. Honor it instead of your own backoff.
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:
- Retry a synchronous call after a network error only when the request never reached the server: connection refused, host not resolved, connect timeout.
- Do not retry it after a read timeout or a connection reset: it was sent.
- 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:
RESOURCE_ALREADY_EXISTS: the task was created by an earlier submission. Count it as done.VALIDATION_ERROR: fix that task’s payload. Resending it unchanged fails the same way.INSUFFICIENT_CREDITS: the balance is checked in input order, so once the balance runs out every later item is refused. Top up, then resubmit only the refused items.
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:
- A task that did not start within 4 hours of creation fails with
502 EXTERNAL_SERVICE_ERRORanddetails.causeQUEUE_WAIT_EXCEEDED. Resubmit these. - A task that started but did not finish within five minutes fails with “Maximum retries exceeded”.
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:
- Classify the request before sending: GET or DELETE, keyed task creation, or everything else (synchronous calls).
- 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.
- On a response, return 2xx; on 429 wait
Retry-Afterif present, else backoff; on 5xx back off only if replayable; raise everything else. - On a network error, retry if the request was never sent, or if it was sent and the request is replayable; otherwise raise.
- 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
- Retrying on status alone. A 500 on a GET is retryable; the same 500 on a synchronous monitor call is final.
- Random idempotency keys per attempt. Generate the key once per logical task, before the first attempt.
- Treating 409 as a failure. On a retry it means the first attempt worked.
- Ignoring the 200 body of a batch. Refused items are only visible in
results. - Unbounded retries. A full queue or an empty balance will not fix itself in milliseconds; cap attempts.
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.