Skip to content

Keep connection alive for body-less responses (204) to avoid ECONNRESET on keep-alive clients#10

Open
icebob wants to merge 1 commit into
moleculer-java:masterfrom
icebob:fix/keepalive-empty-response-204
Open

Keep connection alive for body-less responses (204) to avoid ECONNRESET on keep-alive clients#10
icebob wants to merge 1 commit into
moleculer-java:masterfrom
icebob:fix/keepalive-empty-response-204

Conversation

@icebob
Copy link
Copy Markdown
Collaborator

@icebob icebob commented Jun 3, 2026

Problem

NettyWebResponse.end() closes the TCP connection whenever the response has no Content-Length header — which is the case for body-less responses such as 204 No Content (or any action returning null):

boolean close = headers.get(CONTENT_LENGTH) == null;   // body-less => close
...
if (close) {
    ctx.flush();
    ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
}

The close is done without sending a Connection: close header (sendHeaders() never emits one). So from the client's point of view it is an ordinary HTTP/1.1 keep-alive response. Clients that use a keep-alive connection pool (browsers, Node's http.Agent/axios, Java HttpClient, …) return the socket to the pool and reuse it for the next request, which then fails with ECONNRESET / "socket hang up".

curl is unaffected because it transparently detects the closed socket and opens a new connection; pooled clients do not. Because the close is an async ChannelFutureListener.CLOSE, whether the client notices the FIN before reusing the socket is a race — so this can appear JVM/timing/load dependent even with an unchanged library version.

Reproduction

Any endpoint returning 204 (or null from the action), called by a keep-alive client, followed by another request on the same pooled connection:

const http = require("http");
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
const req = (m, p) => new Promise(r => {
  const q = http.request({ host: "localhost", port: 3000, method: m, path: p, agent },
    s => { s.resume(); s.on("end", () => r("HTTP " + s.statusCode)); });
  q.on("error", e => r("ERROR " + e.code)); q.end();
});
(async () => {
  console.log("1)", await req("DELETE", "/endpoint-returning-204"));
  console.log("2)", await req("GET", "/anything"));   // => ERROR ECONNRESET
})();

With keepAlive: false (or curl) both calls succeed.

Fix

Emit an explicit Content-Length: 0 for body-less responses before sendHeaders() runs. The existing logic then sees a Content-Length, keeps the connection alive, and the message stays correctly framed. send() only flips first when it writes bytes.length > 0, so first.get() == true at the top of end() reliably means "no body was written"; multipart and bodied responses are left untouched.

if (first.get() && (req == null || req.parser == null)
        && (headers == null || headers.get(CONTENT_LENGTH) == null)) {
    setHeader(CONTENT_LENGTH, "0");
}

./gradlew compileJava passes. The same logic, applied as an application-level HttpMiddleware that wraps the WebResponse, has been verified to resolve the ECONNRESET failures against a running server (204 → reused socket now returns 200 instead of resetting).

Note (optional follow-up)

For genuinely close-delimited responses (a body written with no Content-Length), the server still closes the socket without advertising it; adding a Connection: close header in that branch would make those fully HTTP/1.1-correct too. This PR only addresses the common body-less (204/empty) case.

🤖 Generated with Claude Code

…g it

NettyWebResponse.end() closed the channel whenever no Content-Length header was
present. This is the case for body-less responses such as "204 No Content" (or
an action returning null). The close was performed without a "Connection: close"
header, so HTTP clients that use keep-alive connection pools (browsers, Node's
http.Agent / axios, Java HttpClient, ...) returned the socket to the pool and
hit "ECONNRESET" / "socket hang up" on the next request that reused it.

For a body-less response the message length is well-defined (zero), so emit an
explicit "Content-Length: 0" before the headers are flushed. The existing logic
then sees a Content-Length, keeps the connection alive, and the response is
correctly framed. Responses that write a body are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant