Skip to content

Commit c5bf56b

Browse files
fix(release): the stale-prompts sweep retries a transient 5xx, and defers with a warning instead of reading as main red (#28272)
domain:devx#2 · os-justin · session_01QSeKYmTYfaPbLQD4BJfomW Fixes #28165 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:release.yml 的 stale-prompts 清扫(`scripts/release-pending-publish.mjs sweep`)读 GitHub API 遇到 5xx 或连接被重置时,按共享策略重试(每次读 3 次、间隔 500/1000 ms、整次运行最多 4 次重试);重试用尽仍失败时写一条 warning 和 job summary 小节后以 0 退出,不再把 main 的 Release 染红。 - 为什么改:2026-10-30 一天内 main 的 Release 因同一步骤遇到平台 502 红了 5 次,读起来像 main 红(p0),掩盖真正的发版失败;清扫每次 push 都会重跑,一次没读到只是推迟,不丢任何东西。 - 风险与回滚:改动限于 `scripts/` 下三个文件(未发布,无 changeset):清扫脚本本身;新的叶子模块 `scripts/board-read.mjs`(从 `check-issue-citations.mjs` 原样搬出的重试策略,零 import);`check-issue-citations.mjs` 改为 re-export,原有导入方不变。4xx、其它错误、取消请求失败仍然让 job 失败;in-flight 发布守卫的读取语义不变。回滚即 revert 本 PR。 - 先看:`scripts/release-pending-publish.mjs` 的 `retryingReads` / `expectOk`(重试与分类)和 `sweep` 里 catch `TransientReadFailure` 的分支(唯一以 0 退出的地方);`scripts/board-read.mjs`(确认是原样搬移、零 import)。 - 两分钟验证:`node scripts/release-pending-publish.mjs --self-test`,预期末行 `146 cases across 35 batteries pass`。 - 需要你做的:无,席位落地。 ## What changes `scripts/release-pending-publish.mjs`, plus the shared retry policy moved into a leaf module (`scripts/board-read.mjs`) and re-exported from `scripts/check-issue-citations.mjs`. No workflow file is touched, so this PR is not Tier H. - **Retry, through the shared policy, not a copy.** The sweep's GETs now go through `retryingReads`, which wraps the existing HTTP layer in `readBoard` under `BOARD_READ_RETRY`. That is the same policy and reader the release gate's full-battery read (`scripts/ci/scheduled-full-run.mjs` `readRuns`) uses since the publish-gate twin of this card: 3 attempts per read, 500 / 1000 ms backoff, at most 4 retries for the whole run (at most 3 s of sleep a sweep, against a 10-minute job timeout). Retried: an HTTP 5xx, or a named connection failure (`ECONNRESET`, `UND_ERR_SOCKET`; the policy's closed list). Final at once: any 4xx, and any other thrown error. - **The outcome after the retries: a warning and exit 0.** A read still answering a transient failure comes back flagged, `expectOk` throws it as a `TransientReadFailure`, and `sweep` catches only that class. It prints one `::warning::` line on stderr naming the read, the last answer and the attempts, appends a `NOT READ this run` section to `$GITHUB_STEP_SUMMARY`, and returns 0. Nothing was judged, so nothing was cancelled, and the next push to main re-runs the sweep from scratch. This is the only exit-0 failure path. - **What still fails the job:** a 4xx, a body that does not add up, any other thrown error, and a cancel `POST` that does not answer 202/409. Writes are not retried: they pass through `retryingReads` unchanged. - **The publish guard keeps its semantics.** The `in-flight` mode (`publishInFlight`) still reads through the bare HTTP layer. An unreadable answer is `in-flight` / `unreadable` after one request, which a new case pins. - `sweep` now takes injectable `env`, `http`, `versionCommits`, `npmStateOf`, `now`, `sleep`, `log` and `warn`, and returns the exit code (`main` exits with it). The live defaults are unchanged. - **The policy now lives in a leaf module.** `BOARD_READ_RETRY`, `RETRYABLE_NETWORK_CODES`, `retryableNetworkCode`, `boardReadBudget`, `readBoard` and `readFailure` moved unchanged from `check-issue-citations.mjs` into `scripts/board-read.mjs`, which imports nothing. The only addition is `export` on `sleepMs`, which the citation gate's defaults read. `check-issue-citations.mjs` re-exports every name, so its importers (`scheduled-full-run.mjs`) are unchanged. Its self-test still drives the policy. The import sits beside the re-export, so the census-pinned marker on line 204 does not move. `release-pending-publish.mjs` imports the leaf, so its `./x.mjs` closure is now `board-read.mjs` and `invoked-as.mjs` only. Importing the citation gate would have pulled in `pm/check-half-states.mjs` and the rest of its closure, which `release-verify-npm.mjs --self-test` cannot copy into its fixture repo. That was this PR's first CI red. ## H2: why the sweep keeps listing completed run 40078805767 **By design, so this PR leaves it as it is.** `collectWaitingRuns` finds each version commit's push run by `head_sha`, walking the commits newest first, and stops at the first run older than `WAITING_READ_BACK_DAYS` (30). It then checks that each run it found carries that version's publish job: ```js const verify = carried && id !== String(currentRunId) && (r.status === 'waiting' || r.status === 'completed'); ``` (`scripts/release-pending-publish.mjs` L1228). This came in with `eead9dcf40`, the commit that added the targeted read. Its message says: "It checks that each run it finds carries that version's publish job". The self-test pins it: the `quiet` case reads completed runs 301 and 302, and the `wrong` case flags completed run 501, which has no publish job. Read-only GETs show: - 40078805767 is the push run of the 18.1.0 version commit `e649c2d9` ("chore: version packages (#25606)"), created 2026-10-24, completed / success. - 38841989023, the run named at 13:42Z, is the push run of the previous version commit `e5136ff1` ("chore: version packages (next) (#23481)"), created 2026-10-16. Both are inside the 30-day window. So every push re-reads the jobs listing of every version-commit push run from the last 30 days. 40078805767 is simply the newest of them, and it is read on every sweep until about 2026-11-23. The 502 lands on whichever of those jobs listings GitHub fails to answer. The run itself is not special: a public read of its jobs listing answered 200 with 6 jobs in 1.4 s. With this PR, a 502 on any of those reads is retried, and if it persists the sweep is deferred instead of turning red. ## Tests The self-test has a new battery: `the sweep -> a transient read is retried under one budget, still failing it defers with a warning and exit 0, and a 4xx or a failed cancel stays red`. It has a floor of 10, and the battery floor went from 34 to 35. Its cases drive the whole `sweep` offline with a stubbed `http` and a real summary file in a temp dir: - control: every read answers, so the stale prompt is cancelled, with exit 0, no backoff and no warning; - **502 then 200** on a carried completed run's jobs listing (the incident's read): retried once after 500 ms, then swept (cancel sent, exit 0); - **502 on every attempt**: 3 attempts and 500 + 1000 ms, nothing cancelled, exit 0; - the same case says so: one `::warning::` line naming the read, `HTTP 502 after 3 attempts`, and a `NOT READ` summary section; - **ECONNRESET then 200**: retried, then swept; - an unnamed network error (`ENOTFOUND`): rethrown on the first attempt, so the job goes red; - **404**: fatal at once, with one request, no backoff, nothing cancelled, and the error is not a deferral; - a flapping API: one budget for the whole sweep, 4 retries, then the fifth transient answer defers; - a cancel answering 502: sent once, not retried, exit 1 as before; - the publish guard (`in-flight`) on a 502: still `unreadable`, so `in-flight`, after one request. The existing pin `a probe answering non-200 -> the read throws (a red job)` still passes unchanged on the bare layer. <details><summary>self-test at 88c63cd</summary> ``` ✓ release-pending-publish self-test: 146 cases across 35 batteries pass. os-verify-lock: VERDICT command-exit 0 ``` (The baseline at 361ea24 was 136 cases across 34 batteries.) </details> <details><summary>Ablations, both via scripts/ablation-replace.mjs, restored byte-identical</summary> 1. Retry removed (`if (method !== 'GET') return http(method, path);` replaced by an unconditional pass-through): the mutation landed (anchor 1 to 0, blob `186cbaab511d` to `fb6db796de53`), and the self-test went red with 5 failures. Among them were "a 502 then a 200 ... swept", "a 502 on every attempt ...", "a connection reset ... swept" and "a flapping API ...". Restored: blob equals HEAD `186cbaab511d`, and `git diff HEAD` is empty. 2. Deferral removed (the `TransientReadFailure` catch rethrows every error): the mutation landed, and the self-test went red with 3 failures ("a 502 on every attempt ...", "and it says so ...", "a flapping API ..."). Restored byte-identical to HEAD. </details> ## Acceptance notes - `.github/workflows/release.yml`'s `stale-prompts` comment says "a failed read here must be loud (a red job on main)". After this PR, a read that keeps failing transiently is a `::warning::` annotation plus a summary section, not a red job. Every other failure is still red. The comment is in a Tier H file, so this PR does not edit it. carrier: none. - The policy's network list stays closed (`ECONNRESET`, `UND_ERR_SOCKET`). Other connection failures, such as `ENOTFOUND` and `ETIMEDOUT`, stay final for the sweep exactly as they do for the release gate. Widening the list is a change to the shared policy, not to this script. - No live `sweep` was run. All proof comes from the stubbed harness. - `scripts/ci/scheduled-full-run.mjs` still imports the policy through `check-issue-citations.mjs`, so the citation gate's PM closure still loads in the `full-battery` job. Switching it to `./board-read.mjs` is a one-line follow-up, left out of this PR on purpose. carrier: none. ## Verification I ran the local gates at `88c63cd785`, the final commit. The derived list came from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, and all 34 exited 0. They include `node scripts/release-pending-publish.mjs --self-test`, `node scripts/check-issue-citations.mjs`, `pnpm check:issue-citations` and `pnpm check:pm-dispatch-gates` (fast tier, 1937 cases). The `dispatch-gates --ran` reconciliation answered `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3)`. Outside the derived set, these also exited 0: `node scripts/release-verify-npm.mjs --self-test` (169 cases, 16 batteries), `node scripts/check-issue-citations.mjs --self-test`, `node scripts/ci/scheduled-full-run.mjs --self-test`, and `eslint --no-inline-config` on the three changed files. No changeset: `scripts/` publishes nothing, hence `skip-changeset`. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b358e79 commit c5bf56b

3 files changed

Lines changed: 475 additions & 156 deletions

File tree

‎scripts/board-read.mjs‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3+
//
4+
// board-read -- the ONE transient-read retry policy for a GitHub REST read, as
5+
// a LEAF module: it imports nothing at all, not even a `node:*` builtin, and
6+
// runs nothing at load.
7+
//
8+
// Why a leaf: the policy was measured and pinned by the citation gate
9+
// (`check-issue-citations.mjs`, whose `--self-test` still drives every line
10+
// below through its re-exports), and release tooling reads GitHub through it
11+
// too (`ci/scheduled-full-run.mjs`'s release gate, `release-pending-publish.mjs`'s
12+
// stale-prompts sweep). Release scripts run off a bare checkout with no install
13+
// step, and `release-verify-npm.mjs`'s self-test copies each release script's
14+
// `./x.mjs` import closure into a fixture repo -- so a release script must not
15+
// pull the citation gate's whole lint/PM closure (`pm/check-half-states.mjs`
16+
// and what it imports) just to retry a read. ⛔ Keep this file free of
17+
// imports: a dependency added here lands in every one of those closures.
18+
//
19+
// Moved here unchanged from `check-issue-citations.mjs`, which re-exports
20+
// every name, so its importers are unchanged. The only addition is the
21+
// `export` on `sleepMs`, which the citation gate's own defaults read.
22+
//
23+
// The docblock below speaks for the GATES that read through this policy (the
24+
// citation gate, the release gate): a spent ladder is a refusal there, never a
25+
// pass. The stale-prompts sweep is the one caller that ends exit 0 on a spent
26+
// ladder (`deferredSweep` in `release-pending-publish.mjs`) -- it is a sweep
27+
// the next push re-runs, not a gate, and it says so in a warning.
28+
29+
/**
30+
* ⭐ A RETRYABLE BOARD READ (#23361). One HTTP 500 from GitHub used to end the
31+
* run as PREREQUISITE NOT MET: measured on a merge-queue group whose lint lane
32+
* went red on `GET issues/4045 -> HTTP 500`, and on a local re-run that died on
33+
* the head probe instead, while plain reads of both answered 200 that minute.
34+
* A server error is retryable by definition, so a board read now gets a short,
35+
* bounded ladder before the gate concludes the board cannot be read.
36+
*
37+
* What is retried -- a closed set, each member named:
38+
*
39+
* HTTP 5xx any status 500..599 the board answers.
40+
* ECONNRESET the peer reset the connection. node's fetch throws a
41+
* `TypeError` (`fetch failed`, or `terminated` when the reset
42+
* lands while the body streams) whose `cause.code` is this.
43+
* UND_ERR_SOCKET the peer closed the socket before the answer was whole
44+
* (`other side closed`) -- the same failure without the RST.
45+
*
46+
* MEASURED on node 22.22.0 against a local socket server, directly and through
47+
* a CONNECT proxy under `--use-env-proxy`: a reset before the answer and one
48+
* mid-body arrive as those two codes on the cause chain, nowhere else. Because
49+
* a reset can land while the body streams, the unit retried is the request AND
50+
* its JSON body, never the request alone.
51+
*
52+
* ⛔ What is NOT retried, each an answer rather than a blip: any 4xx (a 404 is
53+
* this gate's "absent" and keeps that meaning; a 401/403/429 is about the
54+
* credential or the rate limit, and asking again does not change it); any other
55+
* thrown error -- `ECONNREFUSED`, `ENOTFOUND`, a proxy that refuses the tunnel
56+
* (`UND_ERR_ABORTED`, measured), a body that is not JSON. Those reach the
57+
* caller unchanged, on the exit path they always had.
58+
*
59+
* ⛔ NOT A WEAKER GATE. A retry only ever buys another attempt at the same read;
60+
* a read still failing after its ladder is handed back as the failure it is,
61+
* and every caller turns that into `prerequisiteRefusal` -- exit 3, never 0.
62+
* The refusal text names the attempt count, so a red in the queue says it was
63+
* not one blip.
64+
*
65+
* The budget is bounded per READ and per RUN, so a lint lane never trades a
66+
* fast exit 3 for a slow one:
67+
*
68+
* a board that stays down the first read spends its ladder (500 + 1000 ms
69+
* of sleep, 2 extra requests) and the run refuses --
70+
* however many numbers the change cites, because the
71+
* first failed read ends the board build.
72+
* a board that flaps `retriesPerRun` caps the whole run at 4 retries,
73+
* so at most 2 full ladders: 3000 ms of sleep and 4
74+
* extra requests, whether the run probes 1 number or
75+
* 400 or enumerates 159 pages. Past the budget the
76+
* next 5xx is final.
77+
*
78+
* Request latency itself is not bounded here (no read in this file sets a
79+
* timeout, before or after this change).
80+
*/
81+
export const BOARD_READ_RETRY = Object.freeze({
82+
/** One read plus up to two retries. */
83+
attemptsPerRead: 3,
84+
/** The sleep before retry 1, retry 2. One entry per retry a read may take. */
85+
backoffMs: Object.freeze([500, 1000]),
86+
/** Retries the whole run may spend, across every read it makes. */
87+
retriesPerRun: 4,
88+
});
89+
90+
/** The connection failures a read is retried on. ⛔ Closed: a code not here is an answer. */
91+
export const RETRYABLE_NETWORK_CODES = Object.freeze(['ECONNRESET', 'UND_ERR_SOCKET']);
92+
93+
/** The named connection failure on `err`'s cause chain, or `null` when there is none. */
94+
export function retryableNetworkCode(err) {
95+
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth += 1) {
96+
if (typeof e.code === 'string' && RETRYABLE_NETWORK_CODES.includes(e.code)) return e.code;
97+
}
98+
return null;
99+
}
100+
101+
/** The real backoff: a timer. Injectable everywhere it is used, so a self-test never waits on it. */
102+
export const sleepMs = (ms) => new Promise((done) => { setTimeout(done, ms); });
103+
104+
/**
105+
* One RUN's retry budget, shared by every board read the run makes -- the
106+
* per-run half of the bound above. `sleep` is injectable so `--self-test`
107+
* spends no wall time on a backoff it only needs to observe.
108+
*/
109+
export function boardReadBudget({ policy = BOARD_READ_RETRY, sleep = sleepMs } = {}) {
110+
return { policy, sleep, retriesSpent: 0 };
111+
}
112+
113+
/**
114+
* One board read -- the request and, when it is answered OK, its JSON body --
115+
* retried on a 5xx or a named connection failure while both the read's ladder
116+
* and the run's budget allow.
117+
*
118+
* Always resolves to a result; never decides a verdict. `ok`/`status`/`body`
119+
* are the LAST attempt's, `status` is `null` when that attempt threw, and
120+
* `stoppedBy` says why there was no further attempt:
121+
*
122+
* 'answer' the read was answered with something that is not retried;
123+
* 'attempts' the read spent its own ladder;
124+
* 'budget' the run's retry budget was already spent.
125+
*
126+
* ⛔ An error that is not a named connection failure is rethrown unchanged.
127+
*/
128+
export async function readBoard(fetchImpl, url, init, budget) {
129+
const { policy } = budget;
130+
for (let attempt = 1; ; attempt += 1) {
131+
let read;
132+
try {
133+
const res = await fetchImpl(url, init);
134+
read = { ok: res.ok, status: res.status, headers: res.headers, body: null, code: null, cause: null, attempts: attempt };
135+
if (res.ok) read.body = await res.json();
136+
} catch (err) {
137+
const code = retryableNetworkCode(err);
138+
if (!code) throw err;
139+
read = { ok: false, status: null, headers: null, body: null, code, cause: err, attempts: attempt };
140+
}
141+
const retryable = read.status === null || (read.status >= 500 && read.status <= 599);
142+
if (!retryable) return { ...read, stoppedBy: 'answer' };
143+
if (attempt >= policy.attemptsPerRead) return { ...read, stoppedBy: 'attempts' };
144+
if (budget.retriesSpent >= policy.retriesPerRun) return { ...read, stoppedBy: 'budget' };
145+
budget.retriesSpent += 1;
146+
await budget.sleep(policy.backoffMs[attempt - 1]);
147+
}
148+
}
149+
150+
/** The error a failed read becomes. Its text names the attempts -- never a tracker number. */
151+
export function readFailure(label, read, policy = BOARD_READ_RETRY) {
152+
const answer = read.status === null ? read.code : `HTTP ${read.status}`;
153+
const tries = `after ${read.attempts} attempt${read.attempts === 1 ? '' : 's'}`;
154+
const why = {
155+
answer: 'not retried: only a 5xx or a connection reset is',
156+
attempts: 'every attempt one read is allowed, so not one blip',
157+
budget: `this run's budget of ${policy.retriesPerRun} retries is spent`,
158+
}[read.stoppedBy];
159+
return new Error(`${label} -> ${answer} ${tries} (${why})`, read.cause ? { cause: read.cause } : undefined);
160+
}

