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

# Идемпотентность

> Безопасно повторяйте запрос в чат без второго ответа и второго списания.

Оба эндпоинта чата, `POST /v1/agents/{agent_id}/chat` и `POST /v1/agents/{agent_id}/chat/stream`, **требуют** заголовок `Idempotency-Key`. Запрос без него — это `422 validation_error`.

## Выбор ключа

Любое значение длиной 1-255 символов, уникальное для логического запроса. Проще всего — UUID, сгенерированный в момент, когда вы решили отправить сообщение. Ключи идемпотентности привязаны к вашему API-ключу, поэтому о коллизиях с другими API-ключами беспокоиться не нужно.

```http theme={null}
Idempotency-Key: 7f20a5f0-5dde-48cf-94aa-38acbbd218e0
```

## Что возвращает повтор

Повтор с тем же `Idempotency-Key` **и тем же телом** возвращает сохранённый результат: исходный `request_id`, то же сообщение ассистента и тот же `usage`. Второй ответ не генерируется, и ничего не списывается повторно.

Записи живут **24 часа**. После этого тот же ключ начинает новый запрос.

<Note>
  Сохранённый результат — это то, что рантайм и так сохранил: сообщение ассистента, помеченное идентификатором запроса, и расход LLM, списанный под этим идентификатором. Поэтому повтор никогда не вызывает модель и не трогает баланс, даже если первая попытка оборвалась на полпути.
</Note>

## Конфликты

| Статус | Код                       | Когда                                                                                                                      |
| ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `409`  | `idempotency_in_progress` | Первая попытка ещё выполняется. Подождите `Retry-After` секунд (по умолчанию 2) и повторите с тем же ключом.               |
| `409`  | `idempotency_conflict`    | Тот же ключ отправлен с другим телом (другой `external_user_id` или `message`). Для нового запроса используйте новый ключ. |

## Стриминг и обрывы соединения

На потоковом эндпоинте правила те же. Если клиент отключился посреди стрима, ответ продолжает генерироваться и сохраняется. Повтор с тем же ключом стримит `response.created`, а сразу за ним `response.completed` с сохранённым результатом, без воспроизведения дельт. См. [Стриминг](/ru/guides/streaming).

## Рекомендуемый цикл повторов

<CodeGroup>
  ```bash curl theme={null}
  # One Idempotency-Key for the whole logical request; retried on transient codes.
  key=$(uuidgen)
  for attempt in 1 2 3 4 5; do
    status=$(curl -s -o body.json -D headers.txt -w '%{http_code}' \
      -X POST "$CHATTLER_BASE_URL/v1/agents/$CHATTLER_AGENT_ID/chat" \
      -H "Authorization: Bearer $CHATTLER_API_KEY" \
      -H "Idempotency-Key: $key" \
      -H "Content-Type: application/json" \
      -d '{"external_user_id": "customer-123", "message": "I want to know the price"}')
    if [ "$status" -lt 400 ]; then cat body.json; break; fi
    case "$(jq -r '.error.code' body.json)" in
      idempotency_in_progress|conversation_busy|rate_limit_exceeded|api_rate_limit_unavailable|service_unavailable)
        sleep "$(grep -i '^Retry-After:' headers.txt | tr -d '\r' | awk '{print $2}')" ;;
      *) cat body.json; break ;;
    esac
  done
  ```

  ```python Python theme={null}
  import time
  import uuid

  import httpx


  def chat_with_retry(external_user_id: str, message: str, attempts: int = 5) -> dict:
      idempotency_key = str(uuid.uuid4())  # one key for the whole logical request
      for _ in range(attempts):
          response = httpx.post(
              f"{BASE_URL}/v1/agents/{AGENT_ID}/chat",
              headers={**HEADERS, "Idempotency-Key": idempotency_key},
              json={"external_user_id": external_user_id, "message": message},
              timeout=120,
          )
          if response.status_code < 400:
              return response.json()
          code = response.json()["error"]["code"]
          if code in ("idempotency_in_progress", "conversation_busy", "rate_limit_exceeded",
                      "api_rate_limit_unavailable", "service_unavailable"):
              time.sleep(float(response.headers.get("Retry-After", "2")))
              continue
          raise RuntimeError(code)
      raise TimeoutError("gave up")
  ```

  ```javascript Node theme={null}
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const RETRYABLE = new Set([
    "idempotency_in_progress",
    "conversation_busy",
    "rate_limit_exceeded",
    "api_rate_limit_unavailable",
    "service_unavailable",
  ]);

  async function chatWithRetry(externalUserId, message, attempts = 5) {
    const idempotencyKey = crypto.randomUUID(); // one key for the whole logical request
    for (let i = 0; i < attempts; i++) {
      const response = await fetch(`${baseUrl}/v1/agents/${agentId}/chat`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Idempotency-Key": idempotencyKey,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ external_user_id: externalUserId, message }),
      });
      const body = await response.json();
      if (response.ok) return body;
      if (!RETRYABLE.has(body.error.code)) throw new Error(body.error.code);
      await sleep(Number(response.headers.get("Retry-After") ?? 2) * 1000);
    }
    throw new Error("gave up");
  }
  ```
</CodeGroup>
