openclaw/scripts/pr-lib/gh-api-preflight.mjs
Peter Steinberger c79a03cd8a
fix: PR landing fails on exhausted GitHub quota (#154558)
* refactor: simplify PR landing and test fixtures

* fix: recognize GitHub quota exhaustion with CORS headers
2026-09-21 08:19:59 +00:00

216 lines
8.2 KiB
JavaScript

import { readFileSync } from "node:fs";
import { isDirectRunUrl } from "../lib/direct-run.mjs";
export function parseGithubResponse(response) {
const boundary = /\r?\n\r?\n/.exec(response);
const lines = boundary ? response.slice(0, boundary.index).split(/\r?\n/) : [];
const status = /^HTTP\/\d+(?:\.\d+)? ([1-5]\d{2})(?: .*)?$/.exec(lines.shift() ?? "")?.[1];
const headers = new Map();
if (status) {
for (const line of lines) {
const colon = line.indexOf(":");
if (colon > 0) {
headers.set(line.slice(0, colon).toLowerCase(), line.slice(colon + 1).trim());
}
}
}
let body;
try {
body = boundary ? JSON.parse(response.slice(boundary.index + boundary[0].length)) : null;
} catch {
// Malformed output is not evidence of rejected credentials.
}
// Only numeric headers and known resource names may escape this response.
/** @param {string} name */
function numericHeader(name) {
const value = headers.get(name);
return /^\d{1,15}$/.test(value ?? "") ? Number(value) : undefined;
}
const remaining = numericHeader("x-ratelimit-remaining");
const limit = numericHeader("x-ratelimit-limit");
const reset = numericHeader("x-ratelimit-reset");
const retryAfter = numericHeader("retry-after");
const resource = ["graphql", "core"].includes(headers.get("x-ratelimit-resource"))
? headers.get("x-ratelimit-resource")
: "unknown";
const resetUtc =
reset !== undefined && reset <= 253402300799
? new Date(reset * 1000).toISOString().replace(".000Z", "Z")
: "unknown";
return { status, body, remaining, limit, resetUtc, retryAfter, resource };
}
function isPrimaryQuotaExhausted(error, resource) {
const failure = error && typeof error === "object" ? error : {};
const text = (value) =>
typeof value === "string" ? value : Buffer.isBuffer(value) ? value.toString("utf8") : "";
const stdout = text(typeof error === "string" ? error : failure.stdout);
const stderr = text(failure.stderr);
const response = parseGithubResponse(stdout);
const statuses = resource === "graphql" ? ["200", "403"] : ["403"];
const resourceHeader = /^x-ratelimit-resource:[ \t]*([^\r\n]+)\r?$/im.exec(stdout)?.[1]?.trim();
if (
failure.status === 0 ||
(resource === "core" && failure.status === null) ||
failure.signal ||
failure.killed ||
(typeof failure.code === "string" && /^E[A-Z]+$/.test(failure.code)) ||
(response.status && !statuses.includes(response.status)) ||
(resource === "graphql"
? response.resource === "core"
: resourceHeader !== undefined &&
(resourceHeader !== "core" || response.resource !== "core")) ||
(response.remaining !== undefined && response.remaining !== 0) ||
/^retry-after:/im.test(stdout) ||
/\b(?:secondary rate limit|abuse detection|retry-after|proxy authentication required)\b/i.test(
// CORS exposed-header names are not retry directives.
`${response.status ? JSON.stringify(response.body) : stdout}\n${stderr}`,
) ||
[...stderr.matchAll(/\bHTTP(?:\/\d+(?:\.\d+)?)?\s+([1-5]\d{2})\b/gi)].some(
([, status]) => !statuses.includes(status),
)
) {
return false;
}
let body = response.body;
if (response.status && !body) {
return false;
}
if (!response.status && stdout.trim()) {
try {
body = JSON.parse(stdout);
} catch {
return false;
}
}
const primary = (message) =>
typeof message === "string" &&
(resource === "graphql"
? /^API rate limit (?:already )?exceeded\b/i.test(message)
: /^API rate limit (?:already )?exceeded(?: for [^\r\n]+)?\.?$/i.test(message));
if (body?.errors !== undefined) {
return (
resource === "graphql" &&
Array.isArray(body.errors) &&
body.errors.length > 0 &&
body.errors.every(
(entry) => ["RATE_LIMIT", "RATE_LIMITED"].includes(entry?.type) && primary(entry?.message),
)
);
}
if (body) {
return (
primary(body.message) ||
(resource === "core" &&
response.status === "403" &&
response.resource === "core" &&
response.remaining === 0 &&
typeof body === "object" &&
!Array.isArray(body) &&
Object.keys(body).length === 0)
);
}
// PR commands render GraphQL errors as one comma-separated line, without JSON.
const rendered = /^GraphQL: ([^\r\n]+)\s*$/i.exec(stderr);
if (rendered) {
return rendered[1].split(", ").every(primary);
}
// gh emits this exact prefix; arbitrary error messages and supplemental quota
// probes cannot establish which credential or budget rejected the request.
return resource === "graphql"
? /^gh: API rate limit (?:already )?exceeded[^\r\n]*\s*$/i.test(stderr)
: primary(/^gh: ([^\r\n]*?)(?: \(HTTP 403\))?\s*$/i.exec(stderr)?.[1]);
}
export function isGraphqlQuotaExhausted(error) {
return isPrimaryQuotaExhausted(error, "graphql");
}
// Headerless primary errors carry no resource identity. The caller must have
// made a known core REST request; this never authorizes replaying a mutation.
export function isCoreQuotaExhausted(error) {
return isPrimaryQuotaExhausted(error, "core");
}
export function rateLimitRetryGuidance({ remaining, resetUtc, retryAfter }) {
const waits = [];
if (retryAfter !== undefined) {
waits.push(`at least ${retryAfter} seconds`);
}
// Primary reset is a retry constraint only when that budget is exhausted,
// not the unblock time for a secondary throttle with quota remaining.
if (remaining === 0 && resetUtc !== "unknown") {
waits.push(`until ${resetUtc} (UTC)`);
}
if (waits.length === 0) {
waits.push("at least 60 seconds");
}
return `Wait ${waits.join(" and ")}${resetUtc === "unknown" ? "; reset time is unknown" : ""}, then retry manually.`;
}
export function readWriterLogin(exitCode, response) {
const { status, body, remaining, limit, resetUtc, retryAfter, resource } =
parseGithubResponse(response);
const login = body?.login;
if (
exitCode === 0 &&
status === "200" &&
typeof login === "string" &&
login.trim().length > 0 &&
(body.errors === undefined || (Array.isArray(body.errors) && body.errors.length === 0))
) {
return login;
}
const rateLimited =
Array.isArray(body?.errors) &&
body.errors.some((error) => ["RATE_LIMIT", "RATE_LIMITED"].includes(error?.type));
const throttleMessage =
typeof body?.message === "string" &&
/\b(?:secondary rate limit|abuse detection|API rate limit exceeded)\b/i.test(body.message);
const details = `HTTP ${status ?? "unknown"}; exit=${exitCode}`;
const quota = `resource=${resource}; remaining=${remaining ?? "unknown"}; limit=${limit ?? "unknown"}; reset=${resetUtc}`;
const diagnostics = [];
// A depleted balance does not explain a malformed/partial HTTP 200 response.
if (
["200", "403", "429"].includes(status) &&
(status === "429" ||
(status === "403" && (remaining === 0 || retryAfter !== undefined)) ||
rateLimited ||
throttleMessage)
) {
diagnostics.push(
`GitHub API preflight rate limited (${details}; ${quota}${retryAfter === undefined ? "" : `; retry-after=${retryAfter}s`}).`,
rateLimitRetryGuidance({ remaining, resetUtc, retryAfter }),
);
} else if (status === "401" || (exitCode === 4 && !status)) {
// gh v2.98.0 returns 4 for its pre-request missing-auth check; 403 is not that contract.
diagnostics.push(
`GitHub API preflight authentication unavailable (${details}).`,
"Configure or refresh the intended active credential manually, then retry.",
);
} else {
diagnostics.push(`GitHub API preflight failed (${details}); authentication was not verified.`);
if (remaining === 0) {
diagnostics.push(
`Observed exhausted primary quota (${quota}); failure cause remains unverified.`,
);
}
diagnostics.push(
"Check connectivity, GitHub service status, and access policy before retrying.",
);
}
throw Object.assign(new Error(diagnostics.join("\n")), { status: 1 });
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
process.stdout.write(`${readWriterLogin(Number(process.argv[2]), readFileSync(0, "utf8"))}\n`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}