From 37b277007180bdfee100a84171bf2ea81b69b5af Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:38:15 +0800 Subject: [PATCH] fix(release): retry ClawHub release planning --- scripts/lib/plugin-clawhub-release.ts | 157 +++++++++++++++----------- test/plugin-clawhub-release.test.ts | 105 ++++++++++++++++- 2 files changed, 196 insertions(+), 66 deletions(-) diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 9151928a23ee..6285bd42c374 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -89,6 +89,7 @@ type ClawHubPublishablePluginPackageFilters = { 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 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._-]*$/; @@ -482,43 +483,70 @@ async function hasClawHubTrustedPublisher( `/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`, getRegistryBaseUrl(options.registryBaseUrl), ); - const request = await fetchClawHubRequest(url, { - fetchImpl: options.fetchImpl, - requestTimeoutMs: options.requestTimeoutMs, - }); - const { response } = request; + for (let attempt = 0; ; attempt += 1) { + const request = await fetchClawHubRequest(url, { + fetchImpl: options.fetchImpl, + requestTimeoutMs: options.requestTimeoutMs, + }); + const { response } = request; - try { - if (!response.ok) { - throw new Error( - `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, - ); - } - - let trustedPublisherDetail: ClawHubTrustedPublisherDetail; - const text = await readBoundedResponseText( - response, - `ClawHub trusted publisher ${packageName}`, - CLAWHUB_RESPONSE_BODY_MAX_BYTES, - { - signal: request.signal, - timeoutPromise: request.timeoutPromise, - }, - ); try { - trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; - } catch (error) { - throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { - cause: error, - }); + if (response.status !== 429 || attempt >= CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length) { + if (!response.ok) { + throw new Error( + `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, + ); + } + + let trustedPublisherDetail: ClawHubTrustedPublisherDetail; + const text = await readBoundedResponseText( + response, + `ClawHub trusted publisher ${packageName}`, + CLAWHUB_RESPONSE_BODY_MAX_BYTES, + { + signal: request.signal, + timeoutPromise: request.timeoutPromise, + }, + ); + try { + trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; + } catch (error) { + throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { + cause: error, + }); + } + + return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); + } + } finally { + request.clearTimeout(); } - return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); - } finally { - request.clearTimeout(); + await delay(clawHubRetryDelayMs(response, attempt)); } } +function clawHubRetryDelayMs(response: Response, attempt: number): number { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter !== null) { + const retryAfterSeconds = Number(retryAfter); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.round(retryAfterSeconds * 1_000); + } + const retryAfterAt = Date.parse(retryAfter); + if (Number.isFinite(retryAfterAt)) { + return Math.max(0, retryAfterAt - Date.now()); + } + } + return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + function isOpenClawPluginTrustedPublisher(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; @@ -581,42 +609,41 @@ export async function collectPluginClawHubReleasePlan(params?: { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan"); } - const planned = await Promise.all( - selectedPublishable.map(async (plugin): Promise => { - const packageExists = await doesClawHubPackageExist(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }); - const hasTrustedPublisher = packageExists - ? await hasClawHubTrustedPublisher(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }) - : false; - const alreadyPublished = packageExists - ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }) - : false; + const planned: PluginReleasePlanItemWithPackageState[] = []; + for (const plugin of selectedPublishable) { + const packageExists = await doesClawHubPackageExist(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }); + const hasTrustedPublisher = packageExists + ? await hasClawHubTrustedPublisher(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }) + : false; + const alreadyPublished = packageExists + ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }) + : false; - return { - extensionId: plugin.extensionId, - packageDir: plugin.packageDir, - packageName: plugin.packageName, - version: plugin.version, - channel: plugin.channel, - publishTag: plugin.publishTag, - packageExists, - hasTrustedPublisher, - alreadyPublished, - artifactName: formatClawHubPackageArtifactName(plugin), - }; - }), - ); + planned.push({ + extensionId: plugin.extensionId, + packageDir: plugin.packageDir, + packageName: plugin.packageName, + version: plugin.version, + channel: plugin.channel, + publishTag: plugin.publishTag, + packageExists, + hasTrustedPublisher, + alreadyPublished, + artifactName: formatClawHubPackageArtifactName(plugin), + }); + } const all = planned.map(stripPackageReleaseState); return { diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 98ef83015638..f23da5e34b58 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { delimiter, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenClawReleaseClawHubPlan, buildOpenClawReleaseClawHubRuntimeState, @@ -452,6 +452,109 @@ describe("collectPluginClawHubReleasePlan", () => { expect(canceled).toEqual(["package", "version"]); }); + it("retries a rate-limited trusted publisher lookup", 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 }); + } + 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}`); + }; + + const plan = 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, + ); + expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]); + }); + + it("honors an HTTP-date Retry-After header", async () => { + const repoDir = createTempPluginRepo(); + const retryAfter = "Wed, 21 Oct 2030 07:28:00 GMT"; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(retryAfter) - 1_000); + 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 = performance.now(); + return new Response("", { status: 429, headers: { "retry-after": retryAfter } }); + } + retryTrustedPublisherRequestAt = performance.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}`); + }; + + try { + await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + } finally { + nowSpy.mockRestore(); + } + + 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({