fix(proxy-service): handle HTTP/3 backend forwarding failures with protocol fallback and pool cleanup

This commit is contained in:
2026-05-12 22:22:10 +00:00
parent 8415a82f21
commit e220208c16
9 changed files with 3854 additions and 4098 deletions
+180
View File
@@ -0,0 +1,180 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import * as fs from 'node:fs';
import * as http2 from 'node:http2';
import * as https from 'node:https';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { findFreePorts } from './helpers/port-allocator.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
const TEST_DOMAIN = 'verdaccio.test';
let backendPort: number;
let proxyPort: number;
let unavailableH3Port: number;
let backendServer: https.Server;
let proxy: SmartProxy;
function httpsRequest(requestPath: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: 'localhost',
port: proxyPort,
path: requestPath,
method: 'GET',
headers: {
Host: TEST_DOMAIN,
},
rejectUnauthorized: false,
servername: TEST_DOMAIN,
agent: new https.Agent({ keepAlive: false, rejectUnauthorized: false }),
},
(res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk.toString();
});
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on('error', reject);
req.setTimeout(5000, () => req.destroy(new Error('https request timeout')));
req.end();
});
}
function http2Request(requestPath: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const session = http2.connect(`https://localhost:${proxyPort}`, {
rejectUnauthorized: false,
servername: TEST_DOMAIN,
});
const cleanup = () => {
if (!session.closed && !session.destroyed) {
session.close();
}
};
session.once('error', (error) => {
cleanup();
reject(error);
});
session.once('connect', () => {
const req = session.request({
':method': 'GET',
':path': requestPath,
':authority': TEST_DOMAIN,
});
let status = 0;
let body = '';
req.setEncoding('utf8');
req.on('response', (headers) => {
status = Number(headers[':status'] ?? 0);
});
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
cleanup();
resolve({ status, body });
});
req.on('error', (error) => {
cleanup();
reject(error);
});
req.end();
});
setTimeout(() => {
cleanup();
reject(new Error('http2 request timeout'));
}, 5000).unref();
});
}
tap.test('setup - backend with Alt-Svc H3 hint and TLS proxy', async () => {
[backendPort, proxyPort, unavailableH3Port] = await findFreePorts(3);
backendServer = https.createServer({ key: KEY_PEM, cert: CERT_PEM }, (req, res) => {
const body = JSON.stringify({ ok: true, url: req.url, host: req.headers.host });
res.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
'alt-svc': `h3=":${unavailableH3Port}"; ma=86400`,
});
res.end(body);
});
await new Promise<void>((resolve, reject) => {
backendServer.once('error', reject);
backendServer.listen(backendPort, () => resolve());
});
proxy = new SmartProxy({
routes: [
{
id: 'backend-protocol-fallback',
name: 'backend-protocol-fallback',
match: { ports: proxyPort, domains: TEST_DOMAIN },
action: {
type: 'forward',
tls: {
mode: 'terminate',
certificate: {
key: KEY_PEM,
cert: CERT_PEM,
},
},
targets: [
{
host: 'localhost',
port: backendPort,
tls: { mode: 'passthrough' },
},
],
options: { backendProtocol: 'auto' },
},
},
],
connectionTimeout: 500,
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 30 },
});
await proxy.start();
await new Promise((resolve) => setTimeout(resolve, 300));
});
tap.test('backend protocol auto: fresh HTTP/1.1 survives unavailable H3 hint', async (tools) => {
tools.timeout(10000);
const first = await httpsRequest('/@consent.software%2Fcatalog');
expect(first.status).toEqual(200);
expect(JSON.parse(first.body).ok).toEqual(true);
const second = await httpsRequest('/@consent.software%2Fcatalog?retry=1');
expect(second.status).toEqual(200);
expect(JSON.parse(second.body).url).toEqual('/@consent.software%2Fcatalog?retry=1');
});
tap.test('backend protocol auto: fresh HTTP/2 survives suppressed H3 hint', async (tools) => {
tools.timeout(10000);
const result = await http2Request('/@consent.software%2Fcatalog?frontend=h2');
expect(result.status).toEqual(200);
expect(JSON.parse(result.body).url).toEqual('/@consent.software%2Fcatalog?frontend=h2');
});
tap.test('cleanup - backend protocol fallback', async () => {
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
});
export default tap.start();