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
02bc776205
8 changed files with 119 additions and 13 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "repo-standard",
|
"name": "repo-standard",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "Per-repo gate for the open/ presentation standard: README first screen, install block, files required by the repo's class, and dead repo references.",
|
"description": "Per-repo gate for the open/ presentation standard: README first screen, install block, files required by the repo's class, and dead repo references.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Kjell Tore Guttormsen"
|
"name": "Kjell Tore Guttormsen"
|
||||||
|
|
|
||||||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -4,6 +4,18 @@ All notable changes to this project are documented here.
|
||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||||
versioning is [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
versioning is [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.1] — 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The gate makes two anonymous forge calls per invocation (org listing +
|
||||||
|
catalog `marketplace.json`), not one — this repo's own `CLAUDE.md` said
|
||||||
|
"one" from before `INSTALL-TRUTH` added the second, and stayed wrong long
|
||||||
|
enough that a 13-repo shell loop trusted the count and tripped an HTTP 429
|
||||||
|
at 26 requests. Both calls now go through `fetchWithRetry`, which honors
|
||||||
|
`Retry-After` and retries instead of silently reporting `SKIP` on a
|
||||||
|
transient rate limit. `CLAUDE.md` corrected to match.
|
||||||
|
|
||||||
## [0.2.0] — 2026-08-04
|
## [0.2.0] — 2026-08-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -51,10 +51,16 @@ would recreate, in data, exactly the drift this plugin exists to remove.
|
||||||
- **Three outcomes on references.** "No match" and "match on a known non-repo"
|
- **Three outcomes on references.** "No match" and "match on a known non-repo"
|
||||||
must stay distinct findings. Collapsing them hides real loss inside correct
|
must stay distinct findings. Collapsing them hides real loss inside correct
|
||||||
text — the exact defect class this gate exists to catch.
|
text — the exact defect class this gate exists to catch.
|
||||||
- **One API call, anonymous.** The org listing carries description and topics
|
- **Two API calls per invocation, anonymous, with 429 retry.** The org listing
|
||||||
already; per-repo fetching trips the rate limiter (HTTP 429). It reads without
|
(description + topics) is one; the catalog's `marketplace.json` for
|
||||||
a token, so the gate works for any reader — a public plugin whose documented
|
INSTALL-TRUTH is the other (added after this used to say "one call" — that
|
||||||
check only runs for its author is a broken plugin.
|
line went stale and stayed stale until a 13-repo shell loop trusted it and
|
||||||
|
tripped the rate limiter at 26 requests). Both go through `fetchWithRetry`,
|
||||||
|
which honors `Retry-After` on HTTP 429 rather than silently reporting SKIP.
|
||||||
|
Both are anonymous — no token — so the gate works for any reader, not only
|
||||||
|
someone holding one. A sweep across every repo still does not belong here:
|
||||||
|
it needs the listing fetched once, not once per invocation, which is a
|
||||||
|
different shape of caller (org-ops), not a flag on this engine.
|
||||||
- **Codepoints, not bytes, not UTF-16 units.** Use `[...s].length`. An em-dash
|
- **Codepoints, not bytes, not UTF-16 units.** Use `[...s].length`. An em-dash
|
||||||
exposes only the byte layer; astral characters expose the rest.
|
exposes only the byte layer; astral characters expose the rest.
|
||||||
- **The reader decides a link's level, not just what is required.** Root
|
- **The reader decides a link's level, not just what is required.** Root
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ checks that surface in one repository and reports what it finds.
|
||||||
|
|
||||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "repo-standard",
|
"name": "repo-standard",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -802,9 +802,27 @@ export function loadRegister(path = REGISTER_PATH) {
|
||||||
return JSON.parse(readFileSync(path, 'utf8'));
|
return JSON.parse(readFileSync(path, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONE call. The org listing already carries description and topics; fetching
|
// TWO calls per invocation (corrected 2026-08-04 — this used to say ONE, from
|
||||||
// per repo trips the rate limiter (HTTP 429). Reads anonymously — verified —
|
// before fetchCatalogNames existed; a 13-repo shell loop trusting that count
|
||||||
// so this works for any reader, not only for someone holding a token.
|
// 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
|
// 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
|
// 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.
|
// 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;
|
if (!mkt.name) return null;
|
||||||
const url = `${register.forge}/api/v1/repos/${register.org}/${mkt.name}/raw/.claude-plugin/marketplace.json`;
|
const url = `${register.forge}/api/v1/repos/${register.org}/${mkt.name}/raw/.claude-plugin/marketplace.json`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
const res = await fetchWithRetry(url, { headers: { accept: 'application/json' } });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const json = JSON.parse(await res.text());
|
const json = JSON.parse(await res.text());
|
||||||
return (json.plugins ?? []).map((p) => p.name).filter(Boolean);
|
return (json.plugins ?? []).map((p) => p.name).filter(Boolean);
|
||||||
|
|
@ -824,7 +842,7 @@ async function fetchCatalogNames(register) {
|
||||||
|
|
||||||
async function fetchOrgListing(register) {
|
async function fetchOrgListing(register) {
|
||||||
const url = `${register.forge}/api/v1/orgs/${register.org}/repos?limit=50`;
|
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}`);
|
if (!res.ok) throw new Error(`org listing returned HTTP ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
levelOf,
|
levelOf,
|
||||||
parseRepoNameFromRemote,
|
parseRepoNameFromRemote,
|
||||||
extractChangelogTop,
|
extractChangelogTop,
|
||||||
|
fetchWithRetry,
|
||||||
} from './repo-standard-check.mjs';
|
} from './repo-standard-check.mjs';
|
||||||
|
|
||||||
const REGISTER = {
|
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'] });
|
const f = checkInternalLinks({ files: { 'README.md': text }, present: ['README.md'] });
|
||||||
assert.equal(f.some((x) => x.code === 'LINK-INTERNAL-MISSING'), true);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ description: >-
|
||||||
"fiks install-blokka", "finn døde repo-referanser", "gjør repoet presentabelt".
|
"fiks install-blokka", "finn døde repo-referanser", "gjør repoet presentabelt".
|
||||||
Trigger when someone is about to release, publish, or hand over a repository
|
Trigger when someone is about to release, publish, or hand over a repository
|
||||||
and wants its public surface to hold up.
|
and wants its public surface to hold up.
|
||||||
version: "0.2.0"
|
version: "0.2.1"
|
||||||
---
|
---
|
||||||
|
|
||||||
# repo-standard — the per-repo gate
|
# repo-standard — the per-repo gate
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue