mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Move the unchanged Actions read transport into an independent module so the admission CLI can evaluate consumers without awaiting a circular import of source verification. Preserve the existing source verification export and all verification and placement contracts. Five child-process cases reproduce the original exit-13 failure before the repair and pass afterward. All 59 source/admission/image checks and 665 workflow contracts pass. Both CLI modes also pass read-only smoke checks against real GitHub metadata. Complete latest-head CI passed on its first attempt, with Greptile 5/5 and no unresolved comments. Co-Authored-By: Paperclip <noreply@paperclip.ing>
74 lines
3.5 KiB
JavaScript
74 lines
3.5 KiB
JavaScript
// Statuses that say "ask again", not "the answer is no". A release must not be
|
|
// blocked because GitHub returned a gateway error during a 60-minute poll.
|
|
const TRANSIENT_READ_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
|
|
// A rate-limited read also says "ask again", and GitHub reports both primary
|
|
// and secondary rate limits as 403. Only the headers separate that from a
|
|
// token that may not read Actions, which must still fail at once.
|
|
function rateLimited(response) {
|
|
if (response.status !== 403) return false;
|
|
const header = (name) => response.headers?.get?.(name) ?? null;
|
|
return header("retry-after") !== null || header("x-ratelimit-remaining") === "0";
|
|
}
|
|
|
|
/** Milliseconds from a Retry-After header, when it carries a sane delay. */
|
|
function retryAfterMs(response, capMs) {
|
|
const value = Number(response?.headers?.get?.("retry-after"));
|
|
if (!Number.isFinite(value) || value <= 0) return null;
|
|
return Math.min(value * 1_000, capMs);
|
|
}
|
|
|
|
/**
|
|
* The GitHub Actions read used by the polling below, with transient transport
|
|
* failures retried: network errors, the statuses above, rate-limited 403s, and
|
|
* a body that fails while it is being read. Any other non-OK status throws on
|
|
* the first response, because waiting out a wrong token only delays the news.
|
|
*
|
|
* `deadlineAt` bounds retries by the caller's own polling deadline, so a read
|
|
* cannot extend the wait past the timeout it belongs to.
|
|
*/
|
|
export function createActionsReader({
|
|
token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
attempts = 4, backoffMs = 1_000, log = console.log,
|
|
now = Date.now, deadlineAt = () => Infinity, maxRetryAfterMs = 60_000,
|
|
} = {}) {
|
|
return async (path) => {
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
// Retry only while both the attempt budget and the caller's deadline
|
|
// leave room for the wait this attempt would cost.
|
|
const waitMs = backoffMs * attempt;
|
|
const retryable = attempt < attempts && now() + waitMs < deadlineAt();
|
|
const pause = (response) => sleep(retryAfterMs(response, maxRetryAfterMs) ?? waitMs);
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(`https://api.github.com${path}`, {
|
|
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
signal: AbortSignal.timeout(30_000), redirect: "error",
|
|
});
|
|
} catch (cause) {
|
|
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
|
log(`GitHub Actions read failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
|
await pause();
|
|
continue;
|
|
}
|
|
if (response.ok) {
|
|
try {
|
|
// A 200 whose body is truncated or undecodable is a transport
|
|
// failure like any other, so it belongs inside the retry.
|
|
return await response.json();
|
|
} catch (cause) {
|
|
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
|
log(`GitHub Actions read body failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
|
await pause();
|
|
continue;
|
|
}
|
|
}
|
|
if (!retryable || !(TRANSIENT_READ_STATUSES.has(response.status) || rateLimited(response))) {
|
|
throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`);
|
|
}
|
|
log(`GitHub Actions read failed (HTTP ${response.status}); retrying (${attempt}/${attempts - 1}).`);
|
|
await pause(response);
|
|
}
|
|
};
|
|
}
|