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 с, прежде чем поднимется финальная ошибка.