Skip to content

connect-node: RST_STREAM(NO_ERROR) mid-response yields misleading incomplete envelope / missing status instead of a transport error #1751

Open

Description

@rwieruch

Describe the bug

When a proxy closes a gRPC stream with RST_STREAM code=0 (NO_ERROR) while the response tail (final DATA and/or trailers) has not yet reached the client — e.g., Envoy resetting right after the upstream completes, while downstream bytes are still flow-control-blocked (envoyproxy/envoy#30149, envoyproxy/envoy#38206; grpc/grpc-node#2569) — Node's http2 client (pre nodejs/node#63249) surfaces this as a clean, error-free end-of-stream with rstCode 0. connect-node's h2Request() close handler correctly maps NO_ERROR to "no error" (connectErrorFromH2ResetCode), so the truncation goes unnoticed and the protocol layer then fails with:

ConnectError: [invalid_argument] protocol error: incomplete envelope   // cut mid-message
ConnectError: [internal] protocol error: missing status               // cut before trailers

Neither error names the stream reset, both point at the server's wire format, and invalid_argument reads as non-retryable — a transport-level abort is misreported as a server protocol violation. RST_STREAM(NO_ERROR) after a complete response is explicitly legal (RFC 9113 §8.1 — which also states "Clients MUST NOT discard responses as a result of receiving such a RST_STREAM"), so any connect-node client behind Envoy/Istio can hit this under page-load-style concurrency.

Note: when the complete response including trailers does reach the client before the RST, Node 24 preserves buffered data and the call succeeds — verified experimentally. The failing case is specifically the reset overtaking the response tail.

Environment

  • @connectrpc/connect-node 2.1.2, @connectrpc/connect 2.1.2 (current latest); createGrpcTransport, unary calls
  • Node.js 24.11.1, macOS (darwin/arm64)
  • Default session options (no nodeOptions, default 65535-byte windows), one shared connection per process, up to ~12 concurrent streams in our traces; interceptors present (auth header, tracing, logging — none consume or transform bodies); no retry layer
  • Server path: Istio/Envoy ingress → Go (grpc-go) services

Evidence

Observed in a production-like environment and bracketed from both ends:

  • Server side (Envoy edge debug logs, per stream): the upstream completed the response; Envoy's codec accepted headers, data, and trailers and then reset the stream with code=0 (doEndStream() path) while request-body bytes were still flow-control-outstanding (65535-byte stream window).
  • Client side (instrumented Connect interceptor, 799 calls): the same streams failed with the two errors above — 34 of 95 calls (~36%) for the largest/slowest unary response (median 654 ms), 13 of them mid-message; near zero for small trailers-only responses; sequential traffic never fails.
  • Control: grpc-go, grpcurl (1000/1000 at 100-way parallelism), and curl ran the identical wire pattern against the same gateway with zero failures (grpc-go's large/BDP-tuned windows leave no undelivered tail for the reset to overtake).
  • Proposed fix trialed as a local patch: running the trailers-tracking fix (the companion PR) as a temporary pnpm patch in the same environment, the identical live failures surfaced as [unavailable] http/2 stream closed with error code NO_ERROR (0x0) before trailers were received — client-side confirmation that the failing streams close with rstCode 0 without trailers ever arriving. In our experiments the graceful END_STREAM-without-trailers shape deterministically still yields missing status, so this signature specifically indicates the reset shape.
  • We have not yet captured client-side frames for a failing stream (planned: NODE_DEBUG=http2/pcap); attribution of the drop to the proxy's downstream leg follows from the verified Node behavior above plus the Envoy issue family cited.

Reproduction (verified on Node 24.11.1)

Node http2 server; client keeps the request open (request body > 64 KiB that the server never reads, so flow control blocks it). The large unread body is the repro's mechanism for holding the stream in the reset-prone state; our Envoy logs show the same precondition (request bytes flow-control-outstanding at reset time). Server: write DATA, then stream.close(NGHTTP2_NO_ERROR) either mid-DATA or after all DATA but before trailers. (When reproducing with a Node http2 server and a fully-sent request instead, respond() must pass { waitForTrailers: true } — otherwise Node closes gracefully with END_STREAM and no RST reaches the wire; the companion PR's spec tests use this to reproduce through the transport.) The client observes a clean end with rstCode 0 and no error event; through createGrpcTransport this yields exactly incomplete envelope / missing status. Sending the RST after sendTrailers() does not reproduce — data and trailers survive — which is what points the data loss at the proxy hop rather than Node.

Repro script (raw Node http2 — demonstrates the silent truncation)
// Variant 3: the RST(NO_ERROR) lands while the response is INCOMPLETE from the
// client's perspective (mid-DATA, or after DATA but before trailers).
// Expectation per pre-#63249 Node: readable ends CLEANLY, no 'error' event,
// rstCode = 0 -> silent truncation. connect-node would then report
// "incomplete envelope" (mid-message cut) or "missing status" (no trailers).
const http2 = require('node:http2');

const mode = process.argv[2]; // "midData" | "beforeTrailers"
const MB = 1024 * 1024;
const log = (...a) => console.log(...a);

const server = http2.createServer();
server.on('stream', (stream) => {
  stream.respond({ ':status': 200, 'content-type': 'application/grpc' });
  if (mode === 'midData') {
    stream.write(Buffer.alloc(300 * 1024, 0x61), () => {
      stream.close(http2.constants.NGHTTP2_NO_ERROR); // cut mid-message, no trailers, no END_STREAM
    });
  } else {
    // whole body flushed, then RST instead of trailers
    stream.write(Buffer.alloc(1 * MB, 0x61), () => {
      stream.close(http2.constants.NGHTTP2_NO_ERROR);
    });
  }
});

server.listen(0, () => {
  const port = server.address().port;
  const session = http2.connect(`http://127.0.0.1:${port}`, {
    settings: { initialWindowSize: 4 * MB },
  });
  const req = session.request({ ':path': '/x', ':method': 'POST' }, { endStream: false });
  let received = 0, sawTrailers = null, sawEnd = false, sawError = null;
  req.on('response', () => {});
  req.on('trailers', (t) => { sawTrailers = t; });
  req.on('error', (e) => { sawError = `${e.code}: ${e.message}`; });
  req.on('data', (c) => { received += c.length; });
  req.on('end', () => { sawEnd = true; });
  req.on('close', () => {
    log(`mode=${mode} received=${received} end=${sawEnd} trailers=${!!sawTrailers} error=${sawError} rstCode=${req.rstCode}`);
    log(sawError ? 'RESULT: surfaced as ERROR' : 'RESULT: SILENT clean EOF (truncation invisible to consumer)');
    process.exit(0);
  });
  req.write(Buffer.alloc(256 * 1024, 0x62)); // request kept in flight
  setTimeout(() => { log('timeout'); process.exit(1); }, 10000);
});

Expected behavior

A stream that closes with rstCode 0 before the response completed is a transport-level abort, not a server wire-format violation. Concretely: extend #1747's approach past response headers — track response completeness, and when the stream closes NO_ERROR with the response started but incomplete, reject with Code.Unavailable (or Aborted) and a message naming the reset instead of surfacing invalid_argument/internal protocol errors. This mirrors grpc-js, which reports this exact case as Received RST_STREAM with code 0 (Call ended without gRPC status) and ignores NO_ERROR resets only when the status already arrived. Secondarily: consider exposing http2 window settings (connect-node currently runs Node defaults, 65535) — downstream flow-control backpressure under concurrency is what widens the race; grpc-js 1.14.3 shipped an adjacent mitigation (prompt half-close) for the same Envoy interop.

A companion PR implementing this accompanies the issue.

Additional context — references

connect-es, active work in this code region:

Upstream and spec:

Activity

  1. rwieruch commented on Sep 2, 2026

    @rwieruch
    Author

    Follow-up from the reporter, after tracking this down end to end in our environment.

    What actually triggers it. The truncation only happened when our Node process read slowly (a Next.js server rendering a page) and the response was larger than the client's receive window. Node's http2 client advertises the protocol minimum of 65535 bytes per stream and per connection, so any larger response has a tail waiting in the proxy. Envoy then ended the stream while it still held those bytes. Neither extra request headers nor concurrency alone reproduced it; blocking the event loop did, every time. Pointed straight at the grpc-go server (bypassing the proxy) the same slow client never lost a byte, so this particular data loss is the proxy's doing, not grpc-go's.

    The mitigation that worked. Advertising larger windows from the client: settings.initialWindowSize in the http2.connect options (per stream) plus session.setLocalWindowSize() once per session (connection). With 4 MiB / 32 MiB the busy client went from losing 42–60 of 180 responses to 0 of 540 across repeated runs; 128 KiB and 256 KiB were still too small for our ~300 KiB responses. This is the same reason grpc-go, grpc-java and browsers were never affected: they all size windows in the megabytes. Node itself is adopting 4 MiB / 32 MiB as defaults in v27 (nodejs/node#64623), but that is semver-major, so Node 24 and 26 keep 64 KiB for their lifetime.

    Two notes for connect-node.

    • The per-stream window is reachable through nodeOptions, but the connection window is not: setLocalWindowSize needs the live session, which today means subclassing Http2SessionManager and hooking request(). A way to set it (or larger defaults until Node 27 is the floor) would make this mitigation a one-liner.
    • The mitigation avoids the loss; it does not fix the reporting. When the tail is lost, the client still sees incomplete envelope / missing status and blames the wire format, so the diagnosis in this issue and the intent of connect-node: reject gRPC responses truncated by RST_STREAM(NO_ERROR) with Code.Unavailable #1752 stand.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions