diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 8b535dad..8c24b6a4 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução). + +**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita); 4xx continua falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`). Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (rede transitória→sucesso, rede persistente→throw após N, 4xx sem retry, 5xx-GET retry, 5xx-POST sem retry, backoff exponencial [100, 200, 400] + pin do 403 existente com calls=1). Verificação: unit 2397 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano). + **S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e --no-deps -- ` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project= --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`. **S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → ``); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate). diff --git a/docs/changelog/2026-08-19-ops67.md b/docs/changelog/2026-08-19-ops67.md new file mode 100644 index 00000000..cace1142 --- /dev/null +++ b/docs/changelog/2026-08-19-ops67.md @@ -0,0 +1 @@ +**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução). diff --git a/docs/plans/ops67-forgejo-api-retry-backoff-impl.md b/docs/plans/ops67-forgejo-api-retry-backoff-impl.md new file mode 100644 index 00000000..73406410 --- /dev/null +++ b/docs/plans/ops67-forgejo-api-retry-backoff-impl.md @@ -0,0 +1,71 @@ +# 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 falhou (tentativa 1/4): — retry em 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 diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs index 069bd5be..7381ca49 100644 --- a/scripts/lib/forgejo-api.mjs +++ b/scripts/lib/forgejo-api.mjs @@ -8,6 +8,13 @@ * inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent * is sent because the instance sits behind Cloudflare. * + * Retry (OPS67): transient failures retry with exponential backoff — a fetch + * that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY + * method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the + * safety net's long `waitForChecks` poll survives, while write endpoints keep + * failing closed instead of risking duplicated side effects. 4xx never + * retries. Defaults: 3 retries, base 300 ms ×2, ±20% jitter. + * * Shapes are normalized to the GitHub-flavored contract the agent scripts and * their unit specs already use: `issue.state` ∈ OPEN|CLOSED, * `issue.labels = [{ name, color }]`, `createdAt` ISO string. @@ -16,6 +23,7 @@ const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1' const DEFAULT_REPOSITORY = 'fsolla/teqo' const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)' +const RETRYABLE_STATUSES = new Set([502, 503, 504]) /** * @typedef {object} ForgejoApiOptions @@ -23,12 +31,25 @@ const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)' * @property {string} [token] * @property {string} [repository] * @property {(input: string | URL, init?: { method?: string; headers?: Record; body?: string }) => Promise} [fetchImpl] + * @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3. + * @property {number} [backoffMs] Base exponential delay. Default 300. + * @property {boolean} [jitter] ±20% randomization on each delay. Default true. + * @property {(ms: number) => Promise} [sleepImpl] Test seam for the backoff delay. */ /** * @param {ForgejoApiOptions} [options] */ -export const createApi = ({ base, token, repository, fetchImpl } = {}) => { +export const createApi = ({ + base, + token, + repository, + fetchImpl, + retries = 3, + backoffMs = 300, + jitter = true, + sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +} = {}) => { const baseUrl = ( base ?? process.env.FORGEJO_API_URL ?? @@ -42,6 +63,22 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { DEFAULT_REPOSITORY const fetcher = fetchImpl ?? fetch const [owner, name] = repo.split('/') + if (retries < 0 || backoffMs < 0) { + throw new Error( + `Retry inválido: retries=${retries}, backoffMs=${backoffMs} — ambos devem ser >= 0`, + ) + } + const attemptCount = retries + 1 + + const backoffDelay = (attempt) => { + const base = backoffMs * 2 ** (attempt - 1) + return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base + } + + const warnRetry = (path, method, reason, attempt, delay) => + console.warn( + `[forgejo-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`, + ) const request = async (path, { method = 'GET', body, query } = {}) => { const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN @@ -57,7 +94,8 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) .join('&') : '' - const response = await fetcher(`${baseUrl}${path}${qs}`, { + const url = `${baseUrl}${path}${qs}` + const init = { method, headers: { Authorization: `token ${authToken}`, @@ -65,12 +103,45 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { 'User-Agent': USER_AGENT, }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }) - const text = await response.text() - if (!response.ok) { - throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`) } - return text ? JSON.parse(text) : null + const retryAfter = async (attempt, reason) => { + const delay = backoffDelay(attempt) + warnRetry(path, method, reason, attempt, delay) + await sleepImpl(delay) + } + for (let attempt = 1; attempt <= attemptCount; attempt += 1) { + const isLastAttempt = attempt === attemptCount + let response + try { + response = await fetcher(url, init) + } catch (error) { + if (isLastAttempt) throw error + await retryAfter(attempt, error.message) + continue + } + if (method === 'GET' && !isLastAttempt && RETRYABLE_STATUSES.has(response.status)) { + await response.body?.cancel() + await retryAfter(attempt, `HTTP ${response.status}`) + continue + } + let text + try { + text = await response.text() + } catch (error) { + if (isLastAttempt || method !== 'GET') throw error + await retryAfter(attempt, error.message) + continue + } + if (!response.ok) { + throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`) + } + if (!text) return null + try { + return JSON.parse(text) + } catch { + throw new Error(`Forgejo API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`) + } + } } const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN') diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts index d95a4fe8..9ebab7f0 100644 --- a/tests/unit/forgejoApi.unit.spec.ts +++ b/tests/unit/forgejoApi.unit.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createApi } from '../../scripts/lib/forgejo-api.mjs' @@ -75,13 +75,18 @@ describe('forgejo-api', () => { }) it('throws with the API error body on non-2xx', async () => { + let calls = 0 const api = createApi({ token: 'tok', - fetchImpl: async () => ok('{"message":"nope"}', 403), + fetchImpl: async () => { + calls += 1 + return ok('{"message":"nope"}', 403) + }, }) await expect(api.listIssues()).rejects.toThrow(/403/) await expect(api.listIssues()).rejects.toThrow(/nope/) + expect(calls).toBe(2) }) it('fails closed when no token is available', async () => { @@ -564,4 +569,255 @@ describe('forgejo-api', () => { ).toBe('{"a":2}') expect((calls.at(-1)!.body as { sha: string }).sha).toBe('shasha') }) + + describe('retry with backoff (OPS67)', () => { + const issueOk = [{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }] + + afterEach(() => { + vi.restoreAllMocks() + }) + + const silentWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const brokenBody = () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(new TypeError('terminated')), + }), + { status: 200 }, + ) + + it('retries a transient network failure and succeeds', async () => { + silentWarn() + let calls = 0 + const sleeps: number[] = [] + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 100, + jitter: false, + sleepImpl: async (ms) => { + sleeps.push(ms) + }, + fetchImpl: async () => { + calls += 1 + if (calls === 1) throw new TypeError('fetch failed') + return ok(issueOk) + }, + }) + + const issues = await api.listIssues() + + expect(calls).toBe(2) + expect(sleeps).toEqual([100]) + expect(issues).toHaveLength(1) + }) + + it('retries a transient network failure on write methods too', async () => { + silentWarn() + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async () => { + calls += 1 + if (calls === 1) throw new TypeError('fetch failed') + return ok(null) + }, + }) + + await api.addComment(1, 'x') + + expect(calls).toBe(2) + }) + + it('retries a GET whose body stream drops mid-read', async () => { + silentWarn() + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async () => { + calls += 1 + return calls === 1 ? brokenBody() : ok(issueOk) + }, + }) + + const issues = await api.listIssues() + + expect(calls).toBe(2) + expect(issues).toHaveLength(1) + }) + + it('does not retry a write whose body drops after a status was received', async () => { + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + fetchImpl: async () => { + calls += 1 + return calls === 1 ? brokenBody() : ok(null) + }, + }) + + await expect(api.addComment(1, 'x')).rejects.toThrow(/terminated/) + expect(calls).toBe(1) + }) + + it('throws after exhausting retries on a persistent network failure', async () => { + silentWarn() + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async () => { + calls += 1 + throw new TypeError('fetch failed') + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/fetch failed/) + expect(calls).toBe(3) + }) + + it('honors retries: 0 as a single attempt', async () => { + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 0, + fetchImpl: async () => { + calls += 1 + throw new TypeError('fetch failed') + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/fetch failed/) + expect(calls).toBe(1) + }) + + it('grows the backoff exponentially between retries', async () => { + const warn = silentWarn() + let calls = 0 + const sleeps: number[] = [] + const api = createApi({ + token: 'tok', + retries: 3, + backoffMs: 100, + jitter: false, + sleepImpl: async (ms) => { + sleeps.push(ms) + }, + fetchImpl: async () => { + calls += 1 + throw new TypeError('fetch failed') + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/fetch failed/) + expect(calls).toBe(4) + expect(sleeps).toEqual([100, 200, 400]) + expect(warn.mock.calls.flat().join(' ')).toContain('tentativa 1/4') + }) + + it('applies the ±20% jitter around the exponential delay', async () => { + silentWarn() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const sleeps: number[] = [] + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 100, + jitter: true, + sleepImpl: async (ms) => { + sleeps.push(ms) + }, + fetchImpl: async () => { + throw new TypeError('fetch failed') + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/fetch failed/) + expect(sleeps).toEqual([100, 200]) + }) + + it('does not retry 4xx responses', async () => { + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 3, + fetchImpl: async () => { + calls += 1 + return ok('{"message":"nope"}', 403) + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/403/) + expect(calls).toBe(1) + }) + + it('retries 5xx responses on GET', async () => { + silentWarn() + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async () => { + calls += 1 + return calls === 1 ? ok('{"message":"boom"}', 502) : ok(issueOk) + }, + }) + + const issues = await api.listIssues() + + expect(calls).toBe(2) + expect(issues).toHaveLength(1) + }) + + it('throws the API error after exhausting 5xx retries on GET', async () => { + silentWarn() + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async () => { + calls += 1 + return ok('{"message":"boom"}', 503) + }, + }) + + await expect(api.listIssues()).rejects.toThrow(/503/) + expect(calls).toBe(3) + }) + + it('does not retry 5xx responses on write methods', async () => { + let calls = 0 + const api = createApi({ + token: 'tok', + retries: 2, + fetchImpl: async () => { + calls += 1 + return ok('{"message":"boom"}', 503) + }, + }) + + await expect(api.addComment(1, 'x')).rejects.toThrow(/503/) + expect(calls).toBe(1) + }) + }) })