Перейти к основному содержимому

ADR 0003 — Политика повторов для удалённых providers

Контекст​

GithubProvider и GitlabProvider делают реальные сетевые вызовы (Octokit и Gitbeaker), которые временно падают — rate-limit (429), ошибки 5xx, timeout. 8 операций GitProviderContract (см. 0001) делятся на две очень разные природы с точки зрения безопасного повторного выполнения:

  • Чтения (getRepo, listBranches) идемпотентны по природе — повторное выполнение не имеет побочного эффекта.
  • Мутации (createRepo, createBranch, protectBranch, commitFiles, openPullRequest, mergePullRequest) не идемпотентны в общем случае (напр.: повторная отправка createRepo после timeout может столкнуться с тем самым репозиторием, который создала первая попытка, или вызвать второй коммит) — решение, повторять ли и как, зависит от контекста, которого у обёртки повторов нет.

Решение​

Автоматический повтор только для чтений, никогда для мутаций. apps/api/src/infrastructure/git/retry.ts предоставляет withRetry(fn, options) с "Full Jitter" (алгоритм AWS: sleep = random(0, min(maxDelay, base·2^tentativa))), maxAttempts по умолчанию 4, baseDelayMs 250, maxDelayMs 4000. Только getRepo/listBranches в двух удалённых providers проходят через withRetry; каждая мутация вызывает API напрямую и отдаёт ошибку наверх как есть при первом сбое — вызывающий решает, пробовать ли снова.

Критерий повтора — по provider, а не общий, через shouldRetry: (error) => boolean, передаваемый в каждый вызов:

  • GitHub (isRetryableReadError в github-provider.ts): 429 или любой 5xx, или 403 только когда заголовок x-ratelimit-remaining: 0 подтверждает rate-limit (isRateLimited) — GitHub перегружает 403 для отказа в доступе И rate-limit, и различает их только заголовок; никогда не повторяет 403 по одному статусу (повтор отказа в доступе бесконечен и бесполезен).
  • GitLab (isRetryableReadError в gitlab-provider.ts): только GitbeakerTimeoutError и 500/503/504. Gitbeaker уже повторяет 429/502 внутри (экспоненциальная задержка без jitter, до 10 попыток) — добавление отсюда повтора для этих кодов удвоило бы общую задержку без выигрыша; обёртка покрывает лишь то, что Gitbeaker не обрабатывает.

Известная оговорка, вне нашего контроля: встроенный повтор Gitbeaker — на уровне ТРАНСПОРТА, а не операции. defaultRequestHandler из @gitbeaker/rest (retryCodes = [429, 502], maxRetries = 10, задержка 2^tentativa · 0.25s, без jitter) работает для ЛЮБОГО HTTP-запроса, который делает клиент, — GET, POST, PUT, не различая чтение и мутацию, — а оба значения (retryCodes, maxRetries) — внутренние константы модуля, без какой-либо публичной опции в конструкторе Gitlab(...), чтобы отключить или перенастроить (подтверждено чтением @gitbeaker/rest@43.8.0 — никакой настраиваемый requesterFn не открыт агрегированным классом, используемым здесь). На практике это означает, что мутация GitlabProvider, ответившая 429 или 502, всё равно повторяется, только библиотекой, а не нашим withRetry, — гарантия "мутация никогда не повторяется автоматически" верна на 100% лишь для GitHub (Octokit не встраивает никакого повтора: нет @octokit/plugin-retry и plugin-throttling в зависимостях), а в GitLab верна лишь для кодов, которые Gitbeaker НЕ обрабатывает (400, 403, 404, 422, 500, 503, 504) — 429/502 ускользают вне нашего контроля. См. тест GitlabProvider — cenários de HTTP mockados > 429 numa mutação (createRepo): o Gitbeaker retenta por conta própria (gitlab-provider.contract.spec.ts), который документирует это реальное поведение вместо того, чтобы притворяться, что его нет, и тест с 500 (вне списка Gitbeaker), доказывающий "без повтора" на обоих уровнях одновременно.

Последствия​

  • Мутация GitHub, упавшая из-за rate-limit или 5xx, отклоняется с первой попытки, всегда — автоматического повтора записи на этой фазе нет. Если это окажется реальной проблемой UX (напр.: bootstrap Gitflow, падающий из-за временного rate-limit), будущее решение будет об идемпотентном повторе для конкретной операции (напр.: createBranch мог бы проверять, существует ли ветка, перед повторным выполнением), а не об общей обёртке.
  • Мутация GitLab, упавшая с 429/502, повторяется Gitbeaker (до 10 раз, ~29 с накопленного ожидания в худшем случае: сумма 0.25·(2^10-1) секунд) ДО того, как наш код вообще увидит ошибку, — принято как ограничение библиотеки, задокументированное, а не скрытое. Смена библиотеки только из-за этого непропорциональна на этой фазе; если это станет реальной проблемой (напр.: дублированный createRepo из-за неуместного повторного выполнения при rate-limit), будущее решение будет о замене @gitbeaker/rest тонким HTTP-клиентом с полным контролем над повтором, а не о попытке обойти поведение снаружи.
  • withRetry не связан с Octokit/Gitbeaker — тестируется изолированно (retry.spec.ts) с общей fn, а каждый provider предоставляет лишь собственный shouldRetry.
  • Общее время ожидания при чтении со всеми исчерпанными попытками ограничено maxDelayMs (4 с) на попытку, а не общим пределом времени — в худшем случае (4 попытки, каждая на пределе) ожидание суммируется до ~12 с, прежде чем поднимется финальная ошибка.