Урок курса

429, Retry-After и ограниченный exponential backoff

Продвинутый n8n: production-автоматизации и AI-агенты

В предыдущем уроке вы научились останавливать пагинацию по условию — когда cursor исчез или Link-заголовок пуст. Сейчас задача другая: API само говорит «подожди», и нужно не просто остановиться, а выяснить, сколько именно ждать, не нарушив при этом контракт сервера.

HTTP 429, Retry-After и нижняя граница ожидания

Статус 429 Too Many Requests означает, что сервер ограничил частоту запросов. Заголовок Retry-After задаёт момент, раньше которого повторять запрос нельзя. По RFC 9110 у него две формы: число секунд (Retry-After: 30) и HTTP-date (Retry-After: Wed, 21 Oct 2015 07:28:00 GMT).

Чтобы обработать 429 внутри workflow, в HTTP Request включите Options → Response → Include Response Headers and Status и Never Error. Тогда downstream item имеет единый envelope { body, headers, statusCode }, а non-2xx не прерывает execution до маршрутизации.

Code node разбирает обе формы и отличает отсутствующий заголовок от некорректного:

const headers = $input.first().json.headers ?? {};
const raw = headers['retry-after'] ?? headers['Retry-After'];

function parseRetryAfter(value, nowMs = Date.now()) {
  if (value === undefined || value === null || String(value).trim() === '') return 0;
  const text = String(value).trim();
  if (/^\d+$/.test(text)) return Number(text);

  const retryAt = Date.parse(text);
  if (!Number.isFinite(retryAt)) throw new Error('invalid_retry_after');
  return Math.max(0, Math.ceil((retryAt - nowMs) / 1000));
}

const retry_after_seconds = parseRetryAfter(raw);

Отсутствующий Retry-After даёт 0, и тогда действует локальный backoff. Некорректная непустая строка не подменяется нулём: это ошибка входного контракта, иначе workflow мог бы повторить запрос раньше разрешённого времени. Для HTTP-date используется округление вверх, чтобы не сократить server minimum на долю секунды.

retry_after_seconds — нижняя граница ожидания. Локальный cap ограничивает только собственную exponential-компоненту и не имеет права уменьшать требование сервера. Если повторить запрос раньше, следующая попытка может снова получить 429 и впустую израсходовать бюджет попыток.

Ограниченный exponential backoff со счётчиком попыток и wait budget

Локальная компонента backoff растёт по номеру уже выполненного HTTP-вызова и ограничивается exp_cap. В Plan Retry входной item уже содержит envelope ответа и контекст запроса, который понадобится следующему вызову:

const state = $input.first().json;
const attempts = Number(state.attempts ?? 1);
const max_attempts = Number(state.max_attempts);
const base = Number(state.base);
const exp_cap = Number(state.exp_cap);
const total_wait_budget = Number(state.total_wait_budget);
const elapsed_wait = Number(state.elapsed_wait ?? 0);

const exp = Math.min(exp_cap, base * 2 ** (attempts - 1));
const wait_seconds = Math.max(exp, retry_after_seconds);
const remaining_budget = total_wait_budget - elapsed_wait;

При base=2 и exp_cap=60 значения exp равны 2, 4, 8, 16, 32, 60, 60. Затем Math.max применяет server minimum: при exp=8 и Retry-After: 45 ожидание равно 45; при exp=60 и Retry-After: 10 — 60.

Initial call входит в max_attempts. attempts — номер только что завершившегося HTTP-вызова. После каждого 429 Code node сначала решает, разрешён ли ещё один вызов. Terminal result намеренно остаётся узким, а retry result становится полным состоянием следующего запроса:

if (attempts >= max_attempts) {
  return [{ json: {
    error_code: 'retry_attempts_exhausted',
    attempts,
    last_status: 429,
    decision: 'exhausted',
    next_request: false
  }}];
}

if (wait_seconds > remaining_budget) {
  return [{ json: {
    error_code: 'retry_wait_budget_exceeded',
    attempts,
    last_status: 429,
    decision: 'defer',
    next_request: false
  }}];
}

return [{ json: {
  ...state,
  error_code: null,
  attempts: attempts + 1,
  last_status: 429,
  decision: 'retry',
  next_request: true,
  wait_seconds,
  elapsed_wait: elapsed_wait + wait_seconds
}}];

...state здесь существенно: до Wait сохраняются параметры запроса и пагинации, а attempts + 1 задаёт номер следующего HTTP-вызова. Проверки лимитов используют номер текущего неудачного вызова, и только разрешённая retry-ветка увеличивает его. Так wait budget не обрезает Retry-After: если server minimum не помещается в остаток бюджета, execution завершается controlled deferral без Wait и нового запроса. Отдельный fixture проверяет decision=defer, error_code=retry_wait_budget_exceeded, next_request=false.

Цикл retry в n8n: маршрутизация IF, Wait и контролируемое завершение через Stop And Error

Один IF не может выдать отдельные ветки для 2xx, 429 и остальных статусов: у него только outputs true и false. Здесь нужен Switch и отдельный маршрутизатор решения после Code.

Полная топология:

HTTP Request
  → Status Switch
      output 0: 2xx → Success
      output 1: 429 → Plan Retry → Retry Decision
      fallback:     → Unexpected Status Error

Retry Decision
  output 0: decision=retry → Wait → HTTP Request
  fallback:                → Stop And Error

В Status Switch удобно выбрать Expression, задать Number of Outputs = 3 и вернуть индекс:

{{ $json.statusCode >= 200 && $json.statusCode < 300
  ? 0
  : $json.statusCode === 429
    ? 1
    : 2 }}

