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:
parent
75f2149761
commit
d1b6274924
8 changed files with 119 additions and 13 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue