> ## Documentation Index
> Fetch the complete documentation index at: https://oblodai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Рецепт устойчивого клиента

Сеть ненадёжна, а вы работаете с деньгами. Эта инструкция — про то, как построить клиент, который
переживает таймауты, повторы и временные сбои, не создавая дублей и не теряя платежи. Здесь сведены
воедино идемпотентность, обработка таймаутов, ретраи и backoff.

Справочная база — [Идемпотентность](/reference/basics-idempotency) и
[Формат ответа и коды ошибок](/reference/basics-errors).

***

## Главный принцип

<Note>
  **Каждый денежный запрос отправляйте с заголовком `Idempotency-Key`. Тогда любой повтор безопасен.**
</Note>

Это фундамент всей устойчивости. Без защиты от дублей повтор при таймауте = второй счёт или вторая
выплата. С ней повтор = тот же объект. Всё остальное в этой инструкции опирается на это правило.

### Два механизма, а не один

Дедупликацию дают **два** независимых механизма. Они работают вместе, и лучше использовать оба.

|                    | HTTP‑заголовок `Idempotency-Key`                         | Поле `order_id`                               |
| ------------------ | -------------------------------------------------------- | --------------------------------------------- |
| Что это            | Технический ключ **одной попытки действия**              | Ваш **бизнес‑идентификатор** (заказ, выплата) |
| Уровень            | Транспорт: любой запрос                                  | Данные: платежи, выплаты, возвраты            |
| Значение           | Любое уникальное, ≤255 символов (обычно UUID)            | Ваш номер заказа                              |
| Когда генерируется | **До первой отправки**, одинаков во всех попытках        | Когда появился заказ                          |
| Что вернёт повтор  | **Тот же ответ** + заголовок `Idempotent-Replayed: true` | Тот же объект                                 |
| Обязателен?        | Нет, но **настоятельно рекомендуется**                   | Для **выплат — обязателен**                   |

**Ключевое правило заголовка:** значение генерируется **один раз, до первой отправки**, и остаётся тем
же во **всех** ретраях этого действия. Если вы сгенерируете новый ключ на каждую попытку — защиты не
будет вовсе, вы просто отправите N разных запросов.

<CodeGroup>
  ```python Python theme={null}
  import uuid

  key = str(uuid.uuid4())          # ОДИН РАЗ на действие — не внутри цикла ретраев!

  for attempt in range(5):
      resp = call("/v1/payout", {
          "amount": "25", "currency": "USDT", "network": "tron",
          "address": "TXY...", "order_id": "payout-1",
      }, idempotency_key=key)      # ТОТ ЖЕ ключ во всех попытках
      ...
  ```

  ```js Node.js theme={null}
  const key = crypto.randomUUID();     // ОДИН РАЗ на действие — не внутри цикла ретраев!

  for (let attempt = 0; attempt < 5; attempt++) {
    const resp = await call("/v1/payout", {
      amount: "25", currency: "USDT", network: "tron",
      address: "TXY...", order_id: "payout-1",
    }, key);                           // ТОТ ЖЕ ключ во всех попытках
    // ...
  }
  ```
</CodeGroup>

Чтобы передать заголовок, добавьте его в свою обёртку `call()` (базовая версия — в инструкции
[Как подписать запрос](/guides/signing-requests)):

<CodeGroup>
  ```python Python theme={null}
  def call(path: str, payload: dict, idempotency_key: str | None = None) -> dict:
      body = json.dumps(payload, separators=(",", ":"))
      ts = str(int(time.time()))
      signing = f"{ts}\nPOST\n{path}\n{body}"
      sig = hmac.new(SECRET, signing.encode(), hashlib.sha256).hexdigest()
      headers = {
          "Content-Type": "application/json",
          "X-Public-Id": PUBLIC_ID,
          "X-Timestamp": ts,
          "X-Signature": sig,
      }
      if idempotency_key:
          headers["Idempotency-Key"] = idempotency_key   # ← в подпись НЕ входит
      return requests.post(BASE + path, data=body, headers=headers).json()
  ```

  ```js Node.js theme={null}
  async function call(path, payload, idempotencyKey = null) {
    const body = JSON.stringify(payload);
    const ts = Math.floor(Date.now() / 1000).toString();
    const signing = `${ts}\nPOST\n${path}\n${body}`;
    const sig = crypto.createHmac("sha256", SECRET).update(signing).digest("hex");
    const headers = {
      "Content-Type": "application/json",
      "X-Public-Id": PUBLIC_ID,
      "X-Timestamp": ts,
      "X-Signature": sig,
    };
    if (idempotencyKey) {
      headers["Idempotency-Key"] = idempotencyKey;   // ← в подпись НЕ входит
    }
    const res = await fetch(BASE + path, { method: "POST", headers, body });
    return res.json();
  }
  ```
