intDataBlog
← ЖурналМаршруты обучения ↗

Повторы запросов: как пережить сбой и не усилить аварию

Таймаут, общий бюджет времени, ограничение попыток и jitter на примере одного межсервисного вызова.

Запрос завершился по таймауту. Самое очевидное решение — отправить его ещё раз. Но если так одновременно поступят тысячи клиентов, временный сбой превратится в новую волну нагрузки. Повтор помогает только тогда, когда у него есть предел и понятная причина.

Таймаут говорит о том, что клиент не дождался результата. Он не доказывает, что сервер ничего не сделал. Amazon отдельно подчёркивает этот риск и предлагает сочетать ограничения времени, backoff и случайный разброс задержек. AWS Builders Library

Сначала определить, что разрешено повторять

Представим сервис, который получает остаток товара из внешнего каталога. Чтение можно повторить, если правила API это допускают. Создание отгрузки — уже другая операция: первая попытка могла создать документ, а ответ потеряться. Для неё нужен идентификатор операции и серверная защита от дублей.

HTTP связывает идемпотентность с одинаковым предполагаемым эффектом нескольких одинаковых запросов. Сам по себе метод POST такого свойства не обещает; возможность повтора зависит от контракта операции. RFC 9110, раздел 9.2.2

Полезно составить маленькую таблицу решений: ошибки валидации исправляет вызывающая сторона, отсутствие прав не лечится ожиданием, временная недоступность может допускать повтор. Для ограничения частоты учитывают договорённости сервиса и Retry-After, если он предусмотрен. Универсальное правило «повторять все 5xx» слишком грубое: важен смысл конкретного вызова.

Один бюджет на всю операцию

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

Ниже учебная функция планирует паузы. Она не отправляет запросы и не является готовым HTTP-клиентом:

from random import Random

def retry_delays(attempts, base, cap, rng):
    """attempts includes the initial call; values are seconds."""
    for retry in range(max(0, attempts - 1)):
        upper = min(cap, base * 2 ** retry)
        yield rng.uniform(0, upper)

pauses = list(retry_delays(4, 0.1, 0.8, Random(7)))
assert len(pauses) == 3
assert all(0 <= pause <= limit
           for pause, limit in zip(pauses, (0.1, 0.2, 0.4)))

Перед очередной попыткой настоящий клиент проверяет отмену и остаток бюджета. Таймаут вызова не должен превышать этот остаток. Если пауза съела всё время, новую попытку не начинают. Фиксированное зерно генератора здесь нужно только для воспроизводимого примера; одинаковое зерно во всех рабочих клиентах снова синхронизирует их поведение.

Где прячется умножение нагрузки

Рассмотрим цепочку из трёх сервисов. Если каждый слой делает до трёх попыток, один верхний запрос способен породить до 27 обращений к нижней зависимости. Это арифметика учебного примера, а не характеристика конкретной платформы.

Поэтому владелец повторов должен быть явным. Иногда им становится ближайший к зависимости клиент, иногда оркестратор всей операции. Важно, чтобы остальные уровни не добавляли незаметные циклы поверх него. Ключ операции при повторе сохраняется; идентификатор отдельной сетевой попытки, напротив, меняется.

Как проверить, что стало лучше

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

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