Um fetch failed transitorio (rede/DNS/reset) matava o run do safety net (agent-pr-ready-automerge) no meio da espera de checks. Retry no request() do cliente: fetch reject retenta em qualquer metodo; body-drop em GET retenta; 5xx (502/503/504) retenta so em GET (body cancelado antes do retry); 4xx e body-drop em escrita falham fail-closed. 3 retries, base 300ms x2, jitter +-20%, console.warn pt-BR por retry; opcoes flat no createApi (retries/backoffMs/jitter/sleepImpl). 12 specs unit novas.
6.5 KiB
Impl: OPS67 — Forgejo: retry com backoff nos fetchs do forgejo-api (fetch failed transitório mata o run do safety net)
Status: aprovado Atualizado em: 2026-08-19 Issue: #73 Intenção: body sem plano linkado — o título + frontmatter são a spec. Sem plano de intenção separado. Appetite restante: herdado do tamanho da Issue (P3, mudança pontual de infra/scripts).
Leitura da intenção
- Outcome: os fetchs do cliente Forgejo API (
scripts/lib/forgejo-api.mjs) retentam com backoff as falhas transitórias — umfetch failedde rede não mata mais o run do safety net (agent-pr-ready-automerge.yml) nem nenhum outro consumidor do cliente. - O que NÃO negociar: nenhum contrato de produto aqui; os invariantes são os testes existentes do cliente (403 → throw com corpo da API, shape normalizado gh-flavored, fail-closed sem token). O retry não pode mudar o contrato de erro visível dos callers: falha permanente continua
exit 1com a mesma mensagem (ex.: flip daforgejo-issue-transition.mjscontinua vermelhando — OPS61). - O que reavaliar: o escopo do "transitório". O título cobre o fetch reject de rede (
fetch failed); a decisão de engenharia é o que mais retentar (5xx? 429?) e com que política.
Decisões de engenharia
D1 — Onde vive o retry
Opções: A) no request() de scripts/lib/forgejo-api.mjs (choke point único — 19 call sites: safety net, claim/ready/status, issue-transition, branch-protection, pool, worktree); B) wrapper só no forgejo-pr-automerge.mjs; C) helper genérico withRetry novo + aplicar nos call sites.
Recomendação: A — o cliente é o deep module certo do transporte Forgejo (zero-dep, plain Node, roda no Actions sem pnpm); um único ponto dá resiliência a todos os runs de uma vez. Rejeitadas: C porque criaria pass-through raso e tocaria 19 call sites (DRY de conhecimento violado — "edit the owner, don't twin"); B porque deixa claim/status/pool vulneráveis ao mesmo flake e o safety net voltaria a morrer por outro call site.
D2 — O que retentar (classe de falha)
Opções: A) só falha de rede (fetch reject: fetch failed, ECONNRESET/ETIMEDOUT/DNS) em todos os métodos; B) A + 5xx (502/503/504) apenas em GET; C) A + 5xx em qualquer método (padrão Octokit).
Recomendação: B — o sintoma do título é exatamente o fetch reject; o 5xx-em-GET custa 3 linhas e protege o caminho dominante do safety net: o poll do waitForChecks (GET PR + GET statuses a cada 15 s por até 45 min) — um 502 do Cloudflare no meio da espera é a mesma classe de morte de run. Rejeitadas: C porque retentar POST 5xx pode duplicar efeitos colaterais (createIssue duplicado no agent-register, comentário duplicado, merge re-enviado em corrida — o autoMerge já tem guard de re-read, mas o resto não) sem necessidade observada. 429 não entra (não observado; gatilho de revisitação). AbortError não existe no cliente hoje (sem AbortSignal) — se um dia entrar, não retentar.
D3 — Política de backoff
Opções: A) exponential: base 300 ms, fator 2, jitter ±20%, retries: 3 (4 tentativas, pior caso ~2,1 s); B) delay fixo 1 s × 3; C) retry imediato.
Recomendação: A — padrão de livro; o delay total é irrelevante frente ao budget de 55 min do workflow. Rejeitadas: B e C (sem espalhamento; rajada curta de blips derrubaria tentativas consecutivas).
Configuração: opções flat no createApi (retries, backoffMs, jitter, sleepImpl) espelhando o fetchImpl injetável que já existe — defaults prod preservam o comportamento sem nenhum caller mudar; sleepImpl + jitter: false + backoffMs pequeno dão determinismo aos testes (zero espera real).
D4 — Visibilidade
console.warn pt-BR no retry, estilo das mensagens do lib (Sem token…): [forgejo-api] GET <path> falhou (tentativa 1/4): <motivo> — retry em <ms>ms. O log do run do safety net fica diagnóstico; sem plumbing novo de logger.
Componentes / mudanças
request()(scripts/lib/forgejo-api.mjs): loop de tentativas em volta dofetch; checagem deresponse.statusantes deresponse.text()(necessário para decidir 5xx-GET sem consumir o body);sleepImpldefaultsetTimeout. Sem migration, access, UI, DB.tests/unit/forgejoApi.unit.spec.ts: 12 specs novas (após /simplify) — rede GET/POST→sucesso, body-drop em GET retenta + em escrita fail-closed, rede persistente→throw apósretries+1calls,retries: 0, 4xx não retenta, 5xx GET retenta/exaure, 5xx POST não retenta, backoff cresce (sleepImplspy: [base, base×2] com jitter off), jitter ±20% comMath.randompinado. Spec 403 existente ganha pin de 1 fetch por invocação.docs/changelog/2026-08-19-ops67.md+pnpm changelog:build.- Docs: conferir
docs/AGENT-OPS.md/.agents/rules/agent-pr-workflow.mdcpor menção ao cliente sem retry (toque só se houver drift real).
Fases verificáveis
- Cliente + specs —
forgejo-api.mjs+ specs unit novas;pnpm test:unit(arquivo), lint, format, typecheck, knip, cycles. - Changelog + docs — entrada
ops67, build do agregado, retoque de doc se houver drift. - PR → CI —
scripts/conta como produção no.dockerignore, então o CI do PR roda a suíte completa; merge → deploy na janela.
Rabbit holes / Não escopo
- Retry em outros fetchs (
wpArticles,seed-posts,recover-media,check-push-chain): domínios diferentes, políticas próprias — não tocar. - Retry 429 / retry de POST 5xx: não observados — revisitar só se aparecerem.
- AbortSignal no cliente: não existe hoje; não introduzir.
- Timeout 30 min do
waitForChecksvs 55 min do job (débito anotado no OPS62): não escopo.
Riscos e mitigação
- Retry adiciona ~2 s no pior caso antes do erro real: irrelevante; o erro final é o mesmo (exit 1).
- Duplicação de POST quando a conexão cai no meio (server aplicou, resposta não voltou): residual padrão de toda SDK HTTP (Octokit idem); os endpoints de escrita do cliente são idempotentes na prática (labels/comentário) ou têm guard de re-read (
autoMerge);createIssueé o único sensitivo e o risco é residual — aceito. - Testes lentos: impossível —
sleepImplinjetado e backoff configurável.
Aceite de engenharia
- Aceite do título mantido: fetch transitório não mata mais run de nenhum consumidor do cliente
- Invariantes: zero-dep mantido (stdlib), contrato de erro dos callers preservado, mensagens pt-BR
- Testes unit cobrindo as 4 classes (rede / 4xx / 5xx-GET / 5xx-POST) + crescimento do backoff