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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue