К курсу

Цикл агента

Один шаг цикла агентаПрактика: один шаг циклаЦикл в кодеПрактика: цикл в кодеОстановка и повторыПрактика: остановка и повторыЧто решает только кодПрактика: что решает код

Надёжность в проде

Таймаут и ключ идемпотентностиПрактика: таймаут и ключ идемпотентности

Устройство агентов

Workflow или агентПрактика: workflow или агентReAct и plan-and-executeПрактика: ReAct и plan-and-executeОдин агент или ройПрактика: один агент или рой

Контекст и стоимость

Почему растёт счёт за агентаПрактика: токены и кэшКонтекст-инженерияПрактика: управление окномПамять агентаПрактика: что агент хранит

Инструменты и протоколы

MCPПрактика: MCP
←→ENВойти
Агенты › Надёжность в проде

Таймаут и ключ идемпотентности

Опирается наЧто решает только код

Этот блок про то, как один возврат остаётся одним при любом числе попыток: кто создаёт ключ идемпотентности, где он живёт, что будет, если два запроса придут с ним одновременно, и что делать, если результат так и остался неизвестен.

Агент Northwind вызывает issue_refund по заказу 4417 на $38.00. Платёжный сервис не отвечает до таймаута. Покупатель пишет: «попробуй ещё», и модель снова просит возврат. Первый возврат мог уже пройти, так что должен отправить ваш инструмент?

Бланк на столе

В одном из прошлых блоков ключ сравнивался с номером заявки, который вы называете банку. Добавим деталь. Прежде чем звонить, вы записываете номер на бумажный бланк и оставляете его на столе. Кто бы ни звонил следующим, он читает номер с этого бланка: вы при второй попытке, вы же после слов покупателя «попробуй ещё» или коллега из завтрашней смены.

В вашей системе бланк — это строка в таблице базы данных. Одна строка равна одной операции возврата.

Два tool call с разными id приходят к одной строке, поэтому платёжный сервис видит один ключ.
Два tool call с разными id приходят к одной строке, поэтому платёжный сервис видит один ключ.

Вот порядок событий:

  1. Модель просит возврат. Это tool call toolu_01.
  2. Ваш инструмент выполняет проверки, создаёт строку с новым ключом и фиксирует её в базе. Запрос на оплату ещё не отправлен.
  3. Ваш инструмент отправляет возврат с этим ключом и получает таймаут. Он делает retry с backoff и тем же ключом, в пределах лимита, и сообщает, что возврат не подтверждён.
  4. Покупатель пишет «попробуй ещё», и модель просит снова. Это новый tool call, toolu_02, со своим id.
  5. Ваш инструмент находит открытую строку по заказу 4417 на $38.00 и отправляет тот же ключ.

Строка:

{
  "operation_id": "op_5521",
  "order_id": "4417",
  "amount_cents": 3800,
  "idempotency_key": "c41f7a2e-9b3d-4e16-a0c7-5d2f81e6b7aa",
  "status": "open",
  "attempts": 3
}

От того, откуда взят ключ, зависит, что сделает «попробуй ещё»:

Откуда ключ

После «попробуй ещё»

Из аргументов модели

То, что модель напишет на этот раз

Из id tool_use

Новый id, значит, новый ключ и, возможно, второй возврат

Из переменной в процессе

Пропадает после перезапуска, значит, новый ключ

Из строки, зафиксированной до первой попытки

Тот же ключ

Рекомендация Stripe совпадает с последней строкой: если повторы идут через разные вызовы SDK или перезапуски процесса, создайте ключ для операции, сохраните его и передавайте при каждой попытке.

У поиска строки своя гонка. Два tool call могут оба посмотреть, оба ничего не найти и оба создать строку, и ключей станет два. Уникальный индекс позволяет базе выбрать один:

CREATE UNIQUE INDEX one_open_refund
  ON refund_ops (order_id, amount_cents)
  WHERE status IN ('open', 'unknown');

Проигравшая вставка получает конфликт, и ваш код читает строку победителя.

Подтверждённый возврат закрывает строку, и определённый отказ тоже. Если покупатель напишет «попробуй ещё» уже после подтверждённого возврата, может открыться новая операция, поэтому проверка суммы у кассы должна учитывать уже сделанные возвраты.

Два запроса в один момент

Один ключ не мешает двум запросам пересечься. Retry может уйти, пока первая попытка ещё выполняется. Представьте двух коллег, которые одновременно звонят в банк с одним номером. Клерк говорит второму: эта заявка в работе, перезвоните.

Второму запросу говорят подождать, он повторяет с тем же ключом и получает результат первого.
Второму запросу говорят подождать, он повторяет с тем же ключом и получает результат первого.

В API v1 Stripe пересекающийся запрос получает 409, и результат для него не сохраняется. Stripe советует подождать и повторить с тем же ключом и параметрами, потому что новый ключ может запустить вторую операцию. Черновик IETF про заголовок Idempotency-Key, который пока не стал RFC, тоже говорит, что серверу в этом случае следует отвечать 409.

Если такой эндпоинт пишете вы сами, захватывайте ключ одной атомарной вставкой до начала работы.

API v1 Stripe ещё возвращает ошибку, если тот же ключ пришёл с другими параметрами. Изменённая сумма — это другая операция, и новый ключ Stripe велит брать, только убедившись, что исходная операция не прошла и уже не выполняется.

Сколько помнит банк

Банк не хранит свой реестр вечно. Когда страницы больше нет, ваш номер ему ничего не говорит.

Срок хранения в API v1 Stripe. У других API свои сроки.
Срок хранения в API v1 Stripe. У других API свои сроки.

API v1 Stripe хранит ключи не меньше 24 часов и может удалить их после этого. Ключ, использованный повторно после удаления, может запустить новый запрос. У API v2 другое поведение при повторах и окно в 30 дней. Считайте срок того API, который вызываете, крайним сроком для любого retry, в том числе из прогона, простоявшего день на паузе. Если к концу 24 часов исход всё ещё неизвестен, Stripe велит не повторять запрос ни со старым ключом, ни с новым.

Когда никто не знает

Тот же ключ не всегда даёт ответ. В API v1 Stripe сохраняет 500 и возвращает этот 500 при повторах с этим ключом, пока хранит ключ, а исход запроса с 500 велит считать неопределённым. Бюджет повторов тоже может просто закончиться.

Тогда инструмент помечает строку unknown и больше ничего не отправляет. Любой следующий tool call по этому возврату получает такой результат:

{
  "status": "checking",
  "operation_id": "op_5521",
  "note": "Outcome unknown. A person is checking. Do not say the refund succeeded or failed."
}

Один tool call делает несколько попыток и сообщает unconfirmed, и покупатель может попросить снова. У операции свой лимит на все вызовы, и при его достижении строка становится unknown.

Код, который держит это правило:

MAX_PER_CALL, MAX_PER_OPERATION = 3, 6

def send_refund(order, amount):
    op = refund_ops.open_or_get(order.id, amount)  # committed before any attempt
    if op.status == "unknown":
        return {"status": "checking", "operation_id": op.id}
    for attempt in range(MAX_PER_CALL):
        if op.attempts >= MAX_PER_OPERATION:
            break
        refund_ops.count_attempt(op)
        try:
            refund = payments.refund(order.id, amount, idempotency_key=op.key)
        except ServerError:  # a saved 500 only repeats itself
            refund_ops.mark_unknown(op)
            return {"status": "checking", "operation_id": op.id}
        except (Timeout, KeyInProgress):
            sleep(backoff_with_jitter(attempt))
            continue
        refund_ops.close(op, refund.id)
        return {"status": "refunded", "refund_id": refund.id}
    if op.attempts >= MAX_PER_OPERATION:
        refund_ops.mark_unknown(op)
        return {"status": "checking", "operation_id": op.id}
    return {"status": "unconfirmed", "operation_id": op.id}

Что произошло на самом деле, затем выясняет человек или задача сверки. Stripe предлагает получить объект, посмотреть запрос в Dashboard, использовать события webhook, а если это не помогло, написать в поддержку. Положите локальный идентификатор в metadata при создании возврата, и объект, пришедший через webhook, можно будет сопоставить с вашей строкой.

Покупатель получает правду:

Мы пока не можем подтвердить ваш возврат. Коллега проверяет его и ответит в этом чате.

Новый ключ при неизвестном исходе может заплатить дважды.

Вывод

Глубже

Advanced error handling (Stripe)Что делать при каждом классе ошибок, включая 409 и исход, который остался неизвестным.Designing robust and predictable APIs with idempotency (Stripe)Три места, где может сорваться вызов, и почему клиент может не узнать, что произошло.The Idempotency-Key HTTP Header Field (IETF draft)Как серверу обращаться с повторами, одновременными запросами и изменённым телом. Пока черновик.Idempotent requests (Stripe API reference)Точное поведение API v1 и API v2.
‹ Назад
Предложить улучшениеНашёл ошибку