{"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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После timeout появились две записи | Сервер принял POST, но клиент не получил ответ | Сверить request id и журнал транзакции | Не повторять без ключа или проверки состояния |
| GET завершился после первого 503 | Клиент не различает чтение и запись | Проверить метод, status и deadline | Повторить ограниченно с backoff |
| Три попытки вышли за время | Лимит попыток не связан с deadline | Измерить запросы и паузы | Считать остаток времени перед каждой попыткой |
| Сервис перегружается после сбоя | Клиенты повторяют одновременно | Сопоставить rate retry, 503 и нагрузку origin | Добавить jitter, бюджет и circuit breaker |
| Причина ошибки исчезает в логах | Все исключения сведены к одному типу | Проверить method, status, attempt и request id | Сохранить безопасный контекст без payload |
Ниже — учебный пример для локального сервера. Он показывает два ответа 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. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.
Ни один клиент не узнает из timeout, выполнил ли сервер запись. Это ограничение протокола, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация требует поддержки на сервере. Если такого контракта нет, безопасное действие после timeout — остановиться и передать операцию на разбор, а не угадывать.
Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. QUIC и HTTP/2 могут менять транспортное поведение, однако транспорт не превращает создание заказа через POST в идемпотентную операцию.
Учебный сервер с фиксированными ответами не показывает реальные задержки, распределение нагрузки и работу нескольких клиентов. Поэтому он годится для проверки ветвления, но не для обещаний о доступности или времени ответа. Производственные числа нужно получать из наблюдений конкретной системы.
Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.