← Debugging Guides

阅读中文版本

HTTP/2 — ERR_HTTP2_STREAM_ERROR and GOAWAY Mid-Session

Diagnose HTTP/2 stream resets and GOAWAY terminations mid-session, distinguish protocol errors from graceful shutdowns, and isolate misbehaving intermediaries.

advanceddebugginghttp2protocolnetwork

Symptoms

  • Requests fail with ERR_HTTP2_STREAM_ERROR or ERR_HTTP2_PROTOCOL_ERROR on long-lived connections
  • nghttp or curl -v show RST_STREAM frames with error codes mid-conversation
  • Failures correlate with a specific proxy or WAF, while direct-to-origin HTTP/2 works

Possible causes

  • An intermediary misimplements flow control or frame ordering and resets streams on valid traffic
  • The server sends GOAWAY for graceful shutdown, and the client mishandles in-flight streams below last-stream-id
  • Content-length or trailer disagreements trip protocol errors at a middle layer only
  • A WAF re-encodes bodies and violates the HPACK or framing invariants

Diagnosis steps

Observe the wire directly

nghttp -v https://api.example.com/data 2>&1 | grep -E "GOAWAY|RST_STREAM|error" | head

Interpretation

The frame type and error code (PROTOCOL_ERROR, REFUSED_STREAM, NO_ERROR) separate graceful shutdowns from actual violations.

Bypass suspected intermediaries

curl --http2 -v https://origin.internal/data 2>&1 | tail -5

Interpretation

Direct-to-origin succeeding while the public path fails isolates the misbehaving middle layer by elimination.

Compare protocol variants

curl --http1.1 -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://app.example.com/data && curl --http2 -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://app.example.com/data

Interpretation

HTTP/1.1 passing where HTTP/2 fails is the intermediary HTTP/2 implementation, not the application.

Root cause

HTTP/2 multiplexes streams under strict framing invariants. A component that violates them — misimplemented flow control, wrong frame ordering, re-encoded bodies breaking HPACK — resets streams or tears the session with GOAWAY. Distinct error codes distinguish these failures from the graceful GOAWAY shutdown that well-behaved servers emit.

Fix

Route around or upgrade the misbehaving intermediary, handle GOAWAY last-stream-id semantics on the client (retry streams above it, finish streams below), pin connection lifetimes below intermediary idle timeouts, and keep HTTP/1.1 as a scoped fallback while the offending component is fixed.

Risk note

Disabling HTTP/2 globally to mask one bad proxy forfeits multiplexing everywhere. Scope the fallback to the failing path.

Verify the fix

Long-lived HTTP/2 sessions hold through representative traffic with zero protocol errors, and graceful GOAWAY handling completes in-flight streams without client failures.

Symptom walkthrough

The frame trace is the entire story:

$ nghttp -v https://api.example.com/data
[ 0.012] recv GOAWAY frame (last_stream_id=1141, error_code=NO_ERROR)
[ 0.014] recv RST_STREAM frame (stream_id=1142, error_code=PROTOCOL_ERROR)

GOAWAY with NO_ERROR and last_stream_id 1141 is a graceful shutdown: streams up to 1141 were processed, and the client should retry 1142 onward on a new connection — a lifecycle, not a failure. RST_STREAM with PROTOCOL_ERROR on a fresh stream is a real violation — some component in the path is mangling framing. The elimination test isolates which component: direct-to-origin HTTP/2 clean plus public path failing means the middle layer; HTTP/1.1 clean plus HTTP/2 failing on the same path confirms the intermediary HTTP/2 implementation specifically.

Fix in practice

Handle GOAWAY as a lifecycle on the client, and isolate the misbehaving intermediary.

const http2 = require('node:http2');

const session = http2.connect(origin);
session.on('goaway', (code, lastStreamID) => {
  // streams above lastStreamID were never processed: safe to retry
  // streams at or below it: already committed upstream — do not blind-retry
  retryAbove(lastStreamID, { idempotentOnly: true });
  session.close();
});

For the intermediary: upgrade or route around the component that emits RST_STREAM PROTOCOL_ERROR on valid framing, pin client connection lifetimes below its idle timeout, and scope an HTTP/1.1 fallback to the failing path rather than disabling HTTP/2 globally. End-to-end verification after the change: long-lived sessions hold through representative traffic with zero protocol errors in the frame trace, and a graceful GOAWAY completes in-flight streams without surfacing client failures.