Skip to content

Fix: Discard an unconsumed request body when the response completes - #4272

Open
wwbakker wants to merge 3 commits into
zio:mainfrom
wwbakker:sbs1673-drain-or-close
Open

Fix: Discard an unconsumed request body when the response completes#4272
wwbakker wants to merge 3 commits into
zio:mainfrom
wwbakker:sbs1673-drain-or-close

Conversation

@wwbakker

Copy link
Copy Markdown

Issue:
With request streaming, reading is driven by the consumer of the body: the channel is put in autoRead = false and the next read is only issued when the next chunk is asked for. A handler that answers without consuming the body to its end (i.e. one that rejects a request before decoding it, an unmatched route, a handler that ignores the body, or a reader that gives up half way) therefore leaves the connection open but never read from again, and the next request the client sends over it is silently dropped.

Possible solutions
HTTP/1.1 gives a server no way to ask a client to stop sending, so the two options once a response has been written early are:

  • read the rest and discard it, or
  • close the connection.

Current PR
The changes in this PR do the former, up to maxDiscardedRequestBodySize, and the latter beyond it.

I personally think this is a nice solution, since it keeps the connection alive for smaller requests, but stops endlessly listening for larger ones, but it's definitely something you could have an different opinion about (and different use-cases might want different settings).
Also, the default size for maxDiscardedRequestBodySize is quite arbitrary. Someone with an opinion should make a judgement on that as well.

Context
It took a while to find the root cause of this issue when we ran into this issue in our own application, since the symptoms were flaky tests in our CI, which are obviously hard to diagnose. After having a reasonable idea where the issue could lie, I found the issue was already posted by someone else here:

#4193

I've used LLM tools with the diagnosing and the fix, but also had to use my own brain, since it alone wouldn't have found/fixed it.
I've also tested this fix in our own API and it did seem to fix the issue.

With request streaming, reading is driven by the consumer of the body: the
channel is put in autoRead = false and the next read is only issued when the
next chunk is asked for. A handler that answers without consuming the body to
its end - one that rejects a request before decoding it, an unmatched route, a
handler that ignores the body, or a reader that gives up half way - therefore
leaves the connection open but never read from again, and the next request the
client sends over it is silently dropped.

HTTP/1.1 gives a server no way to ask a client to stop sending, so the two
options once a response has been written early are to read the rest and discard
it, or to close the connection. This does the former, up to
maxDiscardedRequestBodySize, and the latter beyond it.
Copilot AI lite review requested due to automatic review settings August 25, 2026 14:12
@netlify

netlify Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploy Preview for zio-http ready!

Name Link
🔨 Latest commit be7aac6
🔍 Latest deploy log https://app.netlify.com/projects/zio-http/deploys/6a8db1c3b43fc200084be69a
😎 Deploy Preview https://deploy-preview-4272--zio-http.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@CLAassistant

CLAassistant commented Aug 25, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 addresses an HTTP/1.1 keep-alive correctness issue when RequestStreaming.Enabled is used and a handler completes a response without fully consuming the request body, which can leave the connection in a non-reading state and cause the next request on the same connection to be silently dropped.

Changes:

  • Introduces Server.Config.maxDiscardedRequestBodySize to bound how much unconsumed request body the server will drain-and-discard before instead closing the connection.
  • Adds logic in the Netty server inbound path to trigger draining/discarding of any remaining request body once a response is produced.
  • Adds a JVM regression spec that reproduces and verifies connection reusability (and close-on-overbudget behavior) using a raw socket split of headers/body.

Reviewed changes

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

File Description
zio-http/shared/src/main/scala/zio/http/Server.scala Adds maxDiscardedRequestBodySize to server config, docs, and config loading.
zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala Captures the active AsyncBodyReader and triggers discard/drain after response handling to keep connections reusable.
zio-http/jvm/src/main/scala/zio/http/netty/AsyncBodyReader.scala Adds a Discarding state and a method to drain-and-discard remaining inbound body (closing when budget exceeded).
zio-http/jvm/src/test/scala/zio/http/netty/server/ServerUnconsumedRequestBodySpec.scala Adds regression coverage for unconsumed/partially-consumed bodies and over-budget close behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +99 to +103
// Captured here, on the event loop, while this request's reader is the one in the pipeline. Looking it up when the
// response completes would be a race: on a keep-alive connection the next request may have installed its own reader
// by then, and discarding its body would break a request that did nothing wrong.
val bodyReader = unfinishedBodyReader(ctx)
val done = () => { releaseRequest(); discardRemainingRequestBody(bodyReader) }

@wwbakker wwbakker Aug 25, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not sure what copilot means with this:
discardMainBody does not call ctx.close() as far as I can see.

And if I understand the code correctly (which granted, I might not), the discard/close logic is effectively chained to the 'future'/zio result by being used inside the attemptImmediateWrite clause, and/or being in the ensured parameter in the writeResponse.

Comment on lines +153 to +157
case State.Discarding =>
// The consumer is gone; read on until the body ends so that the connection stays usable,
// and give up on the connection if the client turns out to be sending more than we are
// willing to throw away.
discardableBytes -= content.length

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated content to be materialized only in the other branches.

Comment on lines +149 to +156
test("a body too large to discard closes the connection instead of leaving it unreadable") {
for {
result <- scenario(
_ => ZIO.unit,
Server.Config.default.maxDiscardedRequestBodySize(bodySize.toLong / 2),
).either
} yield assertTrue(result.isLeft)
},

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Made the assertions more specific.

… tighten the over-budget test

- channelRead0 materialised every chunk's bytes before the state match; a discarded
  chunk only needs its length, so the copy is now made lazily per branch.
- Flush before closing when the discard budget is exceeded, so that a response still
  sitting in the outbound buffer is not dropped with the connection.
- The over-budget test asserted only that the scenario failed, which would also pass
  if the response had never arrived. It now asserts that the rejection is delivered
  and that only the attempt to reuse the connection fails.
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.

3 participants