mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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.
This commit is contained in:
parent
8d33e89102
commit
cc6149191b
5 changed files with 412 additions and 9 deletions
|
|
@ -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 <passthrough verbatim> --no-deps -- <paths>` 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=<família> --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 target="_blank" rel="noopener noreferrer">`); 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).
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-ops67.md
Normal file
1
docs/changelog/2026-08-19-ops67.md
Normal file
|
|
@ -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).
|
||||
71
docs/plans/ops67-forgejo-api-retry-backoff-impl.md
Normal file
71
docs/plans/ops67-forgejo-api-retry-backoff-impl.md
Normal file
|
|
@ -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 <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
|
||||
|
|
@ -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<string, string>; body?: string }) => Promise<Response>} [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<void>} [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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue