From db2786bde1055fcc27b0be1243c662fbd797efbc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 28 Jun 2026 21:57:22 -0700 Subject: [PATCH] fix(provider-usage): bound usage response body reads --- src/infra/provider-usage.fetch.claude.test.ts | 39 +++++++++++++++ src/infra/provider-usage.fetch.claude.ts | 2 +- .../provider-usage.fetch.minimax.test.ts | 48 ++++++++++++++++--- src/infra/provider-usage.fetch.minimax.ts | 5 +- src/infra/provider-usage.fetch.shared.ts | 4 +- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts index 89cc8b750d54..469aae0b2d1a 100644 --- a/src/infra/provider-usage.fetch.claude.test.ts +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -49,6 +49,33 @@ function makeOrgAResponse() { return makeResponse(200, [{ uuid: "org-a" }]); } +function makeOversizedJsonResponse(status: number): { + response: Response; + state: { canceled: boolean; enqueuedBytes: number }; +} { + const state = { canceled: false, enqueuedBytes: 0 }; + const chunkSize = 1024 * 1024; + let emitted = 0; + const response = new Response( + new ReadableStream({ + pull(controller) { + if (emitted >= 64) { + controller.close(); + return; + } + emitted += 1; + state.enqueuedBytes += chunkSize; + controller.enqueue(new Uint8Array(chunkSize)); + }, + cancel() { + state.canceled = true; + }, + }), + { status, headers: { "Content-Type": "application/json" } }, + ); + return { response, state }; +} + describe("fetchClaudeUsage", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -129,6 +156,18 @@ describe("fetchClaudeUsage", () => { expect(result.windows).toHaveLength(0); }); + it("bounds oversized oauth error bodies and cancels the stream", async () => { + const oversized = makeOversizedJsonResponse(403); + const mockFetch = createProviderUsageFetch(async () => oversized.response); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + + expect(result.error).toBe("HTTP 403"); + expect(result.windows).toHaveLength(0); + expect(oversized.state.canceled).toBe(true); + expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024); + }); + it("returns a stable error for malformed successful oauth usage JSON", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, "{not json")); diff --git a/src/infra/provider-usage.fetch.claude.ts b/src/infra/provider-usage.fetch.claude.ts index 68b2d55fae9e..08d400a45a88 100644 --- a/src/infra/provider-usage.fetch.claude.ts +++ b/src/infra/provider-usage.fetch.claude.ts @@ -1,4 +1,5 @@ // Fetches Claude provider usage windows. +import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { buildUsageHttpErrorSnapshot, discardUsageResponseBody, @@ -7,7 +8,6 @@ import { } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; -import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; type ClaudeUsageResponse = { five_hour?: { utilization?: number; resets_at?: string }; diff --git a/src/infra/provider-usage.fetch.minimax.test.ts b/src/infra/provider-usage.fetch.minimax.test.ts index 8d33d7896d89..9db06c910378 100644 --- a/src/infra/provider-usage.fetch.minimax.test.ts +++ b/src/infra/provider-usage.fetch.minimax.test.ts @@ -22,6 +22,33 @@ async function expectMinimaxUsageResult(params: { expect(result.windows).toEqual(params.expected.windows); } +function makeOversizedJsonResponse(): { + response: Response; + state: { canceled: boolean; enqueuedBytes: number }; +} { + const state = { canceled: false, enqueuedBytes: 0 }; + const chunkSize = 1024 * 1024; + let emitted = 0; + const response = new Response( + new ReadableStream({ + pull(controller) { + if (emitted >= 64) { + controller.close(); + return; + } + emitted += 1; + state.enqueuedBytes += chunkSize; + controller.enqueue(new Uint8Array(chunkSize)); + }, + cancel() { + state.canceled = true; + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + return { response, state }; +} + describe("fetchMinimaxUsage", () => { it.each([ { @@ -99,6 +126,18 @@ describe("fetchMinimaxUsage", () => { expect(result.windows).toHaveLength(0); }); + it("bounds oversized successful JSON responses and cancels the stream", async () => { + const oversized = makeOversizedJsonResponse(); + const mockFetch = createProviderUsageFetch(async () => oversized.response); + + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.error).toBe("Invalid JSON"); + expect(result.windows).toHaveLength(0); + expect(oversized.state.canceled).toBe(true); + expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024); + }); + it.each([ { name: "derives usage from used/total fields and includes reset + plan", @@ -293,13 +332,8 @@ describe("fetchMinimaxUsage", () => { first: sharedUsage, nested: [sharedUsage], }; - const mockFetch = createProviderUsageFetch( - async () => - ({ - ok: true, - status: 200, - json: async () => ({ data: dataWithSharedReference }), - }) as Response, + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { data: dataWithSharedReference }), ); const result = await fetchMinimaxUsage("key", 5000, mockFetch); diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index 1b423a1b8b93..000352479907 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -1,6 +1,7 @@ // Fetches and normalizes MiniMax provider usage records. import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { isRecord } from "../utils.js"; import { buildUsageHttpErrorSnapshot, @@ -418,7 +419,9 @@ export async function fetchMinimaxUsage( }); } - const data = (await res.json().catch(() => null)) as MinimaxUsageResponse; + const data = await readProviderJsonResponse(res, "minimax usage").catch( + () => null, + ); if (!isRecord(data)) { return { provider: "minimax", diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index b3c9fd350b23..2ddafc6d1e0f 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -1,5 +1,6 @@ // Shared fetch and parsing helpers for provider usage endpoints. import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js"; import { PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; @@ -67,7 +68,8 @@ export async function readUsageJson( response: Response, ): Promise<{ ok: true; data: unknown } | { ok: false; snapshot: ProviderUsageSnapshot }> { try { - return { ok: true, data: await response.json() }; + const data = await readProviderJsonResponse(response, `${provider} usage`); + return { ok: true, data }; } catch { return { ok: false,