mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user