Stop asking the forge again for a mesh/check it refuses for good, and end the ask when its key is deleted (issue 774)

gitea asked an owed status again every 30 s forever, even on 403 and 404 (jschoubben/hal and jschoubben/novadock, 119
times each in the sweep), and a key the checker deleted did not end a loop already running.

Quick fix: return from askAgain on 403/404. Thorough fix: the same, said once with its cause; a delete of the key
marks its revision seen, so any running ask again ends before its next write; a newer revision still asks anew.
The checker no longer owes statuses outside the mesh (mesh-delivery), which removes the trigger. Not done: raising
the module's health, since gitea's TypeScript SDK has no seat publish for a condition (mesh-sdk).
This commit is contained in:
2026-10-30 18:23:55 +01:00
parent 2066021fca
commit f52545c4ba
2 changed files with 48 additions and 0 deletions
+18
View File
@@ -83,6 +83,12 @@ export function checkStatusOf(change: OwedChange): { owner: string; repo: string
return { owner, repo, sha, revision, status };
}
/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository
* or commit (404), as the client words it ("Gitea API <path>: <status> ..."). */
export function refusedForGood(err: unknown): boolean {
return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err));
}
/** How many mesh/check writes this module has at the forge at once. */
export const WRITES_AT_ONCE = 4;
@@ -115,6 +121,12 @@ export class CheckStatuses {
try {
asked = checkStatusOf(change);
} catch (err) {
// A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain).
if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) {
const seen = this.seen.get(change.key);
if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision);
return "dropped";
}
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
return "dropped";
}
@@ -172,6 +184,12 @@ export class CheckStatuses {
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
let err = first;
for (let attempt = 1; ; attempt++) {
if (refusedForGood(err)) {
// A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue
// 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew.
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`);
return;
}
const wait = bindWait(attempt);
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
await this.sleep(wait);
+30
View File
@@ -175,3 +175,33 @@ test("the writes a watch's start hands over go to the forge a few at a time, nev
assert.equal(done, 50);
assert.equal(most, WRITES_AT_ONCE);
});
// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal
// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and
// never asked again, a 5xx is still asked again, and a delete ends a loop that is running.
test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => {
const { CheckStatuses, refusedForGood } = await import("../check.ts");
for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true],
["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false],
["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) {
assert.equal(refusedForGood(new Error(err)), forGood, err);
}
const said: string[] = [];
const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } };
const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); });
await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3));
await checks.settled();
assert.equal(said.length, 1);
assert.match(said[0], /refused for good.*403.*not asked again/);
// A 502 asks again; the key's delete meanwhile ends the loop before its next ask.
const g = forge();
let release: () => void = () => {};
const gate = new Promise<void>((r) => { release = r; });
const later = new CheckStatuses(g, () => {}, () => gate);
g.fail(5);
await later.take(put(key(), owed(), 8));
await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any);
release();
await later.settled();
assert.deepEqual(g.sent, [], "a deleted key is not written");
});