# What is safe to retry

The HTTP status and the word `retry` alone do not answer the main question: will
a retry create a duplicate record? Answering that needs two separate
characteristics — whether the target call of the method managed to reach
Bitrix24, and whether the original request is a read or a write.

`Retry-After` tells you when the next attempt is allowed from a load
standpoint. It does not prove that the previous write was not applied.

## Terms

### Target call

The target call is the Bitrix24 business method you wanted to execute, for
example `crm.deal.get` or `crm.deal.add`. A service-level OAuth token refresh
does not count as a target call.

For `BH_APP_TIMEOUT`, the request may or may not have reached the app: the
gateway only knows that it handed the frame to the tunnel, not that the app
received it — the agent may have failed to connect to the app's port. The
gateway simply stopped waiting for a response on its own timeout, not because
the app refused. So the outcome of the business operation is unknown from the
code alone: if the request did reach the app, it may have called Bitrix24
either before or after the gateway stopped waiting.

### Read and write

A read does not change data: retrying it is generally safe from a duplicate
standpoint. A write creates, changes, deletes, or sends data, so it needs a
separate verdict. Determine the operation type by the meaning of the method,
not the HTTP verb: a POST can perform a search, while methods like `*.add`,
`*.update`, `*.delete`, and `*.send` change state.

### Unknown outcome

"Unknown" means the code alone cannot prove whether the target call managed to
produce an effect. The absence of a successful response does not equal the
absence of an effect.

## Decision rule

- **Retry is safe** — wait for the stated condition and retry with a limit on
  the number of attempts.
- **Read back first** — locate the operation's effect by a stable business key
  or identifier. Retry the write only if you can prove the effect is absent.
- **Do not retry automatically** — fix the cause, or hand the case to a human.
  After the input changes, it is a new deliberate attempt, not a retry of the
  same request.

If you cannot reliably verify the write's effect, "read back first" means "do
not retry automatically". `X-Request-Id` is for diagnostics and is not an
idempotency key.

## Code matrix

| Code | Did the target call reach Bitrix24 | Read | Write | Condition and explanation |
|---|---|---|---|---|
| `QUEUE_OVERFLOW` | No | Retry is safe | Retry is safe | Honor `Retry-After`, lower concurrency, use backoff with jitter. The queue rejected the call before it ran. |
| `QUEUE_TIMEOUT` | No | Retry is safe | Retry is safe | Honor `Retry-After`. The call was removed from the queue before execution started. |
| `RATE_LIMITED` | Unknown | Retry is safe | Retry is safe | Honor `Retry-After`. The code covers both local pre-call limits and a Bitrix24 rate rejection without the operation being applied. |
| `OPERATION_TIME_LIMIT` | Unknown | Retry is safe | Read back first | Strictly honor `Retry-After`. The first rejection comes from a method already called on Bitrix24; subsequent ones may occur locally before the call is sent. |
| `TIMEOUT_QUARANTINE` | No | Retry is safe | Retry is safe | Do not retry before the full `Retry-After` elapses: early requests interfere with recovery. The current call was stopped locally before it was sent. |
| `BITRIX_TIMEOUT` | Yes | Retry is safe | Read back first | Wait for the recommended pause. The platform stopped waiting for the response, but processing of the write on Bitrix24 may have continued. |
| `BITRIX_ERROR` | Yes | Retry is safe after fixing the cause | Do not retry automatically | Fix the cause using `b24Code`, `message`, and the error fields. A plain pause does not change the outcome; for a questionable write, additionally verify the state. |
| `BITRIX_UNAVAILABLE` | Unknown | Retry is safe | Read back first | Use bounded exponential backoff. The code covers upstream 5xx and network failures; the absence of a response does not prove the absence of an effect. |
| `POOL_EXHAUSTED` | Unknown | Retry is safe | Read back first | Honor `Retry-After`. The shared DB error handler can fire at different stages of request processing. |
| `DB_TRANSIENT` | Unknown | Retry is safe | Read back first | Honor `Retry-After`. Rolling back the local DB transaction does not roll back a Bitrix24 call that already happened. |
| `SERVICE_UNAVAILABLE` | Unknown | Retry is safe | Read back first | Honor `Retry-After`. The platform boundary does not know whether the backend managed to complete the external effect; in particular, a 504 does not stop the upstream call. |
| `TOKEN_REFRESH_FAILED` | No | Retry is safe after authorization is restored | Retry is safe after authorization is restored | The target method was not sent. A plain backoff without fixing authorization is useless. |
| `ERROR_LOOP_DETECTED` | No | Retry is safe after the cause is resolved | Do not retry automatically | The current call was stopped by the circuit breaker before it was sent. Fix the stable cause first; occasional probe calls may get through. |
| `BH_APP_STARTING` | Unknown | Retry is safe | Read back first | No response frame arrived from the app at all, or the agent answered for itself rather than the app — both say a frame from the app was missing, but neither proves the app's code never saw the request: the connection could have dropped after processing had already started. Wait for `Retry-After`. |
| `BH_APP_TIMEOUT` | Unknown | Retry is safe | Read back first | The gateway stops waiting for a response under one of two windows (30 seconds with no frame at all, or an already-started response going silent for more than 15 seconds) — not because the app refused. The request may have been delivered to the app and executed, or it may never have arrived: the gateway only knows that it handed the frame to the tunnel. There is no `Retry-After` in the response: waiting does not make the retry safe. |
| `LARGE_BODY_BACKEND_BUSY` | No | Retry is safe | Retry is safe | Honor `Retry-After`. The admission gate rejected the request before the route handler and the target call. |

