fix(clawrouter): show stable error for malformed usage responses (#119580)

* fix(clawrouter): handle malformed usage responses

* fix(clawrouter): use canonical usage record coercion

Punchcard-Session: clear-orchard-lantern-bc

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
sunlit-deng
2026-08-05 21:33:06 +08:00
committed by GitHub
parent 29175dd11e
commit 0d7baffc19
2 changed files with 60 additions and 8 deletions
+44 -3
View File
@@ -222,20 +222,61 @@ describe("ClawRouter usage", () => {
expect(snapshot.summary).toBeUndefined();
});
it("rejects usage JSON containing invalid UTF-8", async () => {
it.each([
["malformed JSON", new TextEncoder().encode('{"budget":')],
["a non-object JSON root", new TextEncoder().encode("null")],
])("reports %s as a malformed usage response", async (_label, body) => {
const snapshot = await fetchClawRouterUsage({
token: "test-token",
timeoutMs: 5000,
fetchGuard: mockFetchGuard(new Response(body)),
});
expect(snapshot).toEqual({
provider: "clawrouter",
displayName: "ClawRouter",
windows: [],
error: "Malformed usage response",
});
});
it("reports invalid UTF-8 as a malformed usage response", async () => {
const prefix = new TextEncoder().encode(
'{"budget":{"configured":true,"windowKey":"default/test-policy/2026-',
);
const suffix = new TextEncoder().encode('","limitMicros":1000000,"spentMicros":500000}}');
const body = new Uint8Array([...prefix, 0xff, ...suffix]);
const snapshot = await fetchClawRouterUsage({
token: "test-token",
timeoutMs: 5000,
fetchGuard: mockFetchGuard(new Response(body)),
});
expect(snapshot).toEqual({
provider: "clawrouter",
displayName: "ClawRouter",
windows: [],
error: "Malformed usage response",
});
});
it("preserves response stream failures as transport errors", async () => {
const response = new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError("usage stream failed"));
},
}),
);
await expect(
fetchClawRouterUsage({
token: "test-token",
timeoutMs: 5000,
fetchGuard: mockFetchGuard(new Response(body)),
fetchGuard: mockFetchGuard(response),
}),
).rejects.toThrow(TypeError);
).rejects.toThrow("usage stream failed");
});
it("cancels non-OK usage response body before throwing", async () => {
+16 -5
View File
@@ -5,7 +5,7 @@ import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime";
import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeClawRouterRootUrl } from "./provider-catalog.js";
const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024;
@@ -72,16 +72,19 @@ function buildSummary(payload: ClawRouterUsagePayload): string | undefined {
async function readClawRouterUsagePayload(
response: Response,
timeoutMs: number,
): Promise<ClawRouterUsagePayload> {
): Promise<ClawRouterUsagePayload | undefined> {
const buffer = await readResponseWithLimit(response, CLAWROUTER_USAGE_RESPONSE_MAX_BYTES, {
chunkTimeoutMs: timeoutMs,
onOverflow: ({ maxBytes }) => new Error(`ClawRouter usage response exceeds ${maxBytes} bytes`),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`ClawRouter usage response stalled: no data received for ${chunkTimeoutMs}ms`),
});
return JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(buffer),
) as ClawRouterUsagePayload;
try {
const payload: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer));
return asOptionalRecord(payload) as ClawRouterUsagePayload | undefined;
} catch {
return undefined;
}
}
export async function fetchClawRouterUsage(params: {
@@ -116,6 +119,14 @@ export async function fetchClawRouterUsage(params: {
throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`);
}
const payload = await readClawRouterUsagePayload(response, params.timeoutMs);
if (!payload) {
return {
provider: "clawrouter",
displayName: "ClawRouter",
windows: [],
error: "Malformed usage response",
};
}
const budget = payload.budget;
const limitMicros = asFiniteNumberInRange(budget?.limitMicros, { min: 0 });
const spentMicros = asFiniteNumberInRange(budget?.spentMicros, { min: 0 });