Skip to content

Commit c9aa3b0

Browse files
authored
fix: allow h2 post request multiplexing (#5391)
Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 669f0f8 commit c9aa3b0

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

lib/dispatcher/client-h2.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,10 @@ function connectH2 (client, socket) {
279279
if (client[kRunning] > 0) {
280280
// We are already processing requests
281281

282-
// Non-idempotent request cannot be retried.
283-
// Ensure that no other requests are inflight and
284-
// could cause failure.
285-
if (request.idempotent === false) return true
282+
// Unlike HTTP/1.1 pipelining, HTTP/2 multiplexes requests on
283+
// independent streams, so non-idempotent requests can be dispatched
284+
// concurrently. Retry eligibility is handled by stream/session error
285+
// handling instead of by serializing all non-idempotent requests.
286286
// Don't dispatch an upgrade until all preceding requests have completed.
287287
// Possibly, we do not have remote settings confirmed yet.
288288
if ((request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false) return true

test/issue-5390.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict'
2+
3+
const { test, after } = require('node:test')
4+
const { createSecureServer } = require('node:http2')
5+
const { once } = require('node:events')
6+
const { setTimeout: sleep } = require('node:timers/promises')
7+
8+
const { tspl } = require('@matteo.collina/tspl')
9+
const pem = require('@metcoder95/https-pem')
10+
11+
const { Agent, request } = require('..')
12+
13+
test('HTTP/2 POST requests multiplex on an established session', async t => {
14+
const p = tspl(t, { plan: 1 })
15+
16+
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
17+
const measuredStreams = []
18+
const measuredRequests = 5
19+
let releaseImmediately = false
20+
21+
function respond (stream) {
22+
if (!stream.closed && !stream.destroyed) {
23+
stream.respond({ ':status': 200 })
24+
stream.end('ok')
25+
}
26+
}
27+
28+
function flushMeasuredStreams () {
29+
for (const stream of measuredStreams) {
30+
respond(stream)
31+
}
32+
}
33+
34+
server.on('stream', (stream, headers) => {
35+
stream.on('error', () => {})
36+
37+
if (headers[':path'] === '/warmup') {
38+
respond(stream)
39+
return
40+
}
41+
42+
measuredStreams.push(stream)
43+
44+
if (releaseImmediately || measuredStreams.length === measuredRequests) {
45+
releaseImmediately = true
46+
flushMeasuredStreams()
47+
}
48+
})
49+
50+
after(() => server.close())
51+
await once(server.listen(0), 'listening')
52+
53+
const agent = new Agent({
54+
connect: {
55+
rejectUnauthorized: false
56+
},
57+
allowH2: true,
58+
connections: 1
59+
})
60+
after(() => agent.close())
61+
62+
const origin = `https://localhost:${server.address().port}`
63+
const warmup = await request(`${origin}/warmup`, { dispatcher: agent })
64+
await warmup.body.text()
65+
66+
const requests = Promise.all(Array.from({ length: measuredRequests }, () => {
67+
return request(origin, {
68+
dispatcher: agent,
69+
method: 'POST',
70+
body: 'hello'
71+
}).then(response => response.body.text())
72+
}))
73+
74+
await sleep(500)
75+
76+
const concurrentStreams = measuredStreams.length
77+
78+
releaseImmediately = true
79+
flushMeasuredStreams()
80+
await requests
81+
82+
p.strictEqual(concurrentStreams, measuredRequests)
83+
84+
await p.completed
85+
})

0 commit comments

Comments
 (0)