fix: retry refused HTTP/2 streams - #5598
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5598 +/- ##
=======================================
Coverage 93.49% 93.50%
=======================================
Files 110 110
Lines 38429 38476 +47
=======================================
+ Hits 35931 35976 +45
- Misses 2498 2500 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Signed-off-by: Matteo Collina <hello@matteocollina.com>
b87e26f to
34476c2
Compare
|
Rebased onto The conflict was not purely textual, so flagging the resolution for review: #5603 turned Merged naively, asking whether a REFUSED_STREAM retry is possible would silently consume one of the request's GOAWAY reconnect attempts. So I split them: function canReplayRequest (request) { // pure predicate
const { body } = request
return body == null || util.isBuffer(body) || util.isBlobLike(body)
}
function registerGoAwayRefusal (request) { // the budget, GOAWAY path only
const attempts = (request[kRefusedAttempts] ?? 0) + 1
request[kRefusedAttempts] = attempts
return attempts <= MAX_REFUSED_ATTEMPTS
}with the GOAWAY site now reading
One thing worth deciding separately: RFC 9113 §8.7 says clients SHOULD NOT automatically retry more than once, which this PR follows for REFUSED_STREAM ( Verification on the rebased branch: |
Two follow-ups to the rebase onto main. RFC 9113 section 8.7 says a client SHOULD NOT automatically retry a request more than once. The REFUSED_STREAM path already followed that; the GOAWAY replay budget introduced in #5603 allowed three. Lower it to one so both refusal signals behave the same, and rename the budget after the signal it belongs to now that REFUSED_STREAM retries live in this file too: MAX_REFUSED_ATTEMPTS -> MAX_GOAWAY_REPLAY_ATTEMPTS kRefusedAttempts -> kGoAwayReplayAttempts A peer that refuses every connection now opens two connections rather than four before the request fails, so tighten the regression bound accordingly. retryRefusedStream() also open-coded the stream detach that detachRequestStreamForClose() already performs. Reuse the helper: it drops the 'close' listener rather than relying only on the nulled state, and it guards the stream-count decrement, so the abandoned attempt cannot touch the retried request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A49JamgF2TkZHu5h58ChUM Signed-off-by: Matteo Collina <hello@matteocollina.com>
|
Pushed the refactor and the spec alignment — but please read the second half of this comment first, it blocks the PR. Changes in
|
| build | non-zero exits |
|---|---|
main (c76fe46) |
0/8 |
this branch at 34476c29 (rebase only, before today's commit) |
3/8 |
this branch at 5f9a9598 (with the changes above) |
1/12 |
So it is not the rebase resolution and not the two changes in this commit — the crash tracks the PR's own retry path. The refactor seems to reduce the rate rather than fix it, which is consistent with it removing one stale reference to the abandoned stream, but that is a guess.
The most likely area is retryRefusedStream() splicing the request back into the pending queue while the reset stream is still being torn down by nghttp2 — the request gets re-dispatched onto the same session while native state for the old stream is still in flight. Under borp this surfaces as a bare 'test failed' with one subtest missing and no assertion, which is easy to mistake for flakiness; running the file directly is what exposes the signal handler.
I did not chase it further — a native use-after-free needs a debug build and a gdb backtrace to pin down, and it may well be a Node bug that this access pattern triggers rather than something to fix here. Happy to dig in if useful.
|
Correction to my earlier comment above — the part blaming this PR for the crash was wrong, twice over. What I said: that the process crash "tracks the PR's own retry path", based on 3/8 crashes on this branch versus 0/8 on What is actually going on: there were two unrelated crashes behind one signature, and neither is caused by this PR. Under 1. 2. V8 maglev SIGSEGV. This is what the CI failures on this PR actually are, on released v24.18.0:
It reproduces on Net: no undici bug in this PR from either crash. The rebase and the RFC 9113 §8.7 changes still stand as described. CI on this branch will keep flaking on Node 24 until the V8 issue is fixed or Apologies for the noise from the earlier comment. |
This relates to...
HTTP/2 peers can reject an unprocessed stream with
RST_STREAM(REFUSED_STREAM). Node.js surfaces this as the genericERR_HTTP2_STREAM_ERROR, and Undici previously failed the request without considering the retry-safe HTTP/2 reset code.Rationale
RFC 9113 section 8.7 permits clients to retry a request rejected with
REFUSED_STREAM, including non-idempotent requests, because the peer asserts that no application processing occurred. It also says clients should not automatically retry the same request more than once.Changes
Features
N/A
Bug Fixes
NGHTTP2_REFUSED_STREAMusingstream.rstCodebefore response headers are exposed.Buffer, orBlob.error.http2ErrorCodewhen an error is ultimately exposed.Breaking Changes and Deprecations
None.
Status
Validation:
npm run test:h2:core— 96 passednpm run test:h2:fetch— 11 passednpx eslint lib/dispatcher/client-h2.js test/http2-refused-stream.jsnpm run lint