fix(release): drain rate-limited ClawHub responses

This commit is contained in:
Vincent Koc
2026-06-19 12:54:57 +08:00
parent 37b2770071
commit 433d8cbb2c
2 changed files with 73 additions and 6 deletions

View File

@@ -90,6 +90,7 @@ const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai";
const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000;
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024;
const CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const;
const CLAWHUB_MAX_RETRY_AFTER_MS = 60_000;
const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw";
const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml";
const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -490,8 +491,10 @@ async function hasClawHubTrustedPublisher(
});
const { response } = request;
const retryRateLimit =
response.status === 429 && attempt < CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length;
try {
if (response.status !== 429 || attempt >= CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length) {
if (!retryRateLimit) {
if (!response.ok) {
throw new Error(
`Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`,
@@ -522,20 +525,27 @@ async function hasClawHubTrustedPublisher(
request.clearTimeout();
}
await response.body?.cancel().catch(() => undefined);
await delay(clawHubRetryDelayMs(response, attempt));
}
}
function clawHubRetryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter !== null) {
const retryAfter = response.headers.get("retry-after")?.trim();
if (retryAfter) {
const retryAfterSeconds = Number(retryAfter);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
return Math.round(retryAfterSeconds * 1_000);
const retryAfterMs = Math.round(retryAfterSeconds * 1_000);
if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) {
return retryAfterMs;
}
}
const retryAfterAt = Date.parse(retryAfter);
if (Number.isFinite(retryAfterAt)) {
return Math.max(0, retryAfterAt - Date.now());
const retryAfterMs = Math.max(0, retryAfterAt - Date.now());
if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) {
return retryAfterMs;
}
}
}
return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0;

View File

@@ -455,6 +455,7 @@ describe("collectPluginClawHubReleasePlan", () => {
it("retries a rate-limited trusted publisher lookup", async () => {
const repoDir = createTempPluginRepo();
let trustedPublisherRequests = 0;
let rateLimitedBodyCanceled = false;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
@@ -468,7 +469,14 @@ describe("collectPluginClawHubReleasePlan", () => {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = Date.now();
return new Response("", { status: 429 });
return new Response(
new ReadableStream({
cancel() {
rateLimitedBodyCanceled = true;
},
}),
{ status: 429 },
);
}
retryTrustedPublisherRequestAt = Date.now();
return new Response(
@@ -495,6 +503,7 @@ describe("collectPluginClawHubReleasePlan", () => {
});
expect(trustedPublisherRequests).toBe(2);
expect(rateLimitedBodyCanceled).toBe(true);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
@@ -555,6 +564,54 @@ describe("collectPluginClawHubReleasePlan", () => {
);
});
it("falls back to the bounded retry schedule for an excessive Retry-After header", async () => {
const repoDir = createTempPluginRepo();
let trustedPublisherRequests = 0;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = Date.now();
return new Response("", { status: 429, headers: { "retry-after": "999999999999" } });
}
retryTrustedPublisherRequestAt = Date.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(trustedPublisherRequests).toBe(2);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
});
it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({