Ветка 429 идёт в Plan Retry. Этот Code node возвращает один из двух контрактов предыдущей секции: узкий terminal result либо полный retry state. Следующий Retry Decision проверяет только decision: retry уходит в Wait, любой terminal decision (defer или exhausted) — в Stop And Error. Благодаря отдельному Switch terminal item физически не может попасть в Wait.

В Wait выберите After Time Interval, единицу Seconds и значение {{ $json.wait_seconds }}. Wait не пересобирает item: после него в HTTP Request приходит сохранённый контекст с attempts, уже увеличенным на единицу, и обновлённым elapsed_wait.

Для terminal path в Stop And Error выберите Error Type = Error Object и передайте фиксированную форму:

{{ {
  error_code: $json.error_code,
  attempts: $json.attempts,
  last_status: $json.last_status,
  decision: $json.decision,
  next_request: false
} }}

При recovery fixture 429, 429, 200 запросы получают attempt=1, attempt=2 и attempt=3. Always-429 fixture при max_attempts=3 также выполняет ровно три HTTP-вызова: после третьего 429 Plan Retry возвращает retry_attempts_exhausted, attempts=3, last_status=429, decision=exhausted, next_request=false, поэтому четвёртого вызова в transcript нет. Over-budget fixture завершается terminal result с retry_wait_budget_exceeded и decision=defer.

Задача 1

Зачем в retry-fixture включён Never Error у HTTP Request?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

max_attempts равен 4, и каждый ответ — 429. Сколько HTTP-вызовов разрешено до terminal error?

Без регистрации · результат не сохраняется

Задача 3

Лаборатория: 429, Retry-After и ограниченный exponential backoff

Соберите ограниченный retry для 429: задержка учитывает Retry-After и exponential backoff, а общий wait budget имеет приоритет над очередной попыткой. Проверьте восстановление и жёсткую остановку.

Файлы лабораторной

Скачайте из блока «Файлы шага» n8n_lab_08_setup.py и n8n_lab_08_README.md. Запустите setup-скрипт в пустой папке: он восстановит рабочую структуру и создаст submission/workflow.json.

Зафиксированная среда

  • n8n 2.39.9; цель: either. Источник фиксации: Pinned course runtime 2.39.9; exact node typeVersions read from the supplied export on 2026-09-22.

  • n8n-nodes-base.manualTrigger typeVersion 1 — Узел Manual Trigger в выданном export

  • n8n-nodes-base.set typeVersion 3.4 — Узел Scenario Config в выданном export

  • n8n-nodes-base.switch typeVersion 3.4 — Узел Source Mode Switch в выданном export

  • n8n-nodes-base.httpRequest typeVersion 4.3 — Узел Cursor Request в выданном export

  • n8n-nodes-base.code typeVersion 2 — Узел Validate Body в выданном export

  • n8n-nodes-base.stopAndError typeVersion 1 — Узел Auth Failure в выданном export

  • n8n-nodes-base.wait typeVersion 1.1 — Узел Wait Before Retry в выданном export

  • n8n-nodes-base.convertToFile typeVersion 1.1 — Узел Convert Result to JSON File в выданном export

  • n8n-nodes-base.readWriteFile typeVersion 1 — Узел Write result.json в выданном export

Подготовка

  1. Создать рабочую копию. Выполните python assets/setup.py, затем импортируйте submission/workflow.json в n8n 2.39.9.
    Ожидаемое наблюдение: Создан отдельный submission/workflow.json; исходный starter остаётся неизменным.

Запуск

  1. Исправить workflow. Внесите требуемое lesson-specific изменение в импортированный граф и экспортируйте его поверх submission/workflow.json.
    Ожидаемое наблюдение: Изменение присутствует в параметрах и connections экспортированного workflow.

  2. Проверить submission. Выполните python assets/run.py. Runner сам читает submission/workflow.json, прогоняет fixtures и перезаписывает observations/report; student_evidence не используется.
    Ожидаемое наблюдение: Receipt N8N_LAB_08_OK появляется только для исправленного графа.

Матрица приёмки

  • two 429 then success. Runner подаёт fixture в deterministic engine после проверки текущего submission/workflow.json; ручные actual и execution_id не принимаются. Наблюдения: До успеха выполнены ровно три HTTP attempts.; Суммарное ожидание равно 5 секундам.. Зафиксируйте: lesson_id, submission_sha256, case_id, derived_actual, observation_sha256, receipt.

  • retry after over budget. Runner подаёт fixture в deterministic engine после проверки текущего submission/workflow.json; ручные actual и execution_id не принимаются. Наблюдения: Retry-After 12 не запускает Wait при бюджете 5.; Результат терминальный и next_request=false.. Зафиксируйте: lesson_id, submission_sha256, case_id, derived_actual, observation_sha256, receipt.

Сброс

  1. Сбросить derived evidence. Выполните python assets/reset.py. Чтобы вернуть starter, отдельно снова выполните python assets/setup.py.
    Ожидаемое наблюдение: Удалены observations.json и verification_report.json; workflow не подменяется скрытым эталоном.

Что отправить

Сдайте receipt из assets/run.py и сохраните generated evidence/verification_report.json. Runner не читает student_evidence.

Обязательные поля: lesson_id, submission_sha256, case_id, derived_actual, observation_sha256, receipt.

В поле ответа вставьте значение receipt из отчёта runner.

Границы проверки

  • Runner исполняет детерминированную учебную модель узлов и state/stub-контрактов после разбора export; живой n8n 2.39.9 остаётся отдельной ручной проверкой.

  • Ни actual, ни execution_id, ни текстовое заявление learner_change не принимаются как доказательство.

Без регистрации · результат не сохраняется

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку