Таймаут и ключ идемпотентности
Этот блок про то, как один возврат остаётся одним при любом числе попыток: кто создаёт ключ идемпотентности, где он живёт, что будет, если два запроса придут с ним одновременно, и что делать, если результат так и остался неизвестен.
Агент Northwind вызывает issue_refund по заказу 4417 на $38.00. Платёжный сервис не отвечает до таймаута. Покупатель пишет: «попробуй ещё», и модель снова просит возврат. Первый возврат мог уже пройти, так что должен отправить ваш инструмент?
Бланк на столе
В одном из прошлых блоков ключ сравнивался с номером заявки, который вы называете банку. Добавим деталь. Прежде чем звонить, вы записываете номер на бумажный бланк и оставляете его на столе. Кто бы ни звонил следующим, он читает номер с этого бланка: вы при второй попытке, вы же после слов покупателя «попробуй ещё» или коллега из завтрашней смены.
В вашей системе бланк — это строка в таблице базы данных. Одна строка равна одной операции возврата.
Вот порядок событий:
- Модель просит возврат. Это tool call
toolu_01. - Ваш инструмент выполняет проверки, создаёт строку с новым ключом и фиксирует её в базе. Запрос на оплату ещё не отправлен.
- Ваш инструмент отправляет возврат с этим ключом и получает таймаут. Он делает retry с backoff и тем же ключом, в пределах лимита, и сообщает, что возврат не подтверждён.
- Покупатель пишет «попробуй ещё», и модель просит снова. Это новый tool call,
toolu_02, со своим id. - Ваш инструмент находит открытую строку по заказу 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 | Новый 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 хранит ключи не меньше 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, можно будет сопоставить с вашей строкой.
Покупатель получает правду:
Мы пока не можем подтвердить ваш возврат. Коллега проверяет его и ответит в этом чате.
Новый ключ при неизвестном исходе может заплатить дважды.
Вывод
- Ваш код создаёт ключ до первой попытки и фиксирует его вместе с операцией, поэтому «попробуй ещё» даёт новый tool call и тот же ключ.
- Пересекающийся запрос ждёт и повторяет с этим ключом.
- Срок хранения у провайдера — это крайний срок для ваших повторов.
- Если исход так и не прояснился, прекратите отправку, скажите покупателю, что идёт проверка, и передайте дело человеку.