# Retries and error handling in code

Ready-made response handlers in JavaScript, Python, and PHP that distinguish reads
from writes and bound the number of retries.

The authoritative delivery and safety table for every temporary code is [What is
safe to retry](/docs/errors/retry-safety). The summary table of all Vibecode API
codes is [Error codes](/docs/errors).

## Before using the examples

The caller must pass `operationKind` explicitly: `read` for an operation that does
not change data and `write` for creating, changing, deleting, or sending data. Do
not infer it from the HTTP verb: POST can perform a read.

The examples retry a write automatically only for codes where the previous call
could not create a duplicate. For `OPERATION_TIME_LIMIT`, `BITRIX_TIMEOUT`,
`BITRIX_UNAVAILABLE`, `POOL_EXHAUSTED`, `DB_TRANSIENT`, `SERVICE_UNAVAILABLE`,
`BH_APP_STARTING`, `BH_APP_TIMEOUT`, and `BH_APP_CONNECTION_LOST`, a write fails with
`STATE_VERIFICATION_REQUIRED`. The business code must then re-read the entity and
prove that the write had no effect. There is no universal safe `readBack` across
entities.

`BITRIX_ERROR`, `TOKEN_REFRESH_FAILED`, and `ERROR_LOOP_DETECTED` are also outside
the retry loop: fix the cause or restore authorization first. Every retry is
bounded by `MAX_ATTEMPTS`, honors `Retry-After`, and adds jitter. Save
`X-Request-Id` for support, but do not copy it into a retry: it is not an
idempotency key.

## JavaScript

```javascript
const MAX_ATTEMPTS = 5;
const SAFE_WRITE_RETRY = new Set([
  'QUEUE_OVERFLOW', 'QUEUE_TIMEOUT', 'RATE_LIMITED',
  'TIMEOUT_QUARANTINE', 'LARGE_BODY_BACKEND_BUSY',
]);
const VERIFY_WRITE_STATE = new Set([
  'OPERATION_TIME_LIMIT', 'BITRIX_TIMEOUT', 'BITRIX_UNAVAILABLE',
  'POOL_EXHAUSTED', 'DB_TRANSIENT', 'SERVICE_UNAVAILABLE', 'BH_APP_STARTING',
  'BH_APP_TIMEOUT', 'BH_APP_CONNECTION_LOST',
]);
const FIX_BEFORE_RETRY = new Set([
  'BITRIX_ERROR', 'TOKEN_REFRESH_FAILED', 'ERROR_LOOP_DETECTED',
]);

function retryDelaySeconds(error, response, attempt) {
  const rawHeader = response.headers.get('Retry-After');
  const header = rawHeader === null ? Number.NaN : Number(rawHeader);
  const base = error.retryAfter ?? (Number.isFinite(header) ? header : Math.min(30, 2 ** (attempt - 1)));
  return Math.max(1, base) + Math.random();
}

async function vibeRequest(url, options, operationKind, attempt = 1) {
  if (!['read', 'write'].includes(operationKind)) {
    throw new Error('operationKind must be read or write');
  }

  const response = await fetch(url, {
    ...options,
    headers: {
      'X-Api-Key': process.env.VIBE_API_KEY,
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });
  const data = await response.json();
  if (data.success) return data;

  const { code, message } = data.error;
  if (operationKind === 'write' && VERIFY_WRITE_STATE.has(code)) {
    throw new Error(`STATE_VERIFICATION_REQUIRED: ${code}`);
  }
  if (FIX_BEFORE_RETRY.has(code)) {
    throw new Error(`FIX_BEFORE_RETRY: ${code}: ${message}`);
  }

  const retryable = SAFE_WRITE_RETRY.has(code)
    || (operationKind === 'read' && VERIFY_WRITE_STATE.has(code));
  if (!retryable) throw new Error(`${code}: ${message}`);
  if (attempt >= MAX_ATTEMPTS) {
    throw new Error(`${code}: still failing after ${MAX_ATTEMPTS} attempts`);
  }

  const wait = retryDelaySeconds(data.error, response, attempt);
  await new Promise(resolve => setTimeout(resolve, wait * 1000));
  return vibeRequest(url, options, operationKind, attempt + 1);
}
```

## Python

