diff --git a/CHANGELOG.md b/CHANGELOG.md index f706c68a4f3..bfeffb0c6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred. - Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi. - Agents/sandbox: store sandbox container and browser registry entries as per-runtime shard files, reducing unrelated session lock contention while `openclaw doctor --fix` migrates legacy monolithic registry files. (#74831) Thanks @luckylhb90. +- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda. ### Fixes diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index bdb931d33ef..1e404208df7 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -414,6 +414,66 @@ describe("clawhub helpers", () => { ).rejects.toThrow(/declared sha256/); }); + it("annotates 429 errors with the reset hint and a sign-in hint when unauthenticated", async () => { + process.env.OPENCLAW_CLAWHUB_CONFIG_PATH = path.join(os.tmpdir(), "openclaw-no-clawhub-config"); + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => + new Response("Rate limit exceeded", { + status: 429, + headers: { + "RateLimit-Limit": "30", + "RateLimit-Remaining": "0", + "RateLimit-Reset": "42", + }, + }), + }), + ).rejects.toThrow(/Rate limit exceeded \(resets in 42s\) Sign in for higher rate limits\.$/); + }); + + it("degrades gracefully on 429 when the response carries no rate-limit headers", async () => { + process.env.OPENCLAW_CLAWHUB_CONFIG_PATH = path.join(os.tmpdir(), "openclaw-no-clawhub-config"); + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => new Response("Rate limit exceeded", { status: 429 }), + }), + ).rejects.toThrow(/Rate limit exceeded Sign in for higher rate limits\.$/); + }); + + it("annotates 429 errors with the reset hint but no sign-in hint when authenticated", async () => { + process.env.OPENCLAW_CLAWHUB_TOKEN = "env-token-123"; + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => + new Response("Rate limit exceeded", { + status: 429, + headers: { + "RateLimit-Limit": "180", + "RateLimit-Remaining": "0", + "RateLimit-Reset": "10", + }, + }), + }), + ).rejects.toThrow(/Rate limit exceeded \(resets in 10s\)$/); + }); + + it("skips the reset suffix on 429 when Retry-After is an HTTP-date", async () => { + process.env.OPENCLAW_CLAWHUB_TOKEN = "env-token-123"; + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => + new Response("Rate limit exceeded", { + status: 429, + headers: { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" }, + }), + }), + ).rejects.toThrow(/Rate limit exceeded$/); + }); + it("downloads skill archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubSkillArchive({ slug: "agentreceipt", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 863fc64b39e..1d2b0472348 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -555,7 +555,7 @@ function buildUrl(params: Pick { +): Promise<{ response: Response; url: URL; hasToken: boolean }> { const url = buildUrl(params); const token = normalizeOptionalString(params.token) || (await resolveClawHubAuthToken()); const controller = new AbortController(); @@ -573,7 +573,7 @@ async function clawhubRequest( headers: token ? { Authorization: `Bearer ${token}` } : undefined, signal: controller.signal, }); - return { response, url }; + return { response, url, hasToken: Boolean(token) }; } finally { clearTimeout(timeout); } @@ -588,14 +588,43 @@ async function readErrorBody(response: Response): Promise { } } +async function buildClawHubError( + response: Response, + url: URL, + hasToken: boolean, +): Promise { + let body = await readErrorBody(response); + if (response.status === 429) { + const suffix = formatRateLimitSuffix(response.headers, hasToken); + if (suffix) { + body = `${body} ${suffix}`; + } + } + return new ClawHubRequestError({ + path: url.pathname, + status: response.status, + body, + }); +} + +function formatRateLimitSuffix(headers: Headers, hasToken: boolean): string { + const reset = + normalizeHeaderValue(headers.get("RateLimit-Reset")) ?? + normalizeHeaderValue(headers.get("Retry-After")); + const segments: string[] = []; + if (reset && Number.isFinite(Number(reset))) { + segments.push(`(resets in ${reset}s)`); + } + if (!hasToken) { + segments.push("Sign in for higher rate limits."); + } + return segments.join(" "); +} + async function fetchJson(params: ClawHubRequestParams): Promise { - const { response, url } = await clawhubRequest(params); + const { response, url, hasToken } = await clawhubRequest(params); if (!response.ok) { - throw new ClawHubRequestError({ - path: url.pathname, - status: response.status, - body: await readErrorBody(response), - }); + throw await buildClawHubError(response, url, hasToken); } return (await response.json()) as T; } @@ -854,7 +883,7 @@ export async function downloadClawHubPackageArchive(params: { if (!params.version) { throw new Error("ClawPack package downloads require an explicit version."); } - const { response, url } = await clawhubRequest({ + const { response, url, hasToken } = await clawhubRequest({ baseUrl: params.baseUrl, path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( params.version, @@ -864,11 +893,7 @@ export async function downloadClawHubPackageArchive(params: { fetchImpl: params.fetchImpl, }); if (!response.ok) { - throw new ClawHubRequestError({ - path: url.pathname, - status: response.status, - body: await readErrorBody(response), - }); + throw await buildClawHubError(response, url, hasToken); } const bytes = new Uint8Array(await response.arrayBuffer()); const sha256Hex = formatSha256Hex(bytes); @@ -934,7 +959,7 @@ export async function downloadClawHubPackageArchive(params: { : params.tag ? { tag: params.tag } : undefined; - const { response, url } = await clawhubRequest({ + const { response, url, hasToken } = await clawhubRequest({ baseUrl: params.baseUrl, path: `/api/v1/packages/${encodeURIComponent(params.name)}/download`, search, @@ -943,11 +968,7 @@ export async function downloadClawHubPackageArchive(params: { fetchImpl: params.fetchImpl, }); if (!response.ok) { - throw new ClawHubRequestError({ - path: url.pathname, - status: response.status, - body: await readErrorBody(response), - }); + throw await buildClawHubError(response, url, hasToken); } const bytes = new Uint8Array(await response.arrayBuffer()); const sha256Hex = formatSha256Hex(bytes); @@ -975,7 +996,7 @@ export async function downloadClawHubSkillArchive(params: { timeoutMs?: number; fetchImpl?: FetchLike; }): Promise { - const { response, url } = await clawhubRequest({ + const { response, url, hasToken } = await clawhubRequest({ baseUrl: params.baseUrl, path: "/api/v1/download", token: params.token, @@ -988,11 +1009,7 @@ export async function downloadClawHubSkillArchive(params: { }, }); if (!response.ok) { - throw new ClawHubRequestError({ - path: url.pathname, - status: response.status, - body: await readErrorBody(response), - }); + throw await buildClawHubError(response, url, hasToken); } const bytes = new Uint8Array(await response.arrayBuffer()); const sha256Hex = formatSha256Hex(bytes);