‎scripts/check-issue-citations.mjs‎

Lines changed: 10 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -694,137 +694,16 @@ function authHeaders(token) {
694694
return h;
695695
}
696696

697-
/**
698-
* ⭐ A RETRYABLE BOARD READ (#23361). One HTTP 500 from GitHub used to end the
699-
* run as PREREQUISITE NOT MET: measured on a merge-queue group whose lint lane
700-
* went red on `GET issues/4045 -> HTTP 500`, and on a local re-run that died on
701-
* the head probe instead, while plain reads of both answered 200 that minute.
702-
* A server error is retryable by definition, so a board read now gets a short,
703-
* bounded ladder before the gate concludes the board cannot be read.
704-
*
705-
* What is retried -- a closed set, each member named:
706-
*
707-
* HTTP 5xx any status 500..599 the board answers.
708-
* ECONNRESET the peer reset the connection. node's fetch throws a
709-
* `TypeError` (`fetch failed`, or `terminated` when the reset
710-
* lands while the body streams) whose `cause.code` is this.
711-
* UND_ERR_SOCKET the peer closed the socket before the answer was whole
712-
* (`other side closed`) -- the same failure without the RST.
713-
*
714-
* MEASURED on node 22.22.0 against a local socket server, directly and through
715-
* a CONNECT proxy under `--use-env-proxy`: a reset before the answer and one
716-
* mid-body arrive as those two codes on the cause chain, nowhere else. Because
717-
* a reset can land while the body streams, the unit retried is the request AND
718-
* its JSON body, never the request alone.
719-
*
720-
* ⛔ What is NOT retried, each an answer rather than a blip: any 4xx (a 404 is
721-
* this gate's "absent" and keeps that meaning; a 401/403/429 is about the
722-
* credential or the rate limit, and asking again does not change it); any other
723-
* thrown error -- `ECONNREFUSED`, `ENOTFOUND`, a proxy that refuses the tunnel
724-
* (`UND_ERR_ABORTED`, measured), a body that is not JSON. Those reach the
725-
* caller unchanged, on the exit path they always had.
726-
*
727-
* ⛔ NOT A WEAKER GATE. A retry only ever buys another attempt at the same read;
728-
* a read still failing after its ladder is handed back as the failure it is,
729-
* and every caller turns that into `prerequisiteRefusal` -- exit 3, never 0.
730-
* The refusal text names the attempt count, so a red in the queue says it was
731-
* not one blip.
732-
*
733-
* The budget is bounded per READ and per RUN, so a lint lane never trades a
734-
* fast exit 3 for a slow one:
735-
*
736-
* a board that stays down the first read spends its ladder (500 + 1000 ms
737-
* of sleep, 2 extra requests) and the run refuses --
738-
* however many numbers the change cites, because the
739-
* first failed read ends the board build.
740-
* a board that flaps `retriesPerRun` caps the whole run at 4 retries,
741-
* so at most 2 full ladders: 3000 ms of sleep and 4
742-
* extra requests, whether the run probes 1 number or
743-
* 400 or enumerates 159 pages. Past the budget the
744-
* next 5xx is final.
745-
*
746-
* Request latency itself is not bounded here (no read in this file sets a
747-
* timeout, before or after this change).
748-
*/
749-
export const BOARD_READ_RETRY = Object.freeze({
750-
/** One read plus up to two retries. */
751-
attemptsPerRead: 3,
752-
/** The sleep before retry 1, retry 2. One entry per retry a read may take. */
753-
backoffMs: Object.freeze([500, 1000]),
754-
/** Retries the whole run may spend, across every read it makes. */
755-
retriesPerRun: 4,
756-
});
757-
758-
/** The connection failures a read is retried on. ⛔ Closed: a code not here is an answer. */
759-
export const RETRYABLE_NETWORK_CODES = Object.freeze(['ECONNRESET', 'UND_ERR_SOCKET']);
760-
761-
/** The named connection failure on `err`'s cause chain, or `null` when there is none. */
762-
export function retryableNetworkCode(err) {
763-
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth += 1) {
764-
if (typeof e.code === 'string' && RETRYABLE_NETWORK_CODES.includes(e.code)) return e.code;
765-
}
766-
return null;
767-
}
768-
769-
const sleepMs = (ms) => new Promise((done) => { setTimeout(done, ms); });
770-
771-
/**
772-
* One RUN's retry budget, shared by every board read the run makes -- the
773-
* per-run half of the bound above. `sleep` is injectable so `--self-test`
774-
* spends no wall time on a backoff it only needs to observe.
775-
*/
776-
export function boardReadBudget({ policy = BOARD_READ_RETRY, sleep = sleepMs } = {}) {
777-
return { policy, sleep, retriesSpent: 0 };
778-
}
779-
780-
/**
781-
* One board read -- the request and, when it is answered OK, its JSON body --
782-
* retried on a 5xx or a named connection failure while both the read's ladder
783-
* and the run's budget allow.
784-
*
785-
* Always resolves to a result; never decides a verdict. `ok`/`status`/`body`
786-
* are the LAST attempt's, `status` is `null` when that attempt threw, and
787-
* `stoppedBy` says why there was no further attempt:
788-
*
789-
* 'answer' the read was answered with something that is not retried;
790-
* 'attempts' the read spent its own ladder;
791-
* 'budget' the run's retry budget was already spent.
792-
*
793-
* ⛔ An error that is not a named connection failure is rethrown unchanged.
794-
*/
795-
export async function readBoard(fetchImpl, url, init, budget) {
796-
const { policy } = budget;
797-
for (let attempt = 1; ; attempt += 1) {
798-
let read;
799-
try {
800-
const res = await fetchImpl(url, init);
801-
read = { ok: res.ok, status: res.status, headers: res.headers, body: null, code: null, cause: null, attempts: attempt };
802-
if (res.ok) read.body = await res.json();
803-
} catch (err) {
804-
const code = retryableNetworkCode(err);
805-
if (!code) throw err;
806-
read = { ok: false, status: null, headers: null, body: null, code, cause: err, attempts: attempt };
807-
}
808-
const retryable = read.status === null || (read.status >= 500 && read.status <= 599);
809-
if (!retryable) return { ...read, stoppedBy: 'answer' };
810-
if (attempt >= policy.attemptsPerRead) return { ...read, stoppedBy: 'attempts' };
811-
if (budget.retriesSpent >= policy.retriesPerRun) return { ...read, stoppedBy: 'budget' };
812-
budget.retriesSpent += 1;
813-
await budget.sleep(policy.backoffMs[attempt - 1]);
814-
}
815-
}
816-
817-
/** The error a failed read becomes. Its text names the attempts -- never a tracker number. */
818-
export function readFailure(label, read, policy = BOARD_READ_RETRY) {
819-
const answer = read.status === null ? read.code : `HTTP ${read.status}`;
820-
const tries = `after ${read.attempts} attempt${read.attempts === 1 ? '' : 's'}`;
821-
const why = {
822-
answer: 'not retried: only a 5xx or a connection reset is',
823-
attempts: 'every attempt one read is allowed, so not one blip',
824-
budget: `this run's budget of ${policy.retriesPerRun} retries is spent`,
825-
}[read.stoppedBy];
826-
return new Error(`${label} -> ${answer} ${tries} (${why})`, read.cause ? { cause: read.cause } : undefined);
827-
}
697+
// The transient-read retry policy lives in the leaf `./board-read.mjs` (moved
698+
// there unchanged so release tooling can import it without this file's
699+
// closure); re-exported so every importer of these names is unchanged. This
700+
// file's `--self-test` still pins it. The import sits here, beside the
701+
// re-export, rather than in the header's import block: a line added above this
702+
// file's population-marker declaration (near the top) moves the line number the
703+
// dispatch-gates self-test's census pins it at. An ES import is hoisted wherever
704+
// it is written.
705+
import { BOARD_READ_RETRY, boardReadBudget, readBoard, readFailure, retryableNetworkCode, sleepMs } from './board-read.mjs';
706+
export { BOARD_READ_RETRY, RETRYABLE_NETWORK_CODES, retryableNetworkCode, boardReadBudget, readBoard, readFailure } from './board-read.mjs';
828707

829708
/**
830709
* Strategy A -- enumerate the whole board once (cursor pagination), then judge

0 commit comments

Comments
 (0)