{"index":18,"slug":"editorial-2027-07-practice-reliability-capstone","title":"Retry без двойной записи: как связать timeout, 503 и идемпотентность","excerpt":"Разбираем, почему повтор HTTP-запроса нельзя включать одной настройкой. Сначала проверяем семантику операции, затем ограничиваем время, попытки и последствия сбоя.","contentHtml":"

Сервис отвечает медленно. Клиент ждёт 400 миллисекунд, получает timeout и отправляет тот же запрос ещё раз. В логах появляется один ответ, а в базе — две заявки. Первая запись завершилась, но ответ потерялся между сервером и клиентом. Цена ошибки — двойное списание, повторная доставка или ручное удаление лишней записи.

Есть и обратный симптом. Чтение каталога получает 503 Service Unavailable, клиент сразу сдаётся, а пользователь видит отказ, хотя зависимость восстановилась через секунду. Команда добавляет общий retry для всех запросов и исправляет один сценарий, но открывает другой: небезопасную операцию можно выполнить повторно.

Тезис простой: retry — это часть контракта операции, а не свойство сетевого клиента. Клиент должен знать, что он повторяет, сколько времени осталось, какой ответ разрешает повтор и как отличить неизвестный результат записи от подтверждённого отказа.

Механизм: ответ и результат — не одно и то же

HTTP-ответ сообщает клиенту о результате только тогда, когда клиент его получил. Таймаут ломает эту связь. Сервер мог не начать работу, мог завершить чтение или мог сохранить запись перед обрывом соединения. По одному исключению timeout нельзя выбрать безопасное действие.

У запроса есть две разные характеристики. Безопасный метод не меняет состояние сервера. Идемпотентная операция допускает повторение с тем же ожидаемым эффектом. GET обычно читается повторно. PUT может перезаписать ресурс по известному ключу. POST, который создаёт новый ресурс, нельзя повторять по умолчанию. Заголовок Idempotency-Key меняет правило только тогда, когда сервер действительно хранит ключ, результат и срок его действия.

Статус 503 не является командой «повтори». Он говорит, что сервис временно не готов обработать запрос. Заголовок Retry-After может задать паузу. Клиент всё равно должен проверить метод, deadline, лимит попыток и нагрузку на зависимость. Повтор через proxy может снова попасть в перегруженный origin.

Диагностика повтора HTTP-запроса
СимптомПричинаПроверкаДействие
После timeout появились две записиСервер принял POST, но клиент не получил ответСверить request id и журнал транзакцииНе повторять без ключа или проверки состояния
GET завершился после первого 503Клиент не различает чтение и записьПроверить метод, status и deadlineПовторить ограниченно с backoff
Три попытки вышли за времяЛимит попыток не связан с deadlineИзмерить запросы и паузыСчитать остаток времени перед каждой попыткой
Сервис перегружается после сбояКлиенты повторяют одновременноСопоставить rate retry, 503 и нагрузку originДобавить jitter, бюджет и circuit breaker
Причина ошибки исчезает в логахВсе исключения сведены к одному типуПроверить method, status, attempt и request idСохранить безопасный контекст без payload
Дерево решения для HTTP-повтора: deadline, метод, статус и окончательное действие
Безопасное решение начинается с deadline и семантики метода. Статус 503 не отменяет проверку побочного эффекта.

Минимальный пример с общим deadline

Ниже — учебный пример для локального сервера. Он показывает два ответа 503, затем 200. Сервер не моделирует потерю ответа после записи, балансировщик, очередь и реальную нагрузку. Результат примера нельзя выдавать за производственный замер.

async function getWithRetry(url, { maxAttempts = 3, deadlineMs = 1000 } = {}) { const deadline = Date.now() + deadlineMs; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('deadline exceeded'); const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(remaining) }); if (response.ok) return { attempt, status: response.status }; if (response.status !== 503 || attempt === maxAttempts) throw new Error('stop on status ' + response.status); await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); } }

Функция повторяет только GET. Она ограничивает суммарное время, а не умножает timeout на число попыток. В настоящем клиенте нужно отдельно обработать сетевую ошибку, проверить Retry-After, добавить случайную добавку к паузе и передать request id. Для POST эта функция не подходит: её сигнатура специально не принимает тело и метод.

Последовательность важна. Сначала проверяется остаток deadline. Затем клиент получает ответ. Успех возвращается сразу. 503 разрешает следующую попытку только при оставшемся времени и ненулевом бюджете. Другой статус останавливает цикл. Ошибка валидации или авторизации не превращается в поток бесполезных повторов.

Что делать с записью

Для записи нужно разделить неизвестный результат и явный отказ. Если соединение оборвалось до отправки тела, повтор иногда допустим. Если сервер мог принять тело, клиент должен сначала запросить состояние по естественному идентификатору или использовать ключ дедупликации. Нельзя считать отсутствие ответа доказательством отсутствия действия.

Idempotency key должен входить в контракт API. Сервер сохраняет ключ вместе с результатом и возвращает тот же результат при повторе того же ключа. Надо определить срок хранения, связь ключа с параметрами запроса и ответ при несовпадении параметров. Если клиент отправит тот же ключ с другим заказом, сервер должен отклонить запрос, а не изменить исходную операцию.

Логирование помогает расследованию, но не делает повтор безопасным. Записывайте метод, endpoint без секретных параметров, request id, idempotency key в обезличенном виде, номер попытки, статус и длительность. Не записывайте токены, платёжные данные и полный payload. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.

Порядок действий

  1. Опишите побочный эффект операции и ключ, по которому можно проверить состояние.
  2. Разделите методы и статусы: чтение, идемпотентная запись, создание и явный отказ.
  3. Задайте общий deadline для всей операции и передавайте остаток времени в каждый вызов.
  4. Разрешите retry только для подтверждённых сценариев. Для POST сначала зафиксируйте idempotency key и правила сервера.
  5. Ограничьте число попыток, суммарную задержку и долю трафика на повторы.
  6. Обработайте Retry-After, backoff и jitter. При росте 503 или превышении бюджета остановите повтор.
  7. Сохраните безопасный контекст попытки и отделите timeout от ответа сервера.
  8. Проверьте чтение после 503, timeout после принятой записи и отказ без повторной отправки.

Ограничения

Ни один клиент не узнает из timeout, выполнил ли сервер запись. Это ограничение протокола, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация требует поддержки на сервере. Если такого контракта нет, безопасное действие после timeout — остановиться и передать операцию на разбор, а не угадывать.

Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. QUIC и HTTP/2 могут менять транспортное поведение, однако транспорт не превращает создание заказа через POST в идемпотентную операцию.

Учебный сервер с фиксированными ответами не показывает реальные задержки, распределение нагрузки и работу нескольких клиентов. Поэтому он годится для проверки ветвления, но не для обещаний о доступности или времени ответа. Производственные числа нужно получать из наблюдений конкретной системы.

Проверяемый критерий готовности

Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.

Проверяемые источники

"}