Skip to content

fix(http): bound the size of buffered JSON responses - #281

Open
Yazan-O wants to merge 2 commits into
TestSprite:mainfrom
Yazan-O:fix/bound-response-body-size
Open

fix(http): bound the size of buffered JSON responses#281
Yazan-O wants to merge 2 commits into
TestSprite:mainfrom
Yazan-O:fix/bound-response-body-size

Conversation

@Yazan-O

@Yazan-O Yazan-O commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes #83

What

HttpClient.request() reads every successful response with await response.json(), which buffers the entire body into memory before parsing, with no upper bound. A large test result --history page, or a run with a long steps[] array (getRun with includeSteps), grows the heap in proportion to the payload.

This adds a bounded read at that single choke point:

  • A declared Content-Length over the cap is rejected before the body is read.
  • Chunked bodies (no Content-Length) are bounded by counting bytes as they stream, stopping once the cap is crossed.
  • On exceed, it throws a typed PAYLOAD_TOO_LARGE error (exit 5 — the same code the backend already returns for oversized request bodies), with a nextAction pointing at --page-size / --since.
  • Decoding happens once, at the end, so multi-byte UTF-8 sequences that straddle a chunk boundary still decode correctly.

The cap is a new HttpClientOptions.maxResponseBytes (default 64 MiB — far above any legitimate metadata/history/steps response), injectable for tests and future env/flag wiring.

Scope

Focused on the unbounded typed-JSON read, which is the root cause described in #83. Pagination page-count bounding is handled separately (#248), so this does not touch pagination.ts. Error-response bodies (small) keep using the existing safeReadJson path. Happy to widen the scope if you'd prefer.

The default cap value and whether to make it env-configurable are easy to adjust to your preference.

Tests (src/lib/http.test.ts)

  • Rejects an over-cap response via Content-Length.
  • Rejects an over-cap chunked response that has no Content-Length.
  • Reads a within-cap response unchanged.
  • Updates the existing "timeout during body read" test to stall at the new read site (a body stream that errors) — same intent, still classified as RequestTimeoutError.

npm run typecheck, npm run lint, npm run format:check, and src/lib/http.test.ts all pass locally.

Summary by CodeRabbit

  • New Features

    • Added a response-size guard for successful JSON bodies (default limit: 64 MiB, configurable via maxResponseBytes).
    • Requests that exceed the cap are now rejected with a dedicated payload-too-large (PAYLOAD_TOO_LARGE) error including request details.
  • Bug Fixes

    • Improved detection of oversized streamed/chunked responses while downloading (not just by declared size).
    • Correctly classifies timeouts that occur during response body reading.
    • Preserves existing behavior for interrupted requests and malformed successful responses.

response.json() buffers the entire body before parsing with no upper bound, so a large test result --history page or a run with a long steps[] array grows the heap in proportion to the payload. Read the typed JSON response bounded by a configurable cap (HttpClientOptions.maxResponseBytes, default 64 MiB): reject an over-cap Content-Length up front, count bytes while streaming chunked bodies, and throw a typed PAYLOAD_TOO_LARGE (exit 5) with guidance to narrow --page-size / --since.
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

✅ This PR is linked to an issue assigned to @Yazan-O — thanks! The needs-issue label has been removed.

@github-actions github-actions Bot added the needs-issue PR not linked to an issue yet — please open one first and claim it (see CONTRIBUTING) label Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: be6e834e-082b-40cb-98ed-5b61fba5b42a

📥 Commits

Reviewing files that changed from the base of the PR and between 2d3fe69 and 75a87fc.

📒 Files selected for processing (1)
  • src/lib/http.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/http.ts

Walkthrough

HttpClient now bounds successful JSON response buffering with a configurable 64 MiB default, reports oversized payloads as typed errors, and preserves existing parsing and timeout classification. Tests cover declared and streamed limits, valid responses, and body-read timeouts.

Changes

Bounded HTTP response handling

Layer / File(s) Summary
Response limit configuration and error contract
src/lib/http.ts
HttpClientOptions accepts maxResponseBytes; HttpClient stores the configured value or a 64 MiB default.
Bounded JSON response reading
src/lib/http.ts
Successful response bodies use bounded streaming reads before JSON.parse; oversized responses raise PAYLOAD_TOO_LARGE while typed errors, interrupts, and timeout classification remain handled.
Response limit and body-timeout tests
src/lib/http.test.ts
Tests cover Content-Length, streamed responses, successful bounded parsing, and timeout errors during body consumption.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HttpClient
  participant Response
  participant readBoundedText
  participant JSON.parse
  HttpClient->>Response: receive successful response
  HttpClient->>readBoundedText: read with maxResponseBytes
  readBoundedText->>Response: stream and count body bytes
  readBoundedText-->>HttpClient: bounded text or PAYLOAD_TOO_LARGE
  HttpClient->>JSON.parse: parse bounded text
  JSON.parse-->>HttpClient: parsed JSON or malformed response error
Loading

Possibly related PRs

Suggested labels: needs-issue