</CodeGroup>

<Note>
  Заголовок `Idempotency-Key` **не участвует в подписи** — подписывается только каноническая строка
  `ts\nMETHOD\npath\nbody`. Добавление заголовка ничего не ломает в подписи.
</Note>

### Что делать, если заголовок ещё не внедрён

`order_id` **тоже** дедуплицирует — это ваша страховка. Если вы не готовы менять транспортный слой,
минимум остаётся прежним: **всегда задавайте `order_id`**, и повтор вернёт существующий объект.

Опасен только случай, когда нет **ни того, ни другого**: без заголовка и без `order_id` повтор создаст
дубль. Для выплат это невозможно (`order_id` обязателен), для платежей — вполне.

***

## Проблема таймаута: «ответ не пришёл — операция прошла или нет?»

Самая коварная ситуация. Вы отправили `POST /v1/payout`, но ответ не дошёл (таймаут, разрыв). Выплата
могла:

* **не создаться** — запрос не дошёл до сервера;
* **создаться** — сервер обработал, но ответ потерялся по пути к вам.

Гадать нельзя. Правильных действий два, и оба безопасны **благодаря идемпотентности**:

### Вариант А. Повторить тот же запрос

Повторяйте с **тем же `Idempotency-Key`** и **тем же `order_id`**:

<CodeGroup>
  ```python Python theme={null}
  # Повтор идемпотентен: если выплата уже создана — вернётся ОНА, дубля не будет
  result = call("/v1/payout", {
      "amount": "25", "currency": "USDT", "network": "tron",
      "address": "TXY...", "order_id": "payout-1",   # ТОТ ЖЕ order_id
  }, idempotency_key=key)                            # ТОТ ЖЕ Idempotency-Key
  ```

  ```js Node.js theme={null}
  // Повтор идемпотентен: если выплата уже создана — вернётся ОНА, дубля не будет
  const result = await call("/v1/payout", {
    amount: "25", currency: "USDT", network: "tron",
    address: "TXY...", order_id: "payout-1",   // ТОТ ЖЕ order_id
  }, key);                                     // ТОТ ЖЕ Idempotency-Key
  ```
</CodeGroup>

Если первая попытка прошла — получите ту же выплату (в ответе будет `Idempotent-Replayed: true`). Если
не прошла — создастся сейчас. В обоих случаях итог один, и денег не уйдёт дважды.

<Note>
  **`409 idempotency.in_progress`** означает, что запрос с этим ключом **прямо сейчас выполняется** на
  сервере. Это не ошибка и не отказ: подождите и повторите — получите результат первой попытки.
</Note>

### Вариант Б. Спросить статус

<CodeGroup>
  ```python Python theme={null}
  # Узнать, что с выплатой по вашему order_id
  info = call("/v1/payout/info", {"order_id": "payout-1"})
  # 404 payout.not_found → не создавалась, можно создавать
  # объект выплаты → уже существует, действуйте по её статусу
  ```

  ```js Node.js theme={null}
  // Узнать, что с выплатой по вашему order_id
  const info = await call("/v1/payout/info", { order_id: "payout-1" });
  // 404 payout.not_found → не создавалась, можно создавать
  // объект выплаты → уже существует, действуйте по её статусу
  ```
</CodeGroup>

Для платежей — [`/v1/payment/info`](/reference/payment-info), для выплат —
[`/v1/payout/info`](/reference/payout-info).

***

## Почему подписи недостаточно

Подпись запроса живёт в окне **±5 минут** — она гасит переигрывание запроса старше пяти минут, но
**не** защищает от повторной обработки внутри окна. Реальную защиту от дублей дают `Idempotency-Key` и
`order_id`, а не подпись. Не полагайтесь на подпись как на защиту от двойного списания. →
[Аутентификация](/reference/basics-auth)

***

## Какие ошибки повторять, а какие — нет

| Класс                   | Коды                       | Повторять?       | Как                                               |
| ----------------------- | -------------------------- | ---------------- | ------------------------------------------------- |
| Ваша ошибка запроса     | `400 *`, большинство `403` | **Нет**          | Исправить запрос; повтор даст тот же результат.   |
| Аутентификация          | `401 *`                    | **Нет**          | Починить подпись/часы/ключ.                       |
| Не найдено              | `404 *`                    | **Нет**          | Проверить идентификатор.                          |
| Конфликт состояния      | `409 *`                    | **Смотря какой** | **Не** считайте любой `409` финальным — см. ниже. |
| Временная недоступность | `503`, `429`-подобная      | **Да**           | Экспоненциальный backoff.                         |
| Внутренняя              | `500`                      | **Да**           | Экспоненциальный backoff.                         |

