feat(clawhub): surface rate-limit headers and sign-in hint on 429s (#76748)

* feat(clawhub): surface rate-limit headers and sign-in hint on 429s

* feat(clawhub): handle no-headers 429s and add changelog entry

* fix(clawhub): simplify 429 message to reset hint only
This commit is contained in:
Dallin Romney
2026-05-04 00:34:50 +08:00
committed by GitHub
parent 07a11c4806
commit aa18254770
3 changed files with 104 additions and 26 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -555,7 +555,7 @@ function buildUrl(params: Pick<ClawHubRequestParams, "baseUrl" | "path" | "searc
async function clawhubRequest(
params: ClawHubRequestParams,
): Promise<{ response: Response; url: URL }> {
): 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<string> {
}
}
async function buildClawHubError(
response: Response,
url: URL,
hasToken: boolean,
): Promise<ClawHubRequestError> {
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<T>(params: ClawHubRequestParams): Promise<T> {
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<ClawHubDownloadResult> {
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);