Repository navigation
fix: keep or announce the HTTP/1.1 connection when the request body is unread - #1625
Merged
Merged
Conversation
…s unread The Netty server decided keep-alive only after the response, from requestBody.isFullyConsumed(). A handler answering without reading the request body (an early 401, 403 or 406) got its connection closed after a response head that didn't announce it, so a client reusing the pooled connection failed with a 1001 "Communication Error". The decision is now taken when the response head is written: - a request body already received, or whose declared length leaves at most 256 KiB in transit, is discarded as it arrives and the connection is kept; - otherwise (chunked or larger body still in transit) the head announces "Connection: close". The completion follows what the head announced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jlouvel
force-pushed
the
fix/netty-server-unread-body-keep-alive
branch
from
November 1, 2026 21:29
54ce43f to
8b270ad
Compare
|
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues remain for the HTTP/1.1 unread-request-body connection-reuse fix.
No open issues 🎉
What changed in this PR
Fixes HTTP/1.1 connection reuse after handlers respond without reading the request body, preventing unannounced closures that can cause client communication errors. HTTP/2 and HTTP/3 behavior remains unchanged.
Changes:
- Decides connection persistence before sending response headers, then honors that decision at completion.
- Discards fully received or bounded unread bodies; otherwise announces
Connection: close. - Adds regression coverage for connection reuse, closure, streaming echoes, and buffer release, plus a changelog entry.
| File | Description |
|---|---|
org.restlet/src/test/java/org/restlet/engine/netty/Http1UnreadBodyKeepAliveTestCase.java |
Tests unread-body handling, connection reuse, and stream discard behavior. |
org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java |
Aligns connection completion with the persistence announced in response headers. |
org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java |
Tracks received bytes and supports discarding content without stopping channel reads. |
changes.md |
Documents the HTTP/1.1 connection-reuse fix. |
Review effort: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Cause
NettyServerCall.complete()decided keep-alive only at the end of an HTTP/1.1 response:By then the response head was already sent, without
Connection: close. When a handler answers without reading the request body (an early 401 or 403 from authentication, a 406, a test endpoint),isFullyConsumed()is false even when the whole body is already buffered, so the server closed the connection after the response with no warning. The Netty client kept the connection in its pool, reused it for the next request, and got a 1001 "Communication Error" before any response head.This is the cause of intermittent CI failures:
GraphqlHttpErrorsTestCase.nonGraphqlResponsesAreTransportErrors: anullstatus instead of 401 or 200;It is not the cause of the
SecurityTestCase1001 failures (#1601): each of its tests sends a single request with a new client, so no connection is reused. There, the client is stopped byClientResource.release()before the end of the 403 response, and the late failure replaces its status. #1608 fixes that.Changes
NettyServerCall: the keep-alive decision is now taken when the HTTP/1.1 response head is written, andcomplete()follows what the head announced.Content-Length, at most 256 KiB left (MAX_DISCARDED_BYTES, the stream's high-water mark): the connection is kept, and the rest of the body is discarded as it arrives. The reads go on asynchronously on the event loop, which never waits for the data, and the bound comes from the declared length, so the drain can't grow. A stalled client is still closed by the idle timeout.Connection: close, and the connection is closed after the response, as before.Connection: closeheader set by the application is now honored too, since the completion reads the head.ChannelInputStream:isFullyReceived(),getReceivedBytes()anddiscard()(a close whose later offers are released while channel reads go on). The unusedisFullyConsumed()is removed.changes.md: entry under Bugs fixed.HTTP/2 and HTTP/3 streams are unchanged.
Duplex handlers
A handler that streams the request body back after sending the head (a streaming echo) can't be told apart from an early rejection when the head is written. Two choices were possible: announce
Connection: closewhenever the body isn't fully received, which costs every duplex exchange its connection, or keep announcing keep-alive and close silently when the body ends up unread, which is the bug. This PR takes a third way: when the body still in transit is bounded, the connection stays alive whether the handler consumes the body or not, because the server can discard the rest itself. Only chunked or large bodies still in transit when the head is written getConnection: close, including a duplex handler that would have read them. That's a lost connection reuse, never a broken one, which I think is the safer trade-off.Tests
Http1UnreadBodyKeepAliveTestCase(new), with raw sockets against a Restlet that answers 403 without reading the body:Connection: close, and a second request on the same socket gets its 200;Content-Length: 10body with only 5 bytes sent before the 403: the rest of the body and the next request follow on the same socket, which gets its 200;Connection: closeand the server closes the socket;Content-Length: 10, the second half sent after the handler ran: the echo succeeds, isn't announced as closed, and the connection is reused;ChannelInputStream.discard(): later content is released without pausing reads, while a closed stream still stops reading.Without the fix (original
NettyServerCall), 5 of the 7 tests fail: the two "keeps the connection" cases with the CI symptom (An established connection was aborted), the chunked one, and the twoConnection: closecases (expected: <close> but was: <null>). The echo and the stream unit tests pass both ways.Validation
./mvnw -B -q verify -P default,test -pl org.restlet: passes, with the coverage check and SpotBugs (still 8 findings). A first run hit the timing-basedShutdownHookTestCaseandServerMaxConnectionsTestCase(GET requests only), which pass alone or in the rerun.GraphqlHttpErrorsTestCaserun 20 times on top of chore: log that HTTP/3 is not used through proxies #1580: 20 passes, against 9 of 10 without this fix.🤖 Generated with Claude Code