Skip to content

Commit 39e1fc2

Browse files
committed
fix(provider-usage): bound remaining usage JSON reads
1 parent a411eed commit 39e1fc2

4 files changed

Lines changed: 85 additions & 9 deletions

File tree

src/infra/provider-usage.fetch.claude.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ function makeOrgAResponse() {
4949
return makeResponse(200, [{ uuid: "org-a" }]);
5050
}
5151

52+
function makeOversizedJsonResponse(status: number): {
53+
response: Response;
54+
state: { canceled: boolean; enqueuedBytes: number };
55+
} {
56+
const state = { canceled: false, enqueuedBytes: 0 };
57+
const chunkSize = 1024 * 1024;
58+
let emitted = 0;
59+
const response = new Response(
60+
new ReadableStream({
61+
pull(controller) {
62+
if (emitted >= 64) {
63+
controller.close();
64+
return;
65+
}
66+
emitted += 1;
67+
state.enqueuedBytes += chunkSize;
68+
controller.enqueue(new Uint8Array(chunkSize));
69+
},
70+
cancel() {
71+
state.canceled = true;
72+
},
73+
}),
74+
{ status, headers: { "Content-Type": "application/json" } },
75+
);
76+
return { response, state };
77+
}
78+
5279
describe("fetchClaudeUsage", () => {
5380
afterEach(() => {
5481
vi.unstubAllEnvs();
@@ -129,6 +156,18 @@ describe("fetchClaudeUsage", () => {
129156
expect(result.windows).toHaveLength(0);
130157
});
131158

159+
it("bounds oversized oauth error bodies and cancels the stream", async () => {
160+
const oversized = makeOversizedJsonResponse(403);
161+
const mockFetch = createProviderUsageFetch(async () => oversized.response);
162+
163+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
164+
165+
expect(result.error).toBe("HTTP 403");
166+
expect(result.windows).toHaveLength(0);
167+
expect(oversized.state.canceled).toBe(true);
168+
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
169+
});
170+
132171
it("returns a stable error for malformed successful oauth usage JSON", async () => {
133172
const mockFetch = createProviderUsageFetch(async () => makeResponse(200, "{not json"));
134173

src/infra/provider-usage.fetch.claude.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Fetches Claude provider usage windows.
2+
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
23
import {
34
buildUsageHttpErrorSnapshot,
45
discardUsageResponseBody,
@@ -7,7 +8,6 @@ import {
78
} from "./provider-usage.fetch.shared.js";
89
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
910
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
10-
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
1111

1212
type ClaudeUsageResponse = {
1313
five_hour?: { utilization?: number; resets_at?: string };

src/infra/provider-usage.fetch.minimax.test.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,33 @@ async function expectMinimaxUsageResult(params: {
2222
expect(result.windows).toEqual(params.expected.windows);
2323
}
2424

25+
function makeOversizedJsonResponse(): {
26+
response: Response;
27+
state: { canceled: boolean; enqueuedBytes: number };
28+
} {
29+
const state = { canceled: false, enqueuedBytes: 0 };
30+
const chunkSize = 1024 * 1024;
31+
let emitted = 0;
32+
const response = new Response(
33+
new ReadableStream({
34+
pull(controller) {
35+
if (emitted >= 64) {
36+
controller.close();
37+
return;
38+
}
39+
emitted += 1;
40+
state.enqueuedBytes += chunkSize;
41+
controller.enqueue(new Uint8Array(chunkSize));
42+
},
43+
cancel() {
44+
state.canceled = true;
45+
},
46+
}),
47+
{ status: 200, headers: { "Content-Type": "application/json" } },
48+
);
49+
return { response, state };
50+
}
51+
2552
describe("fetchMinimaxUsage", () => {
2653
it.each([
2754
{
@@ -99,6 +126,18 @@ describe("fetchMinimaxUsage", () => {
99126
expect(result.windows).toHaveLength(0);
100127
});
101128

129+
it("bounds oversized successful JSON responses and cancels the stream", async () => {
130+
const oversized = makeOversizedJsonResponse();
131+
const mockFetch = createProviderUsageFetch(async () => oversized.response);
132+
133+
const result = await fetchMinimaxUsage("key", 5000, mockFetch);
134+
135+
expect(result.error).toBe("Invalid JSON");
136+
expect(result.windows).toHaveLength(0);
137+
expect(oversized.state.canceled).toBe(true);
138+
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
139+
});
140+
102141
it.each([
103142
{
104143
name: "derives usage from used/total fields and includes reset + plan",
@@ -293,13 +332,8 @@ describe("fetchMinimaxUsage", () => {
293332
first: sharedUsage,
294333
nested: [sharedUsage],
295334
};
296-
const mockFetch = createProviderUsageFetch(
297-
async () =>
298-
({
299-
ok: true,
300-
status: 200,
301-
json: async () => ({ data: dataWithSharedReference }),
302-
}) as Response,
335+
const mockFetch = createProviderUsageFetch(async () =>
336+
makeResponse(200, { data: dataWithSharedReference }),
303337
);
304338

305339
const result = await fetchMinimaxUsage("key", 5000, mockFetch);

src/infra/provider-usage.fetch.minimax.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Fetches and normalizes MiniMax provider usage records.
22
import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
33
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
4+
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
45
import { isRecord } from "../utils.js";
56
import {
67
buildUsageHttpErrorSnapshot,
@@ -418,7 +419,9 @@ export async function fetchMinimaxUsage(
418419
});
419420
}
420421

421-
const data = (await res.json().catch(() => null)) as MinimaxUsageResponse;
422+
const data = await readProviderJsonResponse<MinimaxUsageResponse>(res, "minimax usage").catch(
423+
() => null,
424+
);
422425
if (!isRecord(data)) {
423426
return {
424427
provider: "minimax",

0 commit comments

Comments
 (0)