CLODEx.
ПРАКТИКА / CLODEx

HTTP 429 и Retry-After: очередь ожидания без шторма повторных запросов

Ответ 429 сообщает о временном ограничении частоты запросов, но не определяет, что повтор безопасен или обязательно завершится успехом. Retry-After может отсутствовать, быть задан как число секунд или HTTP-date; некорректные и слишком большие для локального бюджета значения останавливают планирование. Scheduler должен отделять планирование от отправки, использовать монотонные часы для deadline, допускать не более одной активной попытки для одной задачи и завершать работу при исчерпании попыток или времени. Неопределённый timeout следует обрабатывать отдельно: без подтверждённого результата нельзя автоматически включать его в ветку повтора 429.

Редакционная проверка · / Диагностика API

Что означает ответ 429

RFC 6585 описывает статус 429 Too Many Requests как сигнал о превышении некоторого ограничения частоты. Ответ может содержать Retry-After, но его отсутствие не означает, что повтор разрешён в любой момент. Согласно HTTP-семантике, Retry-After может быть задержкой в секундах или HTTP-date. При отсутствии Retry-After scheduler должен применить явно заданную локальную политику, например ограниченную задержку или остановку с передачей решения оператору. Это не следует выдавать за указание сервера. Некорректное значение Retry-After нужно считать недействительным, а не пытаться угадать его смысл. Если серверное ожидание превышает max_wait, остановите задачу или передайте её оператору. Нельзя уменьшать Retry-After до локального cap и отправлять раньше указанного сервером срока. Если рассчитанное ожидание выходит за абсолютный deadline задачи, повтор не планируется. Дедлайн должен проверяться перед постановкой в очередь и непосредственно перед возможной отправкой. HTTP-date зависит от часов. Для измерения ожидания используйте монотонные часы, а wall-clock — только для разбора даты. При наличии рассинхронизации нельзя обещать точный момент восстановления. Если ответ содержит дату сервера, её можно использовать как дополнительный ориентир для оценки смещения, но результат всё равно должен быть ограничен локальным deadline.

Одна очередь вместо множества таймеров

Планирование и сетевую отправку лучше разделить. Планировщик принимает уже полученный результат 429 и возвращает одно из решений: wait с моментом следующей проверки, stop с причиной или escalate для ручного решения. Он не открывает соединения, не запускает HTTP-клиент и не утверждает, что будущий запрос будет успешным. Для каждой логической задачи нужен стабильный task_id или иной ключ дедупликации. В состоянии задачи храните число уже использованных попыток, абсолютный deadline, время готовности и признак active. При поступлении нового события для active-задачи нельзя создавать второй повторный таймер. При дублирующих событиях храните одну запись; новый более поздний срок нельзя заменять старым более ранним запуском. Пример сохраняет максимум из двух сроков. Пример ниже не включает сетевую отправку и рассчитан на одного локального владельца состояния. В многопоточном или распределённом worker проверка и изменение состояния требуют блокировки/транзакции. Функции получают уже проверенные числовые лимиты и часы; полноценная очередь и сохранение состояния не реализованы. Значения max_attempts, max_wait и fallback_delay должны задаваться политикой конкретного приложения, а не восприниматься как универсальные параметры.

python
import re
from email.utils import parsedate_to_datetime


class Decision:
    def __init__(self, kind, ready_at=None, reason=None):
        self.kind, self.ready_at, self.reason = kind, ready_at, reason


class RetryState:
    def __init__(self, deadline_mono, attempts=1):
        self.deadline_mono = deadline_mono
        self.attempts = attempts  # Includes the request that returned 429.
        self.active = False
        self.ready_at = None


def parse_retry_after(value, wall_now):
    if value is None:
        return "missing", None
    text = value.strip()
    if len(text) > 128:
        return "invalid", None
    if re.fullmatch(r"[0-9]+", text):
        return "valid", int(text)
    try:
        date = parsedate_to_datetime(text)
        if date.tzinfo is None:
            return "invalid", None
        return "valid", max(0, date.timestamp() - wall_now)
    except (ValueError, TypeError, OverflowError):
        return "invalid", None


def plan_after_429(state, retry_after, *, wall_now, mono_now,
                   safe_to_retry, uncertain=False, max_attempts=3,
                   max_wait=60, fallback_delay=2):
    # Single-threaded example: a shared scheduler needs a lock/transaction.
    def stop(reason):
        state.ready_at = None
        return Decision("stop", reason=reason)
    if uncertain:
        return stop("reconcile_unknown_outcome")
    if not safe_to_retry:
        return stop("retry_not_authorized_by_contract")
    if state.active:
        return Decision("stop", reason="already_active")
    if state.attempts >= max_attempts:
        return stop("attempt_budget_exhausted")
    if mono_now >= state.deadline_mono:
        return stop("deadline_exhausted")
    kind, delay = parse_retry_after(retry_after, wall_now)
    if kind == "invalid":
        return stop("invalid_retry_after")
    if kind == "missing":
        delay = fallback_delay  # An explicit local policy, not a server promise.
    if delay < 0 or delay > max_wait:
        return stop("wait_budget_exhausted")  # Never clamp a server delay down.
    ready = max(mono_now + delay, state.ready_at or mono_now)
    if ready >= state.deadline_mono:
        return stop("deadline_exhausted")
    state.ready_at = ready  # One slot per task, not a new timer each time.
    return Decision("wait", ready_at=ready)


def claim_due(state, *, mono_now, max_attempts=3):
    # Check and update must be atomic if multiple workers share the state.
    if state.active or state.ready_at is None or mono_now < state.ready_at:
        return False
    if mono_now >= state.deadline_mono or state.attempts >= max_attempts:
        state.ready_at = None
        return False
    state.active, state.ready_at = True, None
    state.attempts += 1
    return True  # No HTTP request is made by this function.

Разбор Retry-After и конечный бюджет

Разбор значения должен различать как минимум четыре случая: заголовок отсутствует, содержит корректное delta-seconds, содержит корректный HTTP-date или не может быть разобран. Для delta-seconds проверяйте формат и не допускайте отрицательного результата. Для HTTP-date переводите дату в задержку относительно текущего wall-clock, затем используйте монотонное время для ожидания. Не следует превращать некорректный заголовок в бесконечный backoff или в немедленный бесконечный цикл. В этом примере некорректное значение приводит к stop; fallback_delay используется только при отсутствии заголовка. Парсер даты опирается на email.utils и не является строгим валидатором всего HTTP-поля; некорректные или неоднозначные даты следует отклонять по контракту клиента. В любом варианте должны действовать max_attempts и абсолютный deadline. Ограничение времени нужно считать частью контракта планировщика: если Retry-After указывает срок позже deadline, функция возвращает stop, а не ставит задачу в очередь. При повторных 429 можно применять отдельный предел общего времени ожидания; каждый новый ответ не должен продлевать его сверх исходного deadline.

python
from datetime import datetime, timezone

wall_now = datetime(2026, 11, 7, tzinfo=timezone.utc).timestamp()
state = RetryState(deadline_mono=200, attempts=1)
decision = plan_after_429(
    state, "10", wall_now=wall_now, mono_now=100, safe_to_retry=True
)
assert decision.kind == "wait" and decision.ready_at == 110
assert not claim_due(state, mono_now=109)
assert claim_due(state, mono_now=110)
assert not claim_due(state, mono_now=110)  # Already active.

Параллельные повторы, платные операции и timeout

Один task_id должен иметь не более одной активной попытки и не более одного запланированного повтора. Это защищает от сценария, в котором несколько обработчиков одного 429 создают независимые таймеры и одновременно запускают одинаковую операцию. Для общего лимита сервиса может понадобиться отдельный глобальный rate limiter, но он не заменяет дедупликацию конкретной задачи. Автоповтор допустим только после подтверждения семантики операции и её политики ошибок. Наличие статуса 429 само по себе не доказывает, что повтор безопасен для платного POST, списания, создания ресурса или другой операции с побочными эффектами. Не предполагайте универсальную серверную идемпотентность; используйте подтверждённый контракт API, ключ идемпотентности или ручное решение, если это предусмотрено конкретным сервисом. Неопределённый timeout — отдельный случай. Если клиент не знает, получил ли сервер запрос и обработал ли его, такой результат нельзя автоматически превращать в retry-ветку для 429. Его следует передавать в отдельную процедуру reconciliation: проверка статуса операции, ключ идемпотентности, журнал результата или ручная проверка. Нельзя обещать, что ограничитель снимется через фиксированное время или что повтор разблокирует аккаунт.

Детерминированные fixtures и проверки

Тестовые fixtures должны моделировать только входы и решения локального scheduler, без сетевой отправки. Зафиксируйте wall_now и mono_now, чтобы результат не зависел от реальных часов. Минимальный набор проверок: - отсутствующий Retry-After использует явно заданную fallback-политику или возвращает stop/escalate; - delta-seconds больше max_wait останавливает задачу, не сокращая серверное ожидание; - HTTP-date преобразуется в задержку, а отрицательное значение не уходит в прошлое; - некорректное значение не создаёт бесконечное ожидание; - Retry-After за пределами deadline приводит к stop; - превышение max_attempts приводит к stop; - два события для одного active task_id не создают два повтора; - повторная постановка той же задачи не увеличивает число одновременных запусков; - неопределённый timeout не проходит через обработчик 429; - scheduler не вызывает сетевой клиент и возвращает только решение или запись в очередь. Отдельно проверяйте, что ограничение одного task_id не ошибочно блокирует независимые задачи. Глобальный бюджет попыток, бюджет конкретной задачи и ограничение параллелизма — разные политики, их состояния не следует смешивать.

Шаблон промпта

Подготовь русскоязычный технический материал о планировании повторов после HTTP 429 на основе RFC 6585 section 4 и RFC 9110 Retry-After. Покажи, что Retry-After может отсутствовать и задаваться как delta-seconds или HTTP-date. Объясни обработку отсутствующего, некорректного и слишком большого значения, рассинхронизацию часов, монотонные часы, абсолютный deadline, max_attempts и ограниченный бюджет ожидания. Отдели локальный bounded scheduler от сетевой отправки: приведи иллюстративный, явно нетестированный псевдокод или Python-подобный код без HTTP-вызовов. Покажи одну очередь, дедупликацию по task_id и запрет параллельных повторов одной задачи. Добавь детерминированные fixtures и проверки глобального или задачного бюджета попыток, отсутствия одновременных повторов и отделения timeout с неопределённым исходом от ветки 429. Не обещай фиксированное время восстановления, разблокировку аккаунта, универсальную идемпотентность или безопасность платных POST. Не выдумывай цены, benchmarks, результаты тестов, promotions, совместимость или дополнительные источники. Используй только заданные факты и явно отмечай ограничения.

Источники и проверка