2 lines
17 KiB
JSON
2 lines
17 KiB
JSON
{"index":18,"slug":"editorial-2027-07-practice-reliability-capstone","title":"Retry без двойной записи: как связать timeout, 503 и идемпотентность","excerpt":"Разбираем, почему повтор HTTP-запроса нельзя включать одной настройкой. Сначала проверяем семантику операции, затем ограничиваем время, попытки и последствия сбоя.","contentHtml":"<p>Сервис отвечает медленно. Клиент ждёт 400 миллисекунд, получает timeout и отправляет тот же запрос ещё раз. В логах появляется один ответ, а в базе — две заявки. Первая запись завершилась, но ответ потерялся между сервером и клиентом. Цена ошибки — двойное списание, повторная доставка или ручное удаление лишней записи.</p><p>Есть и обратный симптом. Чтение каталога получает <code>503 Service Unavailable</code>, клиент сразу сдаётся, а пользователь видит отказ, хотя зависимость восстановилась через секунду. Команда добавляет общий retry для всех запросов и исправляет один сценарий, но открывает другой: небезопасную операцию можно выполнить повторно.</p><p>Тезис простой: retry — это часть контракта операции, а не свойство сетевого клиента. Клиент должен знать, что он повторяет, сколько времени осталось, какой ответ разрешает повтор и как отличить неизвестный результат записи от подтверждённого отказа.</p><h2>Механизм: ответ и результат — не одно и то же</h2><p>HTTP-ответ сообщает клиенту о результате только тогда, когда клиент его получил. Таймаут ломает эту связь. Сервер мог не начать работу, мог завершить чтение или мог сохранить запись перед обрывом соединения. По одному исключению <code>timeout</code> нельзя выбрать безопасное действие.</p><p>У запроса есть две разные характеристики. Безопасный метод не просит изменить состояние ресурса; сервер при этом всё равно может вести журнал, считать метрики или выполнять другие побочные действия. Идемпотентная операция допускает повторение с тем же намеренным эффектом на ресурсе. <code>GET</code> безопасен и идемпотентен по HTTP-семантике, а <code>PUT</code> и <code>DELETE</code> относятся к идемпотентным методам; конкретный endpoint всё равно должен соблюдать эту семантику. <code>POST</code>, который создаёт новый ресурс, не следует автоматически повторять без отдельного контракта. Заголовок <code>Idempotency-Key</code> меняет правило только тогда, когда сервер действительно хранит ключ, результат и срок его действия.</p><p>Статус <code>503</code> не является командой «повтори». Он говорит, что сервис временно не готов обработать запрос. Заголовок <code>Retry-After</code> может задать паузу. Клиент всё равно должен проверить метод, deadline, лимит попыток и нагрузку на зависимость. Повтор через proxy может снова попасть в перегруженный origin.</p><table><caption>Диагностика повтора HTTP-запроса</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>После timeout появились две записи</td><td>Сервер принял POST, но клиент не получил ответ</td><td>Сверить request id и журнал транзакции</td><td>Не повторять без ключа или проверки состояния</td></tr><tr><td>GET завершился после первого 503</td><td>Клиент не различает чтение и запись</td><td>Проверить метод, status и deadline</td><td>Повторить ограниченно с backoff</td></tr><tr><td>Три попытки вышли за время</td><td>Лимит попыток не связан с deadline</td><td>Измерить запросы и паузы</td><td>Считать остаток времени перед каждой попыткой</td></tr><tr><td>Сервис перегружается после сбоя</td><td>Клиенты повторяют одновременно</td><td>Сопоставить rate retry, 503 и нагрузку origin</td><td>Добавить jitter, бюджет и circuit breaker</td></tr><tr><td>Причина ошибки исчезает в логах</td><td>Все исключения сведены к одному типу</td><td>Проверить method, status, attempt и request id</td><td>Сохранить безопасный контекст без payload</td></tr></tbody></table><figure><img src='/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg' alt='Дерево решения для HTTP-повтора: deadline, метод, статус и окончательное действие' loading='lazy' /><figcaption>Безопасное решение начинается с deadline и семантики метода. Статус 503 не отменяет проверку побочного эффекта.</figcaption></figure><h2>Минимальный пример с общим deadline</h2><p>Ниже — учебная клиентская функция для локального endpoint. Если сервер отвечает двумя <code>503</code>, а затем <code>200</code>, функция сделает три попытки в пределах общего deadline. Она не моделирует потерю ответа после записи, балансировщик, очередь и реальную нагрузку. Результат примера нельзя выдавать за производственный замер.</p><pre><code>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); const delay = Math.min(50 * attempt, Math.max(0, deadline - Date.now())); await new Promise((resolve) => setTimeout(resolve, delay)); } }</code></pre><p>Функция повторяет только <code>GET</code>. Она ограничивает суммарное время, а не умножает timeout на число попыток, и не оставляет backoff за пределами deadline. <code>AbortSignal.timeout</code> требует среды, где этот API доступен; для другой среды нужен эквивалентный механизм отмены. В настоящем клиенте нужно отдельно обработать сетевую ошибку, проверить <code>Retry-After</code>, добавить случайную добавку к паузе и передать request id. Для <code>POST</code> эта функция не подходит: её сигнатура специально не принимает тело и метод.</p><p>Последовательность важна. Сначала проверяется остаток deadline. Затем клиент получает ответ. Успех возвращается сразу. <code>503</code> разрешает следующую попытку только при оставшемся времени и ненулевом бюджете. Другой статус останавливает цикл. Ошибка валидации или авторизации не превращается в поток бесполезных повторов.</p><h2>Что делать с записью</h2><p>Для записи нужно разделить неизвестный результат и явный отказ. Повтор допустим только при доказательстве из контракта: сервер не получил запрос, операция идемпотентна или сервер умеет распознать тот же ключ и вернуть прежний результат. Состояние соединения само по себе такого доказательства не даёт. Если сервер мог принять тело, клиент должен сначала запросить состояние по естественному идентификатору или использовать ключ дедупликации. Нельзя считать отсутствие ответа доказательством отсутствия действия.</p><p>Idempotency key должен входить в контракт API. В одном из вариантов контракта сервер сохраняет ключ вместе с параметрами и результатом и возвращает тот же результат при повторе того же ключа. Надо определить срок хранения, связь ключа с параметрами запроса и ответ при несовпадении параметров. Если клиент отправит тот же ключ с другим заказом, сервер должен отклонить запрос, а не изменить исходную операцию.</p><p>Логирование помогает расследованию, но не делает повтор безопасным. Записывайте метод, endpoint без секретных параметров, request id, idempotency key в обезличенном виде, номер попытки, статус и длительность. Не записывайте токены, платёжные данные и полный payload. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.</p><h2>Порядок действий</h2><ol><li>Опишите побочный эффект операции и ключ, по которому можно проверить состояние.</li><li>Разделите методы и статусы: чтение, идемпотентная запись, создание и явный отказ.</li><li>Задайте общий deadline для всей операции и передавайте остаток времени в каждый вызов.</li><li>Разрешите retry только для подтверждённых сценариев. Для POST сначала зафиксируйте idempotency key и правила сервера.</li><li>Ограничьте число попыток, суммарную задержку и долю трафика на повторы.</li><li>Обработайте Retry-After, backoff и jitter. При росте 503 или превышении бюджета остановите повтор.</li><li>Сохраните безопасный контекст попытки и отделите timeout от ответа сервера.</li><li>Проверьте чтение после 503, timeout после принятой записи и отказ без повторной отправки.</li></ol><h2>Ограничения</h2><p>Из одного timeout клиент не узнает, выполнил ли сервер запись. Это ограничение наблюдаемого результата, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация — поддержки на сервере. Если такого контракта нет, автоматический клиент не должен повторять запрос вслепую: безопаснее остановиться и передать операцию на разбор.</p><p>Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. Транспортный протокол также не превращает создание заказа через <code>POST</code> в идемпотентную операцию.</p><p>Учебный сервер с фиксированными ответами не показывает реальные задержки, распределение нагрузки и работу нескольких клиентов. Поэтому он годится для проверки ветвления, но не для обещаний о доступности или времени ответа. Производственные числа нужно получать из наблюдений конкретной системы.</p><h2>Проверяемый критерий готовности</h2><p>Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.</p><h2>Проверяемые источники</h2><ul><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110 — HTTP Semantics</a> — IETF Standards Track, 2022. Определяет семантику идемпотентных методов и Retry-After; не выбирает retry-политику конкретного API.</li><li><a href='https://docs.stripe.com/api/idempotent_requests' target='_blank' rel='noopener noreferrer'>Stripe API — Idempotent requests</a> — официальный пример серверной дедупликации по ключу, сохранения результата и проверки параметров. Это контракт Stripe, а не универсальное правило для любого API.</li><li><a href='https://nodejs.org/api/globals.html#abortsignaltimeoutdelay' target='_blank' rel='noopener noreferrer'>Node.js — AbortSignal.timeout()</a> — официальная документация API отмены по времени. Доступность и поведение нужно сверить с версией runtime проекта.</li></ul>"}
|