```python
import os
import random
import time
import requests

MAX_ATTEMPTS = 5
SAFE_WRITE_RETRY = {
    "QUEUE_OVERFLOW", "QUEUE_TIMEOUT", "RATE_LIMITED",
    "TIMEOUT_QUARANTINE", "LARGE_BODY_BACKEND_BUSY",
}
VERIFY_WRITE_STATE = {
    "OPERATION_TIME_LIMIT", "BITRIX_TIMEOUT", "BITRIX_UNAVAILABLE",
    "POOL_EXHAUSTED", "DB_TRANSIENT", "SERVICE_UNAVAILABLE", "BH_APP_STARTING",
    "BH_APP_TIMEOUT", "BH_APP_CONNECTION_LOST",
}
FIX_BEFORE_RETRY = {
    "BITRIX_ERROR", "TOKEN_REFRESH_FAILED", "ERROR_LOOP_DETECTED",
}

def vibe_request(url, operation_kind, method="GET", json_data=None, attempt=1):
    if operation_kind not in ("read", "write"):
        raise ValueError("operation_kind must be read or write")

    response = requests.request(
        method,
        url,
        headers={"X-Api-Key": os.environ["VIBE_API_KEY"], "Content-Type": "application/json"},
        json=json_data,
    )
    data = response.json()
    if data.get("success"):
        return data

    error = data.get("error", {})
    code = error.get("code", "UNKNOWN")
    message = error.get("message", "Unknown error")
    if operation_kind == "write" and code in VERIFY_WRITE_STATE:
        raise RuntimeError(f"STATE_VERIFICATION_REQUIRED: {code}")
    if code in FIX_BEFORE_RETRY:
        raise RuntimeError(f"FIX_BEFORE_RETRY: {code}: {message}")

    retryable = code in SAFE_WRITE_RETRY or (
        operation_kind == "read" and code in VERIFY_WRITE_STATE
    )
    if not retryable:
        raise RuntimeError(f"{code}: {message}")
    if attempt >= MAX_ATTEMPTS:
        raise RuntimeError(f"{code}: still failing after {MAX_ATTEMPTS} attempts")

    header_delay = response.headers.get("Retry-After")
    base_delay = error.get("retryAfter") or (
        float(header_delay) if header_delay else min(30, 2 ** (attempt - 1))
    )
    time.sleep(max(1, base_delay) + random.random())
    return vibe_request(url, operation_kind, method, json_data, attempt + 1)
```

## PHP

```php
const MAX_ATTEMPTS = 5;
const SAFE_WRITE_RETRY = [
    'QUEUE_OVERFLOW', 'QUEUE_TIMEOUT', 'RATE_LIMITED',
    'TIMEOUT_QUARANTINE', 'LARGE_BODY_BACKEND_BUSY',
];
const VERIFY_WRITE_STATE = [
    'OPERATION_TIME_LIMIT', 'BITRIX_TIMEOUT', 'BITRIX_UNAVAILABLE',
    'POOL_EXHAUSTED', 'DB_TRANSIENT', 'SERVICE_UNAVAILABLE', 'BH_APP_STARTING',
    'BH_APP_TIMEOUT', 'BH_APP_CONNECTION_LOST',
];
const FIX_BEFORE_RETRY = [
    'BITRIX_ERROR', 'TOKEN_REFRESH_FAILED', 'ERROR_LOOP_DETECTED',
];

function vibeRequest(
    string $url,
    string $operationKind,
    string $method = 'GET',
    ?array $requestData = null,
    int $attempt = 1,
): array {
    if (!in_array($operationKind, ['read', 'write'], true)) {
        throw new InvalidArgumentException('operationKind must be read or write');
    }

    $responseHeaders = [];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => [
            'X-Api-Key: ' . getenv('VIBE_API_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$responseHeaders): int {
            $parts = explode(':', $line, 2);
            if (count($parts) === 2) {
                $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
            }
            return strlen($line);
        },
    ]);
    if ($requestData !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
    }
    $rawBody = curl_exec($ch);
    if ($rawBody === false) {
        $transportError = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("NETWORK_OUTCOME_UNKNOWN: $transportError");
    }
    curl_close($ch);

    $body = json_decode($rawBody, true);
    if (!is_array($body)) throw new RuntimeException('Invalid JSON response');
    if (!empty($body['success'])) return $body;

    $error = $body['error'] ?? [];
    $code = $error['code'] ?? 'UNKNOWN';
    $message = $error['message'] ?? 'Unknown error';
    if ($operationKind === 'write' && in_array($code, VERIFY_WRITE_STATE, true)) {
        throw new RuntimeException("STATE_VERIFICATION_REQUIRED: $code");
    }
    if (in_array($code, FIX_BEFORE_RETRY, true)) {
        throw new RuntimeException("FIX_BEFORE_RETRY: $code: $message");
    }

    $retryable = in_array($code, SAFE_WRITE_RETRY, true)
        || ($operationKind === 'read' && in_array($code, VERIFY_WRITE_STATE, true));
    if (!$retryable) throw new RuntimeException("$code: $message");
    if ($attempt >= MAX_ATTEMPTS) {
        throw new RuntimeException("$code: still failing after " . MAX_ATTEMPTS . ' attempts');
    }

    $baseDelay = $error['retryAfter']
        ?? ($responseHeaders['retry-after'] ?? min(30, 2 ** ($attempt - 1)));
    sleep(max(1, (int) $baseDelay));
    usleep(random_int(0, 1000000));
    return vibeRequest($url, $operationKind, $method, $requestData, $attempt + 1);
}
```

A network failure without the JSON envelope does not say whether the write took
effect. In JavaScript and Python, catch the transport exception outside the
function and apply the same rule: a read can be retried with bounded backoff, while a write
requires a state check. The PHP example reports this case as
`NETWORK_OUTCOME_UNKNOWN`.

## See also

- [What is safe to retry](/docs/errors/retry-safety)
- [Error codes](/docs/errors)
- [Limits, queues, and pauses](/docs/errors/limits)
- [Bitrix24 and the platform](/docs/errors/platform)
- [Limits and optimization](/docs/optimization)
- [Batch](/docs/batch)
- [CLI and cURL](/docs/cli)
