fix(engine): retry on 429 instead of a false SKIP, and correct the call count

Measured 2026-08-04: 13 script invocations in one shell loop tripped an
anonymous Forgejo rate limit at 26 requests, because each invocation makes
TWO calls (org listing + catalog marketplace.json), not the ONE this repo's
own CLAUDE.md claimed. That line went stale when INSTALL-TRUTH added the
second call and nobody updated the count it depended on.

fetchWithRetry wraps both calls, honoring Retry-After on HTTP 429 instead of
silently falling back to SKIP. TDD: 5 new tests inject a fake fetch and sleep
to drive the retry/backoff/give-up paths without touching the network.

A sweep across every repo still doesn't belong in this engine — that's
org-ops's job by this file's own header — but a single repo's self-check
should not read as broken just because the forge was briefly busy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01496ZWasKPnA627crFBXWhe
This commit is contained in:
Kjell Tore Guttormsen 2026-08-04 12:12:49 +02:00
commit d1b6274924
8 changed files with 119 additions and 13 deletions

View file

@ -802,9 +802,27 @@ export function loadRegister(path = REGISTER_PATH) {
return JSON.parse(readFileSync(path, 'utf8'));
}
// ONE call. The org listing already carries description and topics; fetching
// per repo trips the rate limiter (HTTP 429). Reads anonymously — verified —
// so this works for any reader, not only for someone holding a token.
// TWO calls per invocation (corrected 2026-08-04 — this used to say ONE, from
// before fetchCatalogNames existed; a 13-repo shell loop trusting that count
// looked safe at 13 requests and was actually 26). Both anonymous — verified
// — so this works for any reader, not only for someone holding a token. A
// sweep across every repo does NOT belong here: it needs the org listing
// exactly once, not once per invocation, and "see all repos at once" is
// org-ops's job by this file's own header. What DOES belong here is not
// silently giving up on a transient 429 — that turns a rate-limit blip into
// a false SKIP, which this repo's own rule says is never a pass.
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fetchWithRetry(url, options, { fetchImpl = fetch, retries = 3, baseDelayMs = 1000, sleep = defaultSleep } = {}) {
for (let attempt = 0; ; attempt += 1) {
const res = await fetchImpl(url, options);
if (res.status !== 429 || attempt >= retries) return res;
const retryAfter = Number(res.headers?.get?.('retry-after'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : baseDelayMs * 2 ** attempt;
await sleep(delayMs);
}
}
// The catalog's plugin list, read straight from the forge. One more call, and
// it is what turns "the install line is well-formed" into "the install line
// works". Null on any failure, which reads as SKIP rather than a pass.
@ -813,7 +831,7 @@ async function fetchCatalogNames(register) {
if (!mkt.name) return null;
const url = `${register.forge}/api/v1/repos/${register.org}/${mkt.name}/raw/.claude-plugin/marketplace.json`;
try {
const res = await fetch(url, { headers: { accept: 'application/json' } });
const res = await fetchWithRetry(url, { headers: { accept: 'application/json' } });
if (!res.ok) return null;
const json = JSON.parse(await res.text());
return (json.plugins ?? []).map((p) => p.name).filter(Boolean);
@ -824,7 +842,7 @@ async function fetchCatalogNames(register) {
async function fetchOrgListing(register) {
const url = `${register.forge}/api/v1/orgs/${register.org}/repos?limit=50`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
const res = await fetchWithRetry(url, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`org listing returned HTTP ${res.status}`);
return res.json();
}

View file

@ -30,6 +30,7 @@ import {
levelOf,
parseRepoNameFromRemote,
extractChangelogTop,
fetchWithRetry,
} from './repo-standard-check.mjs';
const REGISTER = {
@ -1047,3 +1048,72 @@ test('an indented block ends at the first unindented line', () => {
const f = checkInternalLinks({ files: { 'README.md': text }, present: ['README.md'] });
assert.equal(f.some((x) => x.code === 'LINK-INTERNAL-MISSING'), true);
});
// -------------------------------------------------------- fetchWithRetry
// fetchOrgListing/fetchCatalogNames are I/O shell, exercised live by the CLI —
// but the retry decision is pure once fetchImpl and sleep are injected, so it
// gets the same unit coverage as the classifiers. Measured 2026-08-04: 13
// script invocations in ~1s tripped an anonymous 429 with no retry at all;
// this is the fix, not a sweep tool (that belongs in org-ops).
function fakeResponse(status, headers = {}) {
return {
status,
ok: status >= 200 && status < 300,
headers: { get: (k) => headers[k.toLowerCase()] ?? null },
};
}
test('a 429 is retried once and a subsequent 200 is returned', async () => {
const calls = [];
const responses = [fakeResponse(429), fakeResponse(200)];
const fetchImpl = async () => responses.shift();
const sleep = async (ms) => calls.push(ms);
const res = await fetchWithRetry('https://x', {}, { fetchImpl, sleep });
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
});
test('Retry-After (seconds) sets the wait, not the exponential default', async () => {
const calls = [];
const responses = [fakeResponse(429, { 'retry-after': '2' }), fakeResponse(200)];
const fetchImpl = async () => responses.shift();
const sleep = async (ms) => calls.push(ms);
await fetchWithRetry('https://x', {}, { fetchImpl, sleep });
assert.equal(calls[0], 2000);
});
test('no Retry-After header falls back to exponential backoff from baseDelayMs', async () => {
const calls = [];
const responses = [fakeResponse(429), fakeResponse(429), fakeResponse(200)];
const fetchImpl = async () => responses.shift();
const sleep = async (ms) => calls.push(ms);
await fetchWithRetry('https://x', {}, { fetchImpl, sleep, baseDelayMs: 1000 });
assert.deepEqual(calls, [1000, 2000]);
});
test('retries are bounded — a persistent 429 returns the 429, not an infinite loop', async () => {
let calls = 0;
const fetchImpl = async () => fakeResponse(429);
const sleep = async () => {
calls += 1;
};
const res = await fetchWithRetry('https://x', {}, { fetchImpl, sleep, retries: 2, baseDelayMs: 1 });
assert.equal(res.status, 429);
assert.equal(calls, 2);
});
test('a non-429 response returns immediately — no retry, no sleep', async () => {
let fetchCalls = 0;
let sleepCalls = 0;
const fetchImpl = async () => {
fetchCalls += 1;
return fakeResponse(200);
};
const sleep = async () => {
sleepCalls += 1;
};
const res = await fetchWithRetry('https://x', {}, { fetchImpl, sleep });
assert.equal(res.status, 200);
assert.equal(fetchCalls, 1);
assert.equal(sleepCalls, 0);
});