### Не все 409 финальны

Самая дорогая ошибка в клиенте: трактовать **любой** `409` как финал и бросить операцию. Так вы
потеряете законную выплату. Среди `409` есть **ретраибельные**:

| Код `409`                   | Повторять?     | Почему                                                           |
| --------------------------- | -------------- | ---------------------------------------------------------------- |
| `payout.funds_maturing`     | **ДА, позже**  | Средства ещё дозревают. Пройдут подтверждения — выплата пройдёт. |
| `idempotency.in_progress`   | **ДА, позже**  | Запрос с этим `Idempotency-Key` ещё выполняется.                 |
| `payout.frozen`             | **ДА, позже**  | Сработал kill‑switch, защита временная.                          |
| `payout.insufficient_funds` | **Нет**        | Денег реально не хватает — нужно пополнение, а не ожидание.      |
| Прочие `409`                | **Не вслепую** | Сверьтесь через `*/info`.                                        |

Полный список кодов — [Справочник кодов ошибок](/reference/errors-catalog).

***

## Особый случай: `409 payout.funds_maturing`

Это **не** повод отказаться от выплаты навсегда. Средства ещё дозревают (см.
[Модель баланса](/guides/balance-and-funds)). Это временно: повторите позже с backoff — когда депозит
наберёт подтверждения, выплата пройдёт. Не путайте с `409 payout.insufficient_funds` (реально не
хватает денег — нужно пополнение, а не ожидание).

***

## Экспоненциальный backoff

Для `5xx`/`503`/rate limit повторяйте с растущей задержкой, а не в цикле без пауз.

Ключевая идея: повторять надо по **HTTP‑статусу** (`429`/`5xx`) и по **сетевым сбоям**, а не только по
`error.code`. Здесь `signed_post` / `signedPost` — ваш POST с подписью, возвращающий **сырой** ответ
(`requests.Response` в Python, `Response` из fetch в Node.js — в отличие от `call()`, который отдаёт
уже разобранное тело), чтобы был виден статус и заголовок `Retry-After`.

<CodeGroup>
  ```python Python theme={null}
  import time, random, requests, uuid

  RETRIABLE_HTTP = {429, 500, 502, 503, 504}   # временные статусы
  # ретраибельные бизнес-коды, приходящие с 4xx (в основном 409) — их НЕЛЬЗЯ хоронить
  RETRIABLE_CODES = {"payout.funds_maturing", "idempotency.in_progress", "payout.frozen"}

  def call_resilient(path, payload, max_attempts=6):
      delay = 1.0
      last = None
      key = str(uuid.uuid4())      # ОДИН ключ на все попытки этого действия
      for _ in range(max_attempts):
          try:
              # signed_post кладёт key в заголовок Idempotency-Key
              resp = signed_post(path, payload, idempotency_key=key)   # → requests.Response
          except requests.RequestException:
              # сетевой сбой/таймаут — временно, повторяем (идемпотентность по order_id спасает от дублей)
              time.sleep(delay); delay = min(delay * 2, 60) + random.uniform(0, 0.5); continue

          last = resp
          if 200 <= resp.status_code < 300:        # успех
              return resp.json()

          if resp.status_code in RETRIABLE_HTTP:    # временный статус
              wait = float(resp.headers.get("Retry-After", delay))   # уважаем Retry-After (напр. 429)
              time.sleep(min(wait, 60))
              delay = min(delay * 2, 60) + random.uniform(0, 0.5)
              continue

          # прочие 4xx — не повторяем; ИСКЛЮЧЕНИЕ — ретраибельные бизнес-коды (в т.ч. 409!)
          body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
          if body.get("error", {}).get("code") in RETRIABLE_CODES:
              time.sleep(delay); delay = min(delay * 2, 60) + random.uniform(0, 0.5); continue

          return body   # окончательная ошибка запроса — повтор не поможет

      if last is None:
          return None
      try:
          return last.json()
      except ValueError:            # не-JSON тело (напр., HTML от прокси на 502)
          return None
  ```

  ```js Node.js theme={null}
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const RETRIABLE_HTTP = new Set([429, 500, 502, 503, 504]);
  // ретраибельные бизнес-коды, приходящие с 4xx (в основном 409)
  const RETRIABLE_CODES = new Set([
    "payout.funds_maturing", "idempotency.in_progress", "payout.frozen",
  ]);

  // signedPost — ваша подписанная обёртка, возвращающая СЫРОЙ fetch-Response
  // (а не разобранный JSON), чтобы был виден HTTP-статус и заголовки. См.
  // «Как подписать запрос» (пример на Node.js).
  async function callResilient(path, payload, maxAttempts = 6) {
    let delay = 1000;
    let last = null;
    const key = crypto.randomUUID();      // ОДИН ключ на все попытки этого действия
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      let resp;
      try {
        resp = await signedPost(path, payload, { idempotencyKey: key }); // → заголовок Idempotency-Key
      } catch (e) {                              // сетевой сбой/таймаут — повторяем
        await sleep(delay);
        delay = Math.min(delay * 2, 60000) + Math.random() * 500;
        continue;
      }
      last = resp;

      if (resp.ok) return await resp.json();     // 2xx — успех

      if (RETRIABLE_HTTP.has(resp.status)) {     // 429 + 5xx — временное, ждём и повторяем
        const ra = parseFloat(resp.headers.get("Retry-After"));  // у 429 приходит Retry-After: 60
        await sleep(Number.isFinite(ra) ? Math.min(ra * 1000, 60000) : delay);
        delay = Math.min(delay * 2, 60000) + Math.random() * 500;
        continue;
      }

      // Прочее (4xx) — постоянная ошибка запроса, повтор не поможет.
      const body = await resp.json().catch(() => ({}));
      if (RETRIABLE_CODES.has(body.error?.code)) {  // ИСКЛЮЧЕНИЕ: ретраибельные 409
        await sleep(delay);
        delay = Math.min(delay * 2, 60000) + Math.random() * 500;
        continue;
      }
      return body;
    }
    return last ? await last.json().catch(() => null) : null;
  }
  ```
</CodeGroup>

<Note>
  **Почему по HTTP-статусу, а не по `error.code`?** Ответ `429` приходит в особом виде
  `{"state":1,"message":"rate limit exceeded"}` — **без** `error.code`, поэтому ветвиться на код
  бесполезно: 429 надо ловить именно по статусу и читать заголовок `Retry-After`.
</Note>

<Tip>
  **Джиттер** (случайная добавка к задержке) важен: без него много клиентов после сбоя ударят
  одновременно. Потолок задержки не даёт ретраям растягиваться бесконечно.
</Tip>

***

## Практические правила

* **`Idempotency-Key` генерируется до первой отправки и не меняется при ретраях.** Новый ключ на каждую
  попытку = никакой защиты.
* **`order_id` уникален и стабилен на операцию.** Один заказ → один `order_id`, и при ретраях — тот же.
  Не генерируйте новый на каждую попытку.
* **Таймаут не равен провалу.** Прежде чем считать операцию неудачной, повторите тот же запрос (тот же
  `Idempotency-Key` и `order_id`) или спросите `*/info`.
* **`409` — не повторяйте вслепую, но и не хороните.** `payout.funds_maturing`,
  `idempotency.in_progress`, `payout.frozen` — повторяйте позже; остальные — сначала выясните состояние
  через `*/info`.
* **Разделяйте временное и постоянное.** `5xx`/`503`/`429`/`funds_maturing` — ждите и повторяйте;
  `4xx` — чините запрос.
* **Храните сопоставление `order_id` ↔ ваш заказ** на своей стороне — это ваш якорь для сверки после
  любого сбоя.
* **Ограничьте число попыток** и логируйте исчерпание — бесконечный ретрай маскирует реальные проблемы.

***

## Как это сочетается с вебхуками

Ретраи касаются **исходящих** запросов. Для **входящих** вебхуков действует зеркальный принцип: они
доставляются at-least-once, поэтому ваш обработчик тоже должен быть идемпотентным (дедуп по
`uuid` + `status`). Устойчивость нужна с обеих сторон. → [Настройка вебхуков](/guides/webhooks-setup)

***

## Связанные страницы

<CardGroup cols={2}>
  <Card title="Идемпотентность" href="/reference/basics-idempotency" icon="arrow-right" horizontal />

  <Card title="Формат ответа и коды ошибок" href="/reference/basics-errors" icon="arrow-right" horizontal />

  <Card title="Справочник кодов ошибок" href="/reference/errors-catalog" icon="arrow-right" horizontal />

  <Card title="Модель баланса" href="/guides/balance-and-funds" icon="arrow-right" horizontal />

  <Card title="Настройка вебхуков" href="/guides/webhooks-setup" icon="arrow-right" horizontal />

  <Card title="Чек‑лист перед запуском" href="/guides/production-checklist" icon="arrow-right" horizontal />
</CardGroup>
