Skip to content

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

Open
icebob wants to merge 4 commits 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 4 commits into
moleculer-java:masterfrom
icebob:fix/keepalive-empty-response-204

Conversation

@icebob

@icebob icebob commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

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

berkesa and others added 4 commits April 11, 2026 01:05
OSSRH (oss.sonatype.org) was sunset on June 30, 2025. This commit
modernizes the Gradle build to publish via the new Maven Central Portal
(central.sonatype.com).

Changes:
- Replace legacy 'maven' and 'com.bmuschko.nexus' plugins with
  'maven-publish', 'signing', and 'tech.yanand.maven-central-publish'
- Upgrade Gradle wrapper from 4.2 to 7.6.4
- Migrate POM metadata from 'modifyPom' block to 'pom { }' DSL
- Remove jcenter() and oss.sonatype.org repositories; keep mavenCentral() only
- Add javadoc and sources JARs (required by Maven Central)
- Preserve the original 4 explicit runtime dependencies in the published POM
- Credentials read from Gradle properties (sonatypeUsername/sonatypePassword);
  never hardcoded
- Ignore gradle.properties and .claude/ in git to prevent accidental
  secret exposure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clamp error response status codes to the valid HTTP range (100-599).
If MoleculerError returns an out-of-range code, fall back to 500
to prevent invalid HTTP responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove nonexistent tech.yanand.maven-central-publish plugin
- Add datatree-core and datatree-promise as compileOnly deps (Gradle 7.x
  correctly excludes runtime-scoped transitive deps from compile classpath)
- Add sourcesJar duplicate strategy for favicon.ico conflict
- Replace ECJ fork hack (incompatible with Gradle 7.x) with standard javac
- Add staging repository and publishToMavenCentralPortal task that reads
  credentials from .gradle/secrets.xml and uploads via Central Portal REST API
…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.

2 participants