From 37c7233780fb2c2ed6e34093845738e74892f4ec Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 19 Mar 2026 20:57:48 +0000 Subject: [PATCH] fix(http-proxy): avoid repeated HTTP/3 recaching after QUIC fallback and document backend protocol selection --- changelog.md | 7 +++ readme.md | 57 +++++++++++++++++++ .../rustproxy-http/src/proxy_service.rs | 25 +++++--- ts/00_commitinfo_data.ts | 2 +- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/changelog.md b/changelog.md index 8873ce5..26fcf56 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-19 - 25.16.1 - fix(http-proxy) +avoid repeated HTTP/3 recaching after QUIC fallback and document backend protocol selection + +- Suppress Alt-Svc HTTP/3 recaching after a failed QUIC backend connection to prevent repeated H3 timeout fallback loops +- Force an ALPN probe on TCP fallback so auto detection correctly reselects HTTP/2 or HTTP/1.1 after H3 connection failure +- Add README documentation for best-effort backendProtocol selection and supported protocol modes + ## 2026-03-19 - 25.16.0 - feat(quic,http3) add HTTP/3 proxy handling and hot-reload QUIC TLS configuration diff --git a/readme.md b/readme.md index 5807b75..63fb979 100644 --- a/readme.md +++ b/readme.md @@ -328,6 +328,41 @@ const proxy = new SmartProxy({ }); ``` +### 🚄 Best-Effort Backend Protocol (H3 > H2 > H1) + +SmartProxy automatically uses the **highest protocol your backend supports** for HTTP requests. The backend protocol is independent of the client protocol — a client using HTTP/1.1 can be forwarded over HTTP/3 to the backend, and vice versa. + +```typescript +const route: IRouteConfig = { + name: 'auto-protocol', + match: { ports: 443, domains: 'app.example.com' }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 8443 }], + tls: { mode: 'terminate', certificate: 'auto' }, + options: { + backendProtocol: 'auto' // 👈 Default — best-effort selection + } + } +}; +``` + +**How protocol discovery works (browser model):** + +1. First request → TLS ALPN probe detects H2 or H1 +2. Backend response inspected for `Alt-Svc: h3=":port"` header +3. If H3 advertised → cached and used for subsequent requests via QUIC +4. Graceful fallback: H3 failure → H2 → H1 with automatic cache invalidation + +| `backendProtocol` | Behavior | +|---|---| +| `'auto'` (default) | Best-effort: H3 > H2 > H1 with Alt-Svc discovery | +| `'http1'` | Always HTTP/1.1 | +| `'http2'` | Always HTTP/2 (hard-fail if unsupported) | +| `'http3'` | Always HTTP/3 via QUIC (hard-fail if unsupported) | + +> **Note:** WebSocket upgrades always use HTTP/1.1 to the backend regardless of `backendProtocol`, since there's no performance benefit from H2/H3 Extended CONNECT for tunneled connections, and backend support is rare. + ### 🔁 Dual-Stack TCP + UDP Route Listen on both TCP and UDP with a single route — handle each transport with its own handler: @@ -776,6 +811,28 @@ interface IRouteLoadBalancing { } ``` +### Backend Protocol Options + +```typescript +// Set on action.options +{ + action: { + type: 'forward', + targets: [...], + options: { + backendProtocol: 'auto' | 'http1' | 'http2' | 'http3' + } + } +} +``` + +| Value | Backend Behavior | +|-------|-----------------| +| `'auto'` | Best-effort: discovers H3 via Alt-Svc, probes H2 via ALPN, falls back to H1 | +| `'http1'` | Always HTTP/1.1 (no ALPN probe) | +| `'http2'` | Always HTTP/2 (hard-fail if handshake fails) | +| `'http3'` | Always HTTP/3 over QUIC (3s connect timeout, hard-fail if unreachable) | + ### UDP & QUIC Options ```typescript diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index db2748f..0554644 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -696,13 +696,17 @@ impl HttpProxyService { }; // Derive legacy flags for the existing H1/H2 connection path - let (use_h2, needs_alpn_probe) = match &protocol_decision { + let (use_h2, mut needs_alpn_probe) = match &protocol_decision { ProtocolDecision::H1 => (false, false), ProtocolDecision::H2 => (true, false), ProtocolDecision::H3 { .. } => (false, false), // H3 path handled separately below ProtocolDecision::AlpnProbe => (false, true), }; + // Track whether H3 connect failed — suppresses Alt-Svc re-caching to prevent + // the loop: H3 cached → QUIC timeout → H2/H1 fallback → Alt-Svc re-caches H3 → repeat + let mut h3_connect_failed = false; + // --- H3 path: try QUIC connection before TCP --- if let ProtocolDecision::H3 { port: h3_port } = protocol_decision { let h3_pool_key = crate::connection_pool::PoolKey { @@ -738,14 +742,13 @@ impl HttpProxyService { Err(e) => { warn!(backend = %upstream_key, error = %e, "H3 backend connect failed, falling back to H2/H1"); - // Invalidate H3 from cache — next request will ALPN probe for H2/H1 - if is_auto_detect_mode { - self.protocol_cache.insert( - protocol_cache_key.clone(), - crate::protocol_cache::DetectedProtocol::H1, - ); + h3_connect_failed = true; + // Force ALPN probe on TCP fallback so we correctly detect H2 vs H1 + // (don't cache anything yet — let the ALPN probe decide) + if is_auto_detect_mode && upstream.use_tls { + needs_alpn_probe = true; } - // Fall through to TCP path (ALPN probe for auto, or H1 for explicit) + // Fall through to TCP path } } } @@ -946,7 +949,11 @@ impl HttpProxyService { self.metrics.backend_connection_closed(&upstream_key); // --- Alt-Svc discovery: check if backend advertises H3 --- - if is_auto_detect_mode { + // Suppress Alt-Svc caching when we just failed an H3 attempt to prevent the loop: + // H3 cached → QUIC timeout → fallback → Alt-Svc re-caches H3 → repeat. + // The ALPN probe already cached H1 or H2; it will expire after 5min TTL, + // at which point we'll re-probe and see Alt-Svc again, retrying QUIC then. + if is_auto_detect_mode && !h3_connect_failed { if let Ok(ref resp) = result { if let Some(alt_svc) = resp.headers().get("alt-svc").and_then(|v| v.to_str().ok()) { if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 900d2fc..767409a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.16.0', + version: '25.16.1', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }