teqo-backup/docs/plans/ops67-forgejo-api-retry-backoff-impl.md
Francisco Solla cc6149191b ops(OPS67): retry com backoff nos fetchs do forgejo-api
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.
2026-08-19 13:28:37 +00:00

71 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 — um `fetch failed` de 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 1` com a mesma mensagem (ex.: flip da `forgejo-issue-transition.mjs` continua 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 do `fetch`; checagem de `response.status` **antes** de `response.text()` (necessário para decidir 5xx-GET sem consumir o body); `sleepImpl` default `setTimeout`. 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ós `retries+1` calls, `retries: 0`, 4xx não retenta, 5xx GET retenta/exaure, 5xx POST não retenta, backoff cresce (`sleepImpl` spy: [base, base×2] com jitter off), jitter ±20% com `Math.random` pinado. 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.mdc` por menção ao cliente sem retry (toque só se houver drift real).
## Fases verificáveis
1. **Cliente + specs** — `forgejo-api.mjs` + specs unit novas; `pnpm test:unit` (arquivo), lint, format, typecheck, knip, cycles.
2. **Changelog + docs** — entrada `ops67`, build do agregado, retoque de doc se houver drift.
3. **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 `waitForChecks` vs 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 — `sleepImpl` injetado e backoff configurável.
## Aceite de engenharia
- [x] Aceite do título mantido: fetch transitório não mata mais run de nenhum consumidor do cliente
- [x] Invariantes: zero-dep mantido (stdlib), contrato de erro dos callers preservado, mensagens pt-BR
- [x] Testes unit cobrindo as 4 classes (rede / 4xx / 5xx-GET / 5xx-POST) + crescimento do backoff