diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 6285bd42c37..274f3d4ffe6 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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; diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index f23da5e34b5..6d86d3232c1 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -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({