## Why some codes are ambiguous

### `RATE_LIMITED`

Delivery is unknown because the same code is shared by local limits and by
Bitrix24. Retrying a write after a pause is still safe: the known sources
either never send the call or unambiguously reject it by rate without the
operation being applied. This does not mean every HTTP 429 response always
occurs before the call is sent.

### `OPERATION_TIME_LIMIT`

The first rejection with this code is returned by a Bitrix24 method that was
already called. After it, Vibecode remembers the pause and may reject
following requests before they are sent. That is why the write path takes the
more conservative action — verify the effect first.

### `TIMEOUT_QUARANTINE`

The current request, stopped by the local pause, is safe. An earlier operation
whose timeout triggered the quarantine may have an unknown outcome — this
guarantee does not extend to it.

### `DB_TRANSIENT`

A transaction rollback in the Vibecode database is not a distributed rollback
of Bitrix24. If the external call already completed, the local rollback does
not undo its effect.

### `BH_APP_STARTING`

No response frame arrived from the app at all, or the agent answered for
itself rather than the app. Both say a frame from the app was missing, but
neither proves the app's code never saw the request: the connection could
have dropped after processing had already started. That is why a write
requires reading state back first rather than a blind retry — unlike
`BH_APP_TIMEOUT` below, this code does carry a `Retry-After`.

### `BH_APP_TIMEOUT`

This response is formed when the gateway stops waiting for the app under one
of two windows: 30 seconds with no frame at all, or an already-started
response (headers arrived) going silent for more than 15 seconds. Neither
case proves the request reached the app — the gateway only knows that it
handed the frame to the tunnel, not that the app received it. If the request
did arrive, the app may have called Bitrix24, so the outcome of the operation
is unknown under the `BH_APP_TIMEOUT` code, and there is no `Retry-After` in
the response: waiting before retrying does not make the retry safe.

## Algorithm for reads

1. Recognize the response code.
2. For an authorization error, a business error, or a circuit breaker, fix the
   cause first.
3. For a transient failure, honor `Retry-After`; if it is absent, apply
   bounded exponential backoff with jitter.
4. Retry the read no more than the number of times set by the client.
5. On a persistent failure, stop and pass the time and `X-Request-Id` (if it
   was present in the response) to support.

## Algorithm for writes

1. Do not use a single retryable-codes list for both reads and writes.
2. If the "Write" cell allows a retry, wait for the stated condition and retry
   with a limit on the number of attempts.
3. If you need to read back the state, locate exactly the business effect of
   the original operation by a unique attribute.
4. If the effect is already there, treat the original write as successful and
   do not retry it.
5. If the effect is verifiably absent, perform a new attempt.
6. If the check is unreliable, stop the automatic retry and resolve the case
   manually.

A plain GET without a unique criterion does not prove the absence of a
duplicate. If the method has its own server-side idempotency key, you can rely
on that method's contract; this page itself does not introduce that guarantee.

## Classifier example

The classifier takes the operation kind explicitly. It does not try to infer
it from the HTTP verb and does not implement a universal `readBack`: verifying
the effect depends on the specific entity.

```javascript
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',
]);

const FIX_BEFORE_RETRY = new Set([
  'BITRIX_ERROR', 'TOKEN_REFRESH_FAILED', 'ERROR_LOOP_DETECTED',
]);

const SAFE_RETRY = new Set([
  'QUEUE_OVERFLOW', 'QUEUE_TIMEOUT', 'RATE_LIMITED',
  'TIMEOUT_QUARANTINE', 'LARGE_BODY_BACKEND_BUSY',
]);

function classifyRetry(code, operationKind) {
  if (FIX_BEFORE_RETRY.has(code)) return 'fix_before_retry';
  if (operationKind === 'write' && VERIFY_WRITE_STATE.has(code)) {
    return 'state_verification_required';
  }
  if (SAFE_RETRY.has(code) || operationKind === 'read') return 'retry_with_limit';
  return 'state_verification_required';
}
```

Handle a code the classifier does not recognize conservatively: a read can be
retried with bounded backoff, a write only after verifying the state. A
network error without the platform's JSON envelope has the same unknown
outcome for a write.

In a batch, the decision is made separately for each item by its own code and
the sub-request's semantics. An HTTP 200 on the shared envelope does not mean
every item succeeded.

## See also

- [Error codes](/docs/errors)
- [Limits, queues, and pauses](/docs/errors/limits)
- [Bitrix24 and the platform](/docs/errors/platform)
- [Retries and error handling in code](/docs/errors/handling)
- [Batch](/docs/batch)
- [App runtime environment](/docs/infra/app-runtime)
