Skip to content

feat(proxy): support client aborts#1417

Merged
pi0 merged 5 commits into
h3js:mainfrom
iainsproat:iain/proxyrequest-aborted-connections
Jul 2, 2026
Merged

feat(proxy): support client aborts#1417
pi0 merged 5 commits into
h3js:mainfrom
iainsproat:iain/proxyrequest-aborted-connections

Conversation

@iainsproat

@iainsproat iainsproat commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
  • client abort signals are propagated upstream
  • 502 response on abort remains the default to prevent breaking existing behaviour
  • new option propogateAbortError to throw client abort error instead of 502 response

Summary by CodeRabbit

  • New Features
    • Added a propagateAbortError option to control whether client disconnects surface as an AbortError or are handled quietly.
    • Proxying now forwards the client abort signal for upstream requests, and combines it with any provided fetch signal.
    • Client disconnects now result in HTTP 499 by default; enabling propagateAbortError surfaces the AbortError. Mid-stream disconnects no longer trigger gateway errors.
  • Tests
    • Added web-only coverage for default 499 behavior, propagateAbortError: true, and mid-stream abort outcomes.

- client abort signals are propogated upstream
- 502 response on abort remains the default to prevent breaking existing behaviour
- new option `propogateAbortError` to throw client abort error instead of 502 response
@iainsproat
iainsproat requested a review from pi0 as a code owner June 18, 2026 15:59
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

ProxyOptions adds propagateAbortError. proxy() now forwards and combines abort signals, returns 499 on client aborts by default, and can rethrow AbortError when propagation is enabled. Tests cover client abort, caller-signal abort, and streamed abort behavior.

Changes

Proxy Abort Handling

Layer / File(s) Summary
Signal forwarding and abort response
src/utils/proxy.ts
ProxyOptions adds propagateAbortError?: boolean; proxy() always forwards event.req.signal, combines it with a caller signal via AbortSignal.any(...), and changes abort handling to rethrow AbortError when enabled or return 499 Client Closed Request otherwise, while keeping non-abort failures as 502.
Web abort behavior coverage
test/proxy.test.ts
Web-only tests cover client signal visibility, default 499 abort handling, propagateAbortError with request and caller signals, forwarding with a non-aborted caller signal, and aborting upstream stream consumption.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: pi0, pi0x

Poem

🐇 I hopped through signals, quick and keen,
To catch the aborts in between.
I whisper 499 by default, you see,
Or shout AbortError when asked by decree.

🚥 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 matches the main change: proxy support for client abort handling and abort propagation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

Comment thread src/utils/proxy.ts Outdated
@pi0 pi0 changed the title feat: proxy supports client aborts feat(proxy): support client aborts Jul 2, 2026
Key the `propagateAbortError` guard off the caught error being an
`AbortError` rather than `event.req.signal.aborted`. This fixes two
cases:

- a caller-supplied `fetchOptions.signal` now propagates correctly
  (the client signal is never aborted, so the old check failed)
- a real upstream failure coinciding with an aborted signal is now
  wrapped as 502 instead of being rethrown raw

Add a regression test for the custom-signal case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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 (1)
test/proxy.test.ts (1)

265-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use it.skipIf per repo test convention instead of it.runIf.

Guidelines mandate it.skipIf(ctx.target === "node") for runtime-specific skips, not it.runIf(target === "web"). Functionally equivalent today, but diverges from the stated convention and would silently misbehave if a third describeMatrix target is ever added (an it.skipIf("node") naturally excludes only node, whereas it.runIf("web") locks the test to web-only, excluding any future target too).

