Skip to content

fix: keep or announce the HTTP/1.1 connection when the request body is unread - #1625

Merged
jlouvel merged 1 commit into
3.0from
fix/netty-server-unread-body-keep-alive
Nov 1, 2026
Merged

jlouvel merged 1 commit into
3.0from
fix/netty-server-unread-body-keep-alive

Conversation

@jlouvel

@jlouvel jlouvel commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Cause

NettyServerCall.complete() decided keep-alive only at the end of an HTTP/1.1 response:

boolean keepAlive = HttpUtil.isKeepAlive(request) && requestBody.isFullyConsumed();

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:

It is not the cause of the SecurityTestCase 1001 failures (#1601): each of its tests sends a single request with a new client, so no connection is reused. There, the client is stopped by ClientResource.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, and complete() follows what the head announced.
    • Body already received (the common small-body case, and requests without a body): the connection is kept, and the unread buffered body is released.
    • Body still in transit, declared with 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.
    • Otherwise (a chunked body still in transit, or more than 256 KiB left): the head announces Connection: close, and the connection is closed after the response, as before.
    • A Connection: close header set by the application is now honored too, since the completion reads the head.
  • ChannelInputStream: isFullyReceived(), getReceivedBytes() and discard() (a close whose later offers are released while channel reads go on). The unused isFullyConsumed() 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: close whenever 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 get Connection: 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:

  • a received 5-byte body: the 403 doesn't announce Connection: close, and a second request on the same socket gets its 200;
  • a Content-Length: 10 body 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;
  • a body larger than the bound, and a chunked body, still in transit: the 403 announces Connection: close and the server closes the socket;
  • a fully sent chunked body: either kept (second request succeeds) or announced as closed, depending on whether the last chunk arrived before the head;
  • a streaming echo with 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 two Connection: close cases (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-based ShutdownHookTestCase and ServerMaxConnectionsTestCase (GET requests only), which pass alone or in the rerun.
  • GraphqlHttpErrorsTestCase run 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

@jlouvel jlouvel self-assigned this Nov 1, 2026
…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
jlouvel force-pushed the fix/netty-server-unread-body-keep-alive branch from 54ce43f to 8b270ad Compare November 1, 2026 21:29
@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

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.

🟢 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

@jlouvel
jlouvel merged commit 8b78973 into 3.0 Nov 1, 2026
4 of 6 checks passed
@jlouvel
jlouvel deleted the fix/netty-server-unread-body-keep-alive branch November 1, 2026 23:58
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