Suggested reviewers: zeshi-du, ruili-testsprite

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: bounding buffered JSON response size in the HTTP client.
Linked Issues check ✅ Passed The shared HTTP client now enforces maxResponseBytes for JSON reads, preventing unbounded buffering as requested in #83.
Out of Scope Changes check ✅ Passed The diff stays on response-size limiting and related tests; no unrelated features or refactors are apparent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot removed the needs-issue PR not linked to an issue yet — please open one first and claim it (see CONTRIBUTING) label Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/lib/http.ts (2)

971-979: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Null-body fallback fully buffers before enforcing the cap.

When response.body is null, readBoundedText calls response.text() (fully materializing the body) and only checks its encoded length afterward — this defeats the memory-bounding goal this PR sets out to achieve for exactly the pathological/hostile-payload case it targets. It's only reachable when Content-Length is absent AND no stream is exposed, but real fetch implementations rarely hit this combination for non-empty bodies, so it's a residual gap rather than the common path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/http.ts` around lines 971 - 979, Update readBoundedText’s stream ===
null fallback to avoid calling response.text() before enforcing maxBytes; use an
incremental or otherwise bounded read mechanism that rejects once the encoded
payload exceeds maxBytes while preserving the existing
responseTooLargeError(requestId, maxBytes) behavior and returning the text for
bodies within the cap.

989-992: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Unguarded reader.cancel() can mask the PAYLOAD_TOO_LARGE error.

If reader.cancel() rejects, the throw responseTooLargeError(...) on the next line never executes; the cancel rejection propagates instead, falls through the err instanceof ApiError check in requestWithMeta, and gets misclassified via malformedResponseError — silently breaking the exit-code-5 contract for this (admittedly rare) case.

🛡️ Defensive fix
       if (total > maxBytes) {
-        await reader.cancel();
+        await reader.cancel().catch(() => {});
         throw responseTooLargeError(requestId, maxBytes, total);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/http.ts` around lines 989 - 992, Guard the await reader.cancel() call
in the response-size handling branch so any cancellation rejection is contained,
then always throw responseTooLargeError(requestId, maxBytes, total). Preserve
the existing oversized-payload behavior and ensure cancellation failures cannot
replace the intended ApiError.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/lib/http.ts`:
- Around line 971-979: Update readBoundedText’s stream === null fallback to
avoid calling response.text() before enforcing maxBytes; use an incremental or
otherwise bounded read mechanism that rejects once the encoded payload exceeds
maxBytes while preserving the existing responseTooLargeError(requestId,
maxBytes) behavior and returning the text for bodies within the cap.
- Around line 989-992: Guard the await reader.cancel() call in the response-size
handling branch so any cancellation rejection is contained, then always throw
responseTooLargeError(requestId, maxBytes, total). Preserve the existing
oversized-payload behavior and ensure cancellation failures cannot replace the
intended ApiError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f68da18-a6af-471f-9751-9298a900ac54

📥 Commits

Reviewing files that changed from the base of the PR and between fe07bc9 and 2d3fe69.

📒 Files selected for processing (2)
  • src/lib/http.test.ts
  • src/lib/http.ts

Resolves the two conflicts in src/lib/http.ts:

- Constants block: keep both MAX_RESPONSE_BYTES_DEFAULT (this branch) and
  MAX_SCHEMA_ISSUES_IN_DETAILS (upstream TestSprite#264/TestSprite#273 line).
- Success-path body read: keep the bounded read, but assign the parsed
  value to `raw` instead of returning early, so upstream's valibot
  `v.safeParse(options.schema, raw)` still runs. Returning here would have
  made the new schema validation dead code on every request.

Verified: tsc --noEmit clean, eslint clean, vitest 58 files /
2030 passed / 3 skipped.
Copilot AI review requested due to automatic review settings July 26, 2026 12:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a bounded read path for successful JSON responses in HttpClient.requestWithMeta() to prevent unbounded heap growth when large responses (history/steps) are returned, addressing issue #83.

Changes:

  • Introduces HttpClientOptions.maxResponseBytes (default 64 MiB) and enforces it for successful JSON responses.
  • Implements a streaming, byte-counted read (readBoundedText) that rejects oversize responses early (via Content-Length) or mid-stream (chunked).
  • Adds/updates HTTP client tests to cover oversize responses and timeouts during body reads.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/lib/http.ts Replaces response.json() on the success path with a bounded read + JSON.parse, adds new size-cap option and helpers.
src/lib/http.test.ts Adds tests for response-size capping and adjusts timeout-during-body-read coverage for the new read path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/http.ts
Comment on lines +1062 to +1065
if (total > maxBytes) {
await reader.cancel();
throw responseTooLargeError(requestId, maxBytes, total);
}
Comment thread src/lib/http.ts
Comment on lines +1101 to +1106
const limitMiB = Math.round(maxBytes / (1024 * 1024));
return new ApiError({
code: 'PAYLOAD_TOO_LARGE',
message:
`The server response exceeded the client-side ${limitMiB} MiB limit and was not ` +
`buffered, to avoid unbounded memory use.`,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Hackathon] Bound Response Buffering (Large Histories/Steps Load Fully into Heap)

2 participants