♻️ Proposed fix
-      it.runIf(t.target === "web")(
+      it.skipIf(t.target === "node")(

As per coding guidelines, test/**/*.test.ts: "Use it.skipIf(ctx.target === "node") for runtime-specific test skips".

🤖 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 `@test/proxy.test.ts` around lines 265 - 290, The test in proxy.test.ts uses
it.runIf(t.target === "web"), which violates the repo’s runtime-skip convention.
Update this case to use it.skipIf with the test context target check, matching
the existing matrix style and keeping the proxyRequest abort-propagation test
named and scoped consistently with the other runtime-specific tests.

Source: Coding guidelines

🤖 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 `@test/proxy.test.ts`:
- Around line 265-290: The test in proxy.test.ts uses it.runIf(t.target ===
"web"), which violates the repo’s runtime-skip convention. Update this case to
use it.skipIf with the test context target check, matching the existing matrix
style and keeping the proxyRequest abort-propagation test named and scoped
consistently with the other runtime-specific tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3c50752c-fa3f-4ea9-81b1-a4ffde59ebc6

📥 Commits

Reviewing files that changed from the base of the PR and between 3f139d0 and d7b9f35.

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

A client disconnect is not a gateway failure, so by default a proxied
request aborted by the client's own signal now resolves to a quiet
`499 Client Closed Request` (never delivered, the client is gone)
instead of throwing a logged `502` for every dropped connection.

`propagateAbortError: true` still lets the `AbortError` propagate to the
handler, and abort detection keys off the error (so it also covers a
custom `fetchOptions.signal`). The signal is still forwarded to internal
sub-request targets as well.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@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.

♻️ Duplicate comments (1)
src/utils/proxy.ts (1)

112-116: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Custom fetchOptions.signal silently disables client-abort forwarding.

Because ...opts.fetchOptions is spread after signal: event.req.signal, any caller-supplied fetchOptions.signal completely replaces the forwarded client signal. That contradicts the new doc comment's claim that the incoming signal is "always forwarded" (Lines 25-27) — in practice, once a custom signal is set, client disconnects no longer abort the upstream request at all, defeating the feature this PR introduces.

Consider merging the two signals with AbortSignal.any() so the client abort is always honored regardless of a custom signal:

♻️ Proposed fix using AbortSignal.any
   const fetchOptions: RequestInit = {
-    signal: event.req.signal,
     headers: opts.headers as HeadersInit,
     ...opts.fetchOptions,
+    signal: opts.fetchOptions?.signal
+      ? AbortSignal.any([event.req.signal, opts.fetchOptions.signal])
+      : event.req.signal,
   };

Also worth relaying: this is also related to the still-open feedback from iainsproat on the previous commit asking whether forwarding should be gated behind an option — the forwarding remains unconditional in this version.

🤖 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/utils/proxy.ts` around lines 112 - 116, The proxy request setup in
`src/utils/proxy.ts` currently lets `opts.fetchOptions.signal` overwrite
`event.req.signal`, so client-abort forwarding is lost whenever a custom signal
is provided. Update the `fetchOptions` assembly in the proxy helper to preserve
the incoming request signal and combine it with any caller-supplied signal,
ideally in the same place where `fetchOptions` is built. Use the
`event.req.signal` and `opts.fetchOptions.signal` values together so upstream
requests still abort on client disconnect even when custom fetch options are
passed, and make sure the behavior matches the doc comment’s “always forwarded”
guarantee or add a gate if that’s the intended contract.
🤖 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.

Duplicate comments:
In `@src/utils/proxy.ts`:
- Around line 112-116: The proxy request setup in `src/utils/proxy.ts` currently
lets `opts.fetchOptions.signal` overwrite `event.req.signal`, so client-abort
forwarding is lost whenever a custom signal is provided. Update the
`fetchOptions` assembly in the proxy helper to preserve the incoming request
signal and combine it with any caller-supplied signal, ideally in the same place
where `fetchOptions` is built. Use the `event.req.signal` and
`opts.fetchOptions.signal` values together so upstream requests still abort on
client disconnect even when custom fetch options are passed, and make sure the
behavior matches the doc comment’s “always forwarded” guarantee or add a gate if
that’s the intended contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 30853529-3cd3-4c20-9ed0-f76f6aedf9e4

📥 Commits

Reviewing files that changed from the base of the PR and between d7b9f35 and ae6ba9e.

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

…mid-stream aborts

- Merge event.req.signal with a caller-supplied fetchOptions.signal instead of
  letting the object spread drop it, so client disconnects always abort upstream.
- Wrap the proxied response body so a client-caused abort during streaming ends
  the stream quietly (499 default) instead of surfacing an unhandled stream error.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@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 (1)
src/utils/proxy.ts (1)

188-231: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Response body is wrapped on the default hot path for every proxied response.

Since propagateAbortError defaults to false, quietlyAbortableStream wraps the upstream body for all successful responses, not just aborted ones. Each chunk is now pumped through a JS pull loop (reader.read()enqueue) instead of handing the native stream straight to HTTPResponse, adding per-chunk overhead and losing byte-stream (type: "bytes"/BYOB) semantics. For a proxy this is a throughput-sensitive path — worth measuring, or scoping the wrap to only kick in on abort.

🤖 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/utils/proxy.ts` around lines 188 - 231, The default proxy response path
in `src/utils/proxy.ts` always wraps successful upstream bodies with
`quietlyAbortableStream`, which adds per-chunk JS pumping overhead and drops
native byte-stream/BYOB behavior even when no abort occurs. Update the
`HTTPResponse` body selection logic so `quietlyAbortableStream` is only used
when abort handling is actually needed, or otherwise preserve the original
`response.body` on the hot path while keeping the abort-suppression behavior in
`quietlyAbortableStream` for client disconnects.
🤖 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/utils/proxy.ts`:
- Around line 188-231: The default proxy response path in `src/utils/proxy.ts`
always wraps successful upstream bodies with `quietlyAbortableStream`, which
adds per-chunk JS pumping overhead and drops native byte-stream/BYOB behavior
even when no abort occurs. Update the `HTTPResponse` body selection logic so
`quietlyAbortableStream` is only used when abort handling is actually needed, or
otherwise preserve the original `response.body` on the hot path while keeping
the abort-suppression behavior in `quietlyAbortableStream` for client
disconnects.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f72496b-8802-406d-a2cf-e6c6d5202425

📥 Commits

Reviewing files that changed from the base of the PR and between ae6ba9e and 079c8af.

📒 Files selected for processing (2)
  • src/utils/proxy.ts
  • test/proxy.test.ts

Pass the upstream body straight to HTTPResponse instead of wrapping every
proxied response in a per-chunk read/enqueue loop. A mid-stream client
abort errors the upstream body, which is delivered to the stream consumer
(the server runtime, already tearing down the closed connection) — so it
needs no special handling and never becomes a 502. Removes per-chunk
overhead from all proxied traffic.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0
pi0 merged commit ac0d6fe into h3js:main Jul 2, 2026
6 of 7 checks passed
@pi0

pi0 commented Jul 2, 2026

Copy link
Copy Markdown
Member

Thanks!

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/proxy.test.ts (1)

316-351: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing Node-target coverage for the "no special handling" streaming-abort assumption.

This test — and all the other new abort tests in this block — only run it.runIf(t.target === "web"). Given the streaming change in src/utils/proxy.ts (removing quietlyAbortableStream) explicitly relies on "the runtime" absorbing the errored body stream, and Node's stream error handling differs materially from the browser/web-stream model, this is exactly the scenario that needs a Node-target equivalent to catch a potential crash regression. As per coding guidelines, test/**/*.test.ts should "Test across runtimes using describeMatrix to ensure compatibility."

🤖 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 `@test/proxy.test.ts` around lines 316 - 351, The new abort-stream coverage in
proxy.test.ts is web-only, but the proxyRequest/quietlyAbortableStream change in
src/utils/proxy.ts needs equivalent Node-runtime validation too. Add a
Node-target test case for the same mid-stream AbortError scenario alongside the
existing it.runIf(t.target === "web") block, using describeMatrix or an
equivalent target split so the behavior is asserted in both runtimes. Keep the
assertions aligned with the existing proxyRequest and t.fetch flow: upstream
response should still come through as 200, and the stream error should be
handled by the consumer rather than becoming a gateway error or crash.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@test/proxy.test.ts`:
- Around line 316-351: The new abort-stream coverage in proxy.test.ts is
web-only, but the proxyRequest/quietlyAbortableStream change in
src/utils/proxy.ts needs equivalent Node-runtime validation too. Add a
Node-target test case for the same mid-stream AbortError scenario alongside the
existing it.runIf(t.target === "web") block, using describeMatrix or an
equivalent target split so the behavior is asserted in both runtimes. Keep the
assertions aligned with the existing proxyRequest and t.fetch flow: upstream
response should still come through as 200, and the stream error should be
handled by the consumer rather than becoming a gateway error or crash.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f82808f9-74c6-4634-ae1a-239c28f6feaa

📥 Commits

Reviewing files that changed from the base of the PR and between 079c8af and 9d283e5.

📒 Files selected for processing (2)
  • src/utils/proxy.ts
  • test/proxy.test.ts

@coderabbitai coderabbitai Bot mentioned this pull request Jul 11, 2026
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.

2 participants