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
+15 -7
View File
@@ -28,12 +28,17 @@
] ]
}, },
"release": { "release": {
"registries": [ "targets": {
"https://verdaccio.lossless.digital", "npm": {
"https://registry.npmjs.org" "registries": [
], "https://verdaccio.lossless.digital",
"accessLevel": "public" "https://registry.npmjs.org"
} ],
"accessLevel": "public"
}
}
},
"schemaVersion": 2
}, },
"@git.zone/tsdoc": { "@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n" "legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
@@ -42,6 +47,9 @@
"npmGlobalTools": [] "npmGlobalTools": []
}, },
"@git.zone/tsrust": { "@git.zone/tsrust": {
"targets": ["linux_amd64", "linux_arm64"] "targets": [
"linux_amd64",
"linux_arm64"
]
} }
} }
+11 -1
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## Pending
### Fixes
- handle HTTP/3 backend forwarding failures with protocol fallback and pool cleanup (proxy-service)
- Retry bodyless requests over HTTP/1.1 when HTTP/3 forwarding fails in auto backend protocol mode
- Remove broken HTTP/3 pooled connections and record protocol cache failures to avoid repeated H3 reuse
- Add regression coverage for backends that advertise an unavailable Alt-Svc HTTP/3 endpoint
- Refresh documentation examples and API notes to reflect target-level load balancing and certificate provisioning event hooks
## 2026-04-30 - 27.10.0 - feat(exports) ## 2026-04-30 - 27.10.0 - feat(exports)
export datagram handler types and align tests with updated nftables and route security APIs export datagram handler types and align tests with updated nftables and route security APIs
@@ -1207,4 +1217,4 @@ Fix ACME certificate provisioning timing to ensure ports are listening first
- Fixed race condition where certificate provisioning would start before ports were listening - Fixed race condition where certificate provisioning would start before ports were listening
- Modified SmartCertManager.initialize() to defer certificate provisioning - Modified SmartCertManager.initialize() to defer certificate provisioning
- Added SmartCertManager.provisionCertificatesAfterPortsReady() for delayed provisioning - Added SmartCertManager.provisionCertificatesAfterPortsReady() for delayed provisioning
- Updated SmartProxy.start() to call certificate provisioning after ports are ready - Updated SmartProxy.start() to call certificate provisioning after ports are ready
Generated
+1143 -1518
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -16,16 +16,16 @@
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.4.0", "@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsrun": "^2.0.2", "@git.zone/tsrun": "^2.0.4",
"@git.zone/tsrust": "^1.3.2", "@git.zone/tsrust": "^1.3.4",
"@git.zone/tstest": "^3.6.3", "@git.zone/tstest": "^3.6.6",
"@push.rocks/smartserve": "^2.0.3", "@push.rocks/smartserve": "^2.0.4",
"@types/node": "^25.6.0", "@types/node": "^25.7.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"why-is-node-running": "^3.2.2", "why-is-node-running": "^3.2.2",
"ws": "^8.20.0" "ws": "^8.20.1"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartcrypto": "^2.0.4", "@push.rocks/smartcrypto": "^2.0.4",
+2198 -2515
View File
File diff suppressed because it is too large Load Diff
+47 -26
View File
@@ -2,16 +2,16 @@
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables). **A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables).
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 📦 Installation ## 📦 Installation
```bash ```bash
pnpm add @push.rocks/smartproxy pnpm add @push.rocks/smartproxy
``` ```
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 🎯 What is SmartProxy? ## 🎯 What is SmartProxy?
SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety. SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety.
@@ -28,7 +28,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
| 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic | | 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic |
| 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding | | 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding |
| 📡 **UDP & QUIC/HTTP3** | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support | | 📡 **UDP & QUIC/HTTP3** | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support |
| ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks | | ⚖️ **Load Balancing** | Round-robin, least-connections, and IP-hash selection across host arrays |
| 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits | | 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
| 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive | | 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive |
| 🎮 **Custom Protocols** | Socket and datagram handlers for implementing any protocol in TypeScript | | 🎮 **Custom Protocols** | Socket and datagram handlers for implementing any protocol in TypeScript |
@@ -135,7 +135,9 @@ const proxy = new SmartProxy({
}); });
``` ```
### ⚖️ Load Balancer with Health Checks ### ⚖️ Load Balancer
For equivalent backends, put the backend hosts into one target's `host` array and choose a target-level load-balancing algorithm. Multiple `targets` are for sub-routing with `target.match` and `priority`.
```typescript ```typescript
import { SmartProxy } from '@push.rocks/smartproxy'; import { SmartProxy } from '@push.rocks/smartproxy';
@@ -146,22 +148,12 @@ const proxy = new SmartProxy({
match: { ports: 443, domains: 'app.example.com' }, match: { ports: 443, domains: 'app.example.com' },
action: { action: {
type: 'forward', type: 'forward',
targets: [ targets: [{
{ host: 'server1.internal', port: 8080 }, host: ['server1.internal', 'server2.internal', 'server3.internal'],
{ host: 'server2.internal', port: 8080 }, port: 8080,
{ host: 'server3.internal', port: 8080 } loadBalancing: { algorithm: 'round-robin' }
], }],
tls: { mode: 'terminate', certificate: 'auto' }, tls: { mode: 'terminate', certificate: 'auto' }
loadBalancing: {
algorithm: 'round-robin',
healthCheck: {
path: '/health',
interval: 30000,
timeout: 5000,
unhealthyThreshold: 3,
healthyThreshold: 2
}
}
} }
}] }]
}); });
@@ -647,7 +639,9 @@ Supply your own certificates or integrate with external certificate providers:
```typescript ```typescript
const proxy = new SmartProxy({ const proxy = new SmartProxy({
certProvisionFunction: async (domain: string) => { certProvisionFunction: async (domain, eventComms) => {
eventComms.setSource('custom-acme-provider');
// Return 'http01' to let the built-in ACME handle it // Return 'http01' to let the built-in ACME handle it
if (domain.endsWith('.example.com')) return 'http01'; if (domain.endsWith('.example.com')) return 'http01';
@@ -670,7 +664,11 @@ SmartProxy **never writes certificates to disk**. Instead, you own all persisten
const proxy = new SmartProxy({ const proxy = new SmartProxy({
routes: [...], routes: [...],
certProvisionFunction: async (domain) => myAcme.provision(domain), certProvisionFunction: async (domain, eventComms) => {
const cert = await myAcme.provision(domain);
eventComms.setExpiryDate(new Date(cert.validUntil));
return cert;
},
// Your persistence layer — SmartProxy calls these hooks // Your persistence layer — SmartProxy calls these hooks
certStore: { certStore: {
@@ -774,6 +772,8 @@ type TPortRange = number | Array<number | { from: number; to: number }>;
| `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) | | `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
| `socket-handler` | Custom socket/datagram handling function in TypeScript | | `socket-handler` | Custom socket/datagram handling function in TypeScript |
`targets` are evaluated as route-internal sub-routes by `target.match` and `target.priority`. For load balancing across equivalent upstreams, use a single target with `host: ['a', 'b', 'c']` and target-level `loadBalancing`.
### Target Options ### Target Options
```typescript ```typescript
@@ -845,6 +845,8 @@ interface IRouteLoadBalancing {
} }
``` ```
Use this on an `IRouteTarget` with `host` as a string array. The `healthCheck` shape is accepted by the type layer, but active backend health polling is not currently performed by the Rust selector.
### Backend Protocol Options ### Backend Protocol Options
```typescript ```typescript
@@ -922,6 +924,7 @@ class SmartProxy extends EventEmitter {
// Route Management (atomic, mutex-locked) // Route Management (atomic, mutex-locked)
updateRoutes(routes: IRouteConfig[]): Promise<void>; updateRoutes(routes: IRouteConfig[]): Promise<void>;
updateSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void>;
// Port Management // Port Management
addListeningPort(port: number): Promise<void>; addListeningPort(port: number): Promise<void>;
@@ -930,7 +933,7 @@ class SmartProxy extends EventEmitter {
// Monitoring & Metrics // Monitoring & Metrics
getMetrics(): IMetrics; // Sync — returns cached metrics adapter getMetrics(): IMetrics; // Sync — returns cached metrics adapter
getStatistics(): Promise<any>; // Async — queries Rust engine getStatistics(): Promise<IRustStatistics>; // Async — queries Rust engine
// Certificate Management // Certificate Management
provisionCertificate(routeName: string): Promise<void>; provisionCertificate(routeName: string): Promise<void>;
@@ -965,7 +968,10 @@ interface ISmartProxyOptions {
}; };
// Custom certificate provisioning // Custom certificate provisioning
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>; certProvisionFunction?: (
domain: string,
eventComms: ICertProvisionEventComms
) => Promise<TSmartProxyCertProvisionObject>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true) certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
certProvisionTimeout?: number; // Timeout per provision call (ms) certProvisionTimeout?: number; // Timeout per provision call (ms)
certProvisionConcurrency?: number; // Max concurrent provisions certProvisionConcurrency?: number; // Max concurrent provisions
@@ -1001,6 +1007,10 @@ interface ISmartProxyOptions {
// Connection limits // Connection limits
maxConnectionsPerIP?: number; // Per-IP connection limit (default: 100) maxConnectionsPerIP?: number; // Per-IP connection limit (default: 100)
connectionRateLimitPerMinute?: number; // Per-IP rate limit (default: 300/min) connectionRateLimitPerMinute?: number; // Per-IP rate limit (default: 300/min)
securityPolicy?: {
blockedIps?: string[];
blockedCidrs?: string[];
}; // Global ingress block policy
// Keep-alive // Keep-alive
keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
@@ -1053,17 +1063,25 @@ metrics.connections.total(); // Total connections since start
metrics.connections.byRoute(); // Map<routeName, activeCount> metrics.connections.byRoute(); // Map<routeName, activeCount>
metrics.connections.byIP(); // Map<ip, activeCount> metrics.connections.byIP(); // Map<ip, activeCount>
metrics.connections.topIPs(10); // Top N IPs by connection count metrics.connections.topIPs(10); // Top N IPs by connection count
metrics.connections.domainRequestsByIP(); // Map<ip, Map<domain, requestCount>>
metrics.connections.topDomainRequests(20); // Top IP/domain pairs by request count
metrics.connections.frontendProtocols(); // H1/H2/H3/WS frontend distribution
metrics.connections.backendProtocols(); // H1/H2/H3/WS backend distribution
// Throughput (bytes/sec) // Throughput (bytes/sec)
metrics.throughput.instant(); // { in: number, out: number } metrics.throughput.instant(); // { in: number, out: number }
metrics.throughput.recent(); // Recent average metrics.throughput.recent(); // Recent average
metrics.throughput.average(); // Overall average metrics.throughput.average(); // Overall average
metrics.throughput.custom(30); // Custom window, if provided by Rust cache
metrics.throughput.history(60); // Recent throughput samples
metrics.throughput.byRoute(); // Map<routeName, { in, out }> metrics.throughput.byRoute(); // Map<routeName, { in, out }>
metrics.throughput.byIP(); // Map<ip, { in, out }>
// Request rates // Request rates
metrics.requests.perSecond(); // Requests per second metrics.requests.perSecond(); // Requests per second
metrics.requests.perMinute(); // Requests per minute metrics.requests.perMinute(); // Requests per minute
metrics.requests.total(); // Total requests metrics.requests.total(); // Total requests
metrics.requests.byDomain(); // Map<domain, { perSecond, lastMinute }>
// UDP metrics // UDP metrics
metrics.udp.activeSessions(); // Current active UDP sessions metrics.udp.activeSessions(); // Current active UDP sessions
@@ -1080,12 +1098,15 @@ metrics.totals.connections(); // Total connections
metrics.backends.byBackend(); // Map<backend, IBackendMetrics> metrics.backends.byBackend(); // Map<backend, IBackendMetrics>
metrics.backends.protocols(); // Map<backend, protocol> metrics.backends.protocols(); // Map<backend, protocol>
metrics.backends.topByErrors(10); // Top N error-prone backends metrics.backends.topByErrors(10); // Top N error-prone backends
metrics.backends.detectedProtocols(); // Backend protocol discovery cache
// Percentiles // Percentiles
metrics.percentiles.connectionDuration(); // { p50, p95, p99 } metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } } metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
``` ```
The percentile methods are part of the public metrics shape. In the current Rust adapter they return zeroed values until percentile collection is implemented in the Rust metrics snapshot.
## 🐛 Troubleshooting ## 🐛 Troubleshooting
### Certificate Issues ### Certificate Issues
@@ -280,6 +280,11 @@ impl ConnectionPool {
} }
} }
/// Remove a QUIC/HTTP/3 connection from the pool unconditionally.
pub fn remove_h3(&self, key: &PoolKey) {
self.h3_pool.remove(key);
}
/// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections. /// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections.
async fn eviction_loop( async fn eviction_loop(
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>, h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
+248 -24
View File
@@ -92,6 +92,23 @@ enum ProtocolDecision {
AlpnProbe, AlpnProbe,
} }
#[derive(Debug)]
struct H3ForwardError {
status: StatusCode,
message: &'static str,
retryable: bool,
}
impl H3ForwardError {
fn new(status: StatusCode, message: &'static str, retryable: bool) -> Self {
Self {
status,
message,
retryable,
}
}
}
/// RAII guard that decrements the active request counter on drop. /// RAII guard that decrements the active request counter on drop.
/// Ensures the counter is correct even if the request handler panics. /// Ensures the counter is correct even if the request handler panics.
struct ActiveRequestGuard { struct ActiveRequestGuard {
@@ -972,6 +989,11 @@ impl HttpProxyService {
use_tls: true, use_tls: true,
protocol: crate::connection_pool::PoolProtocol::H3, protocol: crate::connection_pool::PoolProtocol::H3,
}; };
let h3_retry_state = if body.is_end_stream() {
Some((parts.method.clone(), upstream_headers.clone()))
} else {
None
};
// Try H3 pool checkout first // Try H3 pool checkout first
if let Some((pooled_sr, quic_conn, _age)) = if let Some((pooled_sr, quic_conn, _age)) =
@@ -990,13 +1012,53 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
if is_auto_detect_mode {
Some(protocol_cache_key.clone())
} else {
None
},
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); match result {
return result; Ok(response) => {
self.upstream_selector.connection_ended(&upstream_key);
return Ok(response);
}
Err(error) => {
if is_auto_detect_mode {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
}
if is_auto_detect_mode && error.retryable {
if let Some((method, headers)) = h3_retry_state {
let fallback = self
.retry_h3_failure_as_h1(
method,
headers,
&upstream_path,
&upstream,
route_match.route,
route_id,
&ip_str,
&protocol_cache_key,
domain_str,
&conn_activity,
&upstream_key,
)
.await;
self.upstream_selector.connection_ended(&upstream_key);
return fallback;
}
}
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(error.status, error.message));
}
}
} }
// Try fresh QUIC connection // Try fresh QUIC connection
@@ -1019,13 +1081,54 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
if is_auto_detect_mode {
Some(protocol_cache_key.clone())
} else {
None
},
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); match result {
return result; Ok(response) => {
self.upstream_selector.connection_ended(&upstream_key);
return Ok(response);
}
Err(error) => {
self.metrics.backend_connection_closed(&upstream_key);
if is_auto_detect_mode {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
}
if is_auto_detect_mode && error.retryable {
if let Some((method, headers)) = h3_retry_state {
let fallback = self
.retry_h3_failure_as_h1(
method,
headers,
&upstream_path,
&upstream,
route_match.route,
route_id,
&ip_str,
&protocol_cache_key,
domain_str,
&conn_activity,
&upstream_key,
)
.await;
self.upstream_selector.connection_ended(&upstream_key);
return fallback;
}
}
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(error.status, error.message));
}
}
} }
Err(e) => { Err(e) => {
warn!(backend = %upstream_key, domain = %domain_str, error = %e, warn!(backend = %upstream_key, domain = %domain_str, error = %e,
@@ -1236,13 +1339,23 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
Some(protocol_cache_key.clone()),
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
return result; return match result {
Ok(response) => Ok(response),
Err(error) => {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
Ok(error_response(error.status, error.message))
}
};
} }
Err(e3) => { Err(e3) => {
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed"); debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
@@ -1313,13 +1426,23 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
Some(protocol_cache_key.clone()),
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
return result; return match result {
Ok(response) => Ok(response),
Err(error) => {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
Ok(error_response(error.status, error.message))
}
};
} }
Err(e3) => { Err(e3) => {
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed"); debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
@@ -1410,13 +1533,23 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
Some(protocol_cache_key.clone()),
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
return result; return match result {
Ok(response) => Ok(response),
Err(error) => {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
Ok(error_response(error.status, error.message))
}
};
} }
Err(e3) => { Err(e3) => {
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed"); debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
@@ -1487,13 +1620,23 @@ impl HttpProxyService {
route_id, route_id,
&ip_str, &ip_str,
&h3_pool_key, &h3_pool_key,
Some(protocol_cache_key.clone()),
domain_str, domain_str,
&conn_activity, &conn_activity,
&upstream_key, &upstream_key,
) )
.await; .await;
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
return result; return match result {
Ok(response) => Ok(response),
Err(error) => {
self.protocol_cache.record_failure(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H3,
);
Ok(error_response(error.status, error.message))
}
};
} }
Err(e3) => { Err(e3) => {
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed"); debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
@@ -2402,6 +2545,58 @@ impl HttpProxyService {
} }
} }
/// Retry a bodyless request over HTTP/1.1 after H3 failed post-connect.
/// The original body has already been handed to the H3 path, so only empty
/// requests can be retried safely without duplicating client data.
async fn retry_h3_failure_as_h1(
&self,
method: hyper::Method,
upstream_headers: hyper::HeaderMap,
upstream_path: &str,
upstream: &crate::upstream_selector::UpstreamSelection,
route: &rustproxy_config::RouteConfig,
route_id: Option<&str>,
source_ip: &str,
protocol_cache_key: &crate::protocol_cache::ProtocolCacheKey,
domain: &str,
conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
warn!(backend = %backend_key, domain = %domain,
"H3 forwarding failed, retrying bodyless request as HTTP/1.1");
self.protocol_cache.insert(
protocol_cache_key.clone(),
crate::protocol_cache::DetectedProtocol::H1,
"H3 forwarding failure — downgrade to H1",
);
match self.reconnect_backend(upstream, domain, backend_key).await {
Some(fallback_backend) => {
let fallback_io = TokioIo::new(fallback_backend);
let result = self
.forward_h1_empty_body(
fallback_io,
method,
upstream_headers,
upstream_path,
route,
route_id,
source_ip,
domain,
conn_activity,
backend_key,
)
.await;
self.metrics.backend_connection_closed(backend_key);
result
}
None => Ok(error_response(
StatusCode::BAD_GATEWAY,
"Backend unavailable after H3 fallback",
)),
}
}
/// Forward a request with an empty body via HTTP/1.1. /// Forward a request with an empty body via HTTP/1.1.
/// Used when retrying after a failed H2 attempt where the original body was consumed. /// Used when retrying after a failed H2 attempt where the original body was consumed.
async fn forward_h1_empty_body( async fn forward_h1_empty_body(
@@ -3552,10 +3747,11 @@ impl HttpProxyService {
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str, source_ip: &str,
pool_key: &crate::connection_pool::PoolKey, pool_key: &crate::connection_pool::PoolKey,
protocol_cache_key: Option<crate::protocol_cache::ProtocolCacheKey>,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str, backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, H3ForwardError> {
// Obtain the h3 SendRequest handle: skip handshake + driver on pool hit. // Obtain the h3 SendRequest handle: skip handshake + driver on pool hit.
let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender { let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender {
// Pool hit — reuse existing h3 session, no SETTINGS round-trip // Pool hit — reuse existing h3 session, no SETTINGS round-trip
@@ -3572,9 +3768,11 @@ impl HttpProxyService {
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
self.metrics.backend_handshake_error(backend_key); self.metrics.backend_handshake_error(backend_key);
return Ok(error_response( self.connection_pool.remove_h3(pool_key);
return Err(H3ForwardError::new(
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
"H3 handshake failed", "H3 handshake failed",
true,
)); ));
} }
}; };
@@ -3623,7 +3821,12 @@ impl HttpProxyService {
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H3 send_request failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H3 send_request failed");
self.metrics.backend_request_error(backend_key); self.metrics.backend_request_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 request failed")); self.connection_pool.remove_h3(pool_key);
return Err(H3ForwardError::new(
StatusCode::BAD_GATEWAY,
"H3 request failed",
true,
));
} }
}; };
@@ -3646,9 +3849,11 @@ impl HttpProxyService {
); );
if let Err(e) = stream.send_data(data).await { if let Err(e) = stream.send_data(data).await {
error!(backend = %backend_key, error = %e, "H3 send_data failed"); error!(backend = %backend_key, error = %e, "H3 send_data failed");
return Ok(error_response( self.connection_pool.remove_h3(pool_key);
return Err(H3ForwardError::new(
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
"H3 body send failed", "H3 body send failed",
false,
)); ));
} }
} }
@@ -3669,9 +3874,11 @@ impl HttpProxyService {
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H3 recv_response failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H3 recv_response failed");
self.metrics.backend_request_error(backend_key); self.metrics.backend_request_error(backend_key);
return Ok(error_response( self.connection_pool.remove_h3(pool_key);
return Err(H3ForwardError::new(
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
"H3 response failed", "H3 response failed",
true,
)); ));
} }
}; };
@@ -3693,17 +3900,34 @@ impl HttpProxyService {
} }
// Stream response body back via unfold — correctly preserves waker across polls // Stream response body back via unfold — correctly preserves waker across polls
let body_stream = futures::stream::unfold(stream, |mut s| async move { let h3_failure_cache = Arc::clone(&self.protocol_cache);
match s.recv_data().await { let h3_failure_cache_key = protocol_cache_key.clone();
Ok(Some(mut buf)) => { let h3_failure_pool = Arc::clone(&self.connection_pool);
use bytes::Buf; let h3_failure_pool_key = pool_key.clone();
let data = buf.copy_to_bytes(buf.remaining()); let body_stream = futures::stream::unfold(stream, move |mut s| {
Some((Ok::<_, hyper::Error>(http_body::Frame::data(data)), s)) let h3_failure_cache = Arc::clone(&h3_failure_cache);
} let h3_failure_cache_key = h3_failure_cache_key.clone();
Ok(None) => None, let h3_failure_pool = Arc::clone(&h3_failure_pool);
Err(e) => { let h3_failure_pool_key = h3_failure_pool_key.clone();
warn!("H3 response body recv error: {}", e); async move {
None match s.recv_data().await {
Ok(Some(mut buf)) => {
use bytes::Buf;
let data = buf.copy_to_bytes(buf.remaining());
Some((Ok::<_, hyper::Error>(http_body::Frame::data(data)), s))
}
Ok(None) => None,
Err(e) => {
warn!("H3 response body recv error: {}", e);
if let Some(cache_key) = h3_failure_cache_key {
h3_failure_cache.record_failure(
cache_key,
crate::protocol_cache::DetectedProtocol::H3,
);
}
h3_failure_pool.remove_h3(&h3_failure_pool_key);
None
}
} }
} }
}); });
+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();