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

6.5 KiB
Raw Permalink Blame History

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

  • 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