Skip to main content

http_handle/
http3_profile.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2026 Sebastien Rousseau
3
4//! HTTP/3 production profile primitives.
5//!
6//! This module defines ALPN routing and fallback policy helpers so deployments
7//! can enforce deterministic protocol behavior when HTTP/3 is enabled.
8
9/// Effective protocol route selected after ALPN negotiation.
10/// # Examples
11///
12/// ```rust
13/// use http_handle::http3_profile::ProtocolRoute;
14/// assert_eq!(ProtocolRoute::Http2.to_string(), "h2");
15/// ```
16///
17/// # Panics
18///
19/// This type does not panic.
20#[cfg(feature = "http3-profile")]
21#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ProtocolRoute {
24    /// Use HTTP/3 over QUIC.
25    Http3,
26    /// Fallback to HTTP/2.
27    Http2,
28    /// Fallback to HTTP/1.1.
29    Http11,
30}
31
32/// Runtime QUIC tuning preset.
33/// # Examples
34///
35/// ```rust
36/// use http_handle::http3_profile::QuicTuningPreset;
37/// assert!(matches!(QuicTuningPreset::Balanced, QuicTuningPreset::Balanced));
38/// ```
39///
40/// # Panics
41///
42/// This type does not panic.
43#[cfg(feature = "http3-profile")]
44#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum QuicTuningPreset {
47    /// Lower resource use and conservative timeouts.
48    Conservative,
49    /// Balanced defaults for general production use.
50    Balanced,
51    /// Throughput-biased tuning for high-capacity edge deployments.
52    Aggressive,
53}
54
55/// Derived QUIC runtime tuning values.
56/// # Examples
57///
58/// ```rust
59/// use http_handle::http3_profile::QuicTuning;
60/// let t = QuicTuning { idle_timeout_ms: 1, keep_alive_interval_ms: 1, max_bidi_streams: 1, datagram_receive_buffer_bytes: 1 };
61/// assert_eq!(t.max_bidi_streams, 1);
62/// ```
63///
64/// # Panics
65///
66/// This type does not panic.
67#[cfg(feature = "http3-profile")]
68#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct QuicTuning {
71    /// QUIC idle timeout in milliseconds.
72    pub idle_timeout_ms: u64,
73    /// Keep-alive probe interval in milliseconds.
74    pub keep_alive_interval_ms: u64,
75    /// Max concurrent bidirectional streams.
76    pub max_bidi_streams: u64,
77    /// Datagram receive buffer target in bytes.
78    pub datagram_receive_buffer_bytes: usize,
79}
80
81/// Reason describing how route selection was resolved.
82/// # Examples
83///
84/// ```rust
85/// use http_handle::http3_profile::RouteReason;
86/// assert_eq!(RouteReason::Negotiated.to_string(), "negotiated");
87/// ```
88///
89/// # Panics
90///
91/// This type does not panic.
92#[cfg(feature = "http3-profile")]
93#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum RouteReason {
96    /// Standard negotiated protocol route.
97    Negotiated,
98    /// HTTP/3 profile disabled.
99    H3Disabled,
100    /// ALPN missing during negotiation.
101    AlpnMissing,
102    /// ALPN provided but not recognized.
103    AlpnUnsupported,
104    /// H3 handshake failed and fallback was applied.
105    H3HandshakeFailedFallback,
106    /// H3 handshake failed and fallback is disabled.
107    H3HandshakeFailedNoFallback,
108}
109
110/// Decision output for ALPN+fallback route resolution.
111/// # Examples
112///
113/// ```rust
114/// use http_handle::http3_profile::{ProtocolRoute, RouteDecision, RouteReason};
115/// let d = RouteDecision { selected: ProtocolRoute::Http11, reason: RouteReason::AlpnMissing, negotiated_alpn: None, fallback_chain: vec![ProtocolRoute::Http11] };
116/// assert_eq!(d.selected, ProtocolRoute::Http11);
117/// ```
118///
119/// # Panics
120///
121/// This type does not panic.
122#[cfg(feature = "http3-profile")]
123#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct RouteDecision {
126    /// Final selected route.
127    pub selected: ProtocolRoute,
128    /// Resolution reason.
129    pub reason: RouteReason,
130    /// Raw negotiated ALPN token if present.
131    pub negotiated_alpn: Option<String>,
132    /// Ordered chain considered for fallback.
133    pub fallback_chain: Vec<ProtocolRoute>,
134}
135
136/// Production-focused HTTP/3 configuration profile.
137/// # Examples
138///
139/// ```rust
140/// use http_handle::http3_profile::Http3ProductionProfile;
141/// let p = Http3ProductionProfile::default();
142/// assert!(p.enabled);
143/// ```
144///
145/// # Panics
146///
147/// This type does not panic.
148#[cfg(feature = "http3-profile")]
149#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct Http3ProductionProfile {
152    /// Whether HTTP/3 is enabled.
153    pub enabled: bool,
154    /// Ordered ALPN preference, e.g. `["h3", "h2", "http/1.1"]`.
155    pub alpn_order: Vec<String>,
156    /// QUIC idle timeout in milliseconds.
157    pub quic_idle_timeout_ms: u64,
158    /// QUIC tuning preset.
159    pub quic_preset: QuicTuningPreset,
160    /// Accept draft h3 tokens (for example `h3-29`) as h3 route.
161    pub allow_h3_draft: bool,
162    /// Whether failed HTTP/3 handshakes should fallback to H2/H1.
163    pub fallback_on_h3_error: bool,
164}
165
166#[cfg(feature = "http3-profile")]
167#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
168impl Default for Http3ProductionProfile {
169    fn default() -> Self {
170        Self {
171            enabled: true,
172            alpn_order: vec![
173                "h3".to_string(),
174                "h2".to_string(),
175                "http/1.1".to_string(),
176            ],
177            quic_idle_timeout_ms: 30_000,
178            quic_preset: QuicTuningPreset::Balanced,
179            allow_h3_draft: true,
180            fallback_on_h3_error: true,
181        }
182    }
183}
184
185#[cfg(feature = "http3-profile")]
186#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
187impl Http3ProductionProfile {
188    /// Returns a strict production baseline with h3-first ALPN ordering.
189    ///
190    /// # Examples
191    ///
192    /// ```rust
193    /// use http_handle::http3_profile::Http3ProductionProfile;
194    /// let p = Http3ProductionProfile::production_baseline();
195    /// assert!(p.enabled);
196    /// ```
197    ///
198    /// # Panics
199    ///
200    /// This function does not panic.
201    pub fn production_baseline() -> Self {
202        Self::default()
203    }
204
205    /// Returns effective QUIC tuning values from preset and profile.
206    ///
207    /// # Examples
208    ///
209    /// ```rust
210    /// use http_handle::http3_profile::Http3ProductionProfile;
211    /// let p = Http3ProductionProfile::default();
212    /// let t = p.quic_tuning();
213    /// assert!(t.idle_timeout_ms > 0);
214    /// ```
215    ///
216    /// # Panics
217    ///
218    /// This function does not panic.
219    pub fn quic_tuning(&self) -> QuicTuning {
220        match self.quic_preset {
221            QuicTuningPreset::Conservative => QuicTuning {
222                idle_timeout_ms: self.quic_idle_timeout_ms.max(45_000),
223                keep_alive_interval_ms: 15_000,
224                max_bidi_streams: 64,
225                datagram_receive_buffer_bytes: 512 * 1024,
226            },
227            QuicTuningPreset::Balanced => QuicTuning {
228                idle_timeout_ms: self.quic_idle_timeout_ms.max(30_000),
229                keep_alive_interval_ms: 10_000,
230                max_bidi_streams: 128,
231                datagram_receive_buffer_bytes: 1024 * 1024,
232            },
233            QuicTuningPreset::Aggressive => QuicTuning {
234                idle_timeout_ms: self.quic_idle_timeout_ms.max(20_000),
235                keep_alive_interval_ms: 8_000,
236                max_bidi_streams: 256,
237                datagram_receive_buffer_bytes: 2 * 1024 * 1024,
238            },
239        }
240    }
241
242    /// Derives the serving route from negotiated ALPN protocol bytes.
243    ///
244    /// # Examples
245    ///
246    /// ```rust
247    /// use http_handle::http3_profile::{Http3ProductionProfile, ProtocolRoute};
248    /// let p = Http3ProductionProfile::default();
249    /// assert_eq!(p.route_for_alpn(Some(b"h3")), ProtocolRoute::Http3);
250    /// ```
251    ///
252    /// # Panics
253    ///
254    /// This function does not panic.
255    pub fn route_for_alpn(
256        &self,
257        negotiated_alpn: Option<&[u8]>,
258    ) -> ProtocolRoute {
259        if !self.enabled {
260            return ProtocolRoute::Http11;
261        }
262        match negotiated_alpn {
263            Some(b"h3") => ProtocolRoute::Http3,
264            Some(b"h2") => ProtocolRoute::Http2,
265            Some(b"http/1.1") => ProtocolRoute::Http11,
266            Some(raw)
267                if self.allow_h3_draft
268                    && std::str::from_utf8(raw)
269                        .map(|v| v.starts_with("h3-"))
270                        .unwrap_or(false) =>
271            {
272                ProtocolRoute::Http3
273            }
274            _ => ProtocolRoute::Http11,
275        }
276    }
277
278    /// Selects a route from offered client ALPN tokens and server preference.
279    ///
280    /// # Examples
281    ///
282    /// ```rust
283    /// use http_handle::http3_profile::{Http3ProductionProfile, ProtocolRoute};
284    /// let p = Http3ProductionProfile::default();
285    /// let offered = vec![b"h2".to_vec()];
286    /// assert_eq!(p.route_for_client_alpns(&offered), ProtocolRoute::Http2);
287    /// ```
288    ///
289    /// # Panics
290    ///
291    /// This function does not panic.
292    pub fn route_for_client_alpns(
293        &self,
294        client_offered_alpns: &[Vec<u8>],
295    ) -> ProtocolRoute {
296        if !self.enabled {
297            return ProtocolRoute::Http11;
298        }
299        let offered = client_offered_alpns
300            .iter()
301            .map(|v| self.route_for_alpn(Some(v)).to_string())
302            .collect::<Vec<_>>();
303        for preferred in self.fallback_chain() {
304            if offered.iter().any(|v| v == &preferred.to_string()) {
305                return preferred;
306            }
307        }
308        ProtocolRoute::Http11
309    }
310
311    /// Returns ordered protocol fallback chain.
312    ///
313    /// # Examples
314    ///
315    /// ```rust
316    /// use http_handle::http3_profile::Http3ProductionProfile;
317    /// let p = Http3ProductionProfile::default();
318    /// assert!(!p.fallback_chain().is_empty());
319    /// ```
320    ///
321    /// # Panics
322    ///
323    /// This function does not panic.
324    pub fn fallback_chain(&self) -> Vec<ProtocolRoute> {
325        let mut chain = Vec::new();
326        for protocol in &self.alpn_order {
327            let route = match protocol.as_str() {
328                "h3" => ProtocolRoute::Http3,
329                "h2" => ProtocolRoute::Http2,
330                "http/1.1" => ProtocolRoute::Http11,
331                _ => continue,
332            };
333            if !chain.contains(&route) {
334                chain.push(route);
335            }
336        }
337        if chain.is_empty() {
338            chain.push(ProtocolRoute::Http11);
339        }
340        chain
341    }
342
343    /// Resolves final route with explicit fallback decision tree and reason.
344    ///
345    /// # Examples
346    ///
347    /// ```rust
348    /// use http_handle::http3_profile::{Http3ProductionProfile, RouteReason};
349    /// let p = Http3ProductionProfile::default();
350    /// let d = p.resolve_route(Some(b"h3"), false);
351    /// assert!(matches!(d.reason, RouteReason::H3HandshakeFailedFallback));
352    /// ```
353    ///
354    /// # Panics
355    ///
356    /// This function does not panic.
357    pub fn resolve_route(
358        &self,
359        negotiated_alpn: Option<&[u8]>,
360        h3_handshake_ok: bool,
361    ) -> RouteDecision {
362        let chain = self.fallback_chain();
363        let negotiated = negotiated_alpn
364            .map(|v| String::from_utf8_lossy(v).to_string());
365        let mut selected = self.route_for_alpn(negotiated_alpn);
366        let mut reason = if !self.enabled {
367            RouteReason::H3Disabled
368        } else {
369            match negotiated_alpn {
370                None => RouteReason::AlpnMissing,
371                Some(b"h3") | Some(b"h2") | Some(b"http/1.1") => {
372                    RouteReason::Negotiated
373                }
374                Some(raw)
375                    if self.allow_h3_draft
376                        && std::str::from_utf8(raw)
377                            .map(|v| v.starts_with("h3-"))
378                            .unwrap_or(false) =>
379                {
380                    RouteReason::Negotiated
381                }
382                Some(_) => RouteReason::AlpnUnsupported,
383            }
384        };
385
386        if selected == ProtocolRoute::Http3 && !h3_handshake_ok {
387            if self.fallback_on_h3_error {
388                selected = chain
389                    .iter()
390                    .copied()
391                    .find(|r| *r != ProtocolRoute::Http3)
392                    .unwrap_or(ProtocolRoute::Http11);
393                reason = RouteReason::H3HandshakeFailedFallback;
394            } else {
395                selected = ProtocolRoute::Http11;
396                reason = RouteReason::H3HandshakeFailedNoFallback;
397            }
398        }
399
400        RouteDecision {
401            selected,
402            reason,
403            negotiated_alpn: negotiated,
404            fallback_chain: chain,
405        }
406    }
407
408    /// Serializes a compact fallback telemetry line for logs.
409    ///
410    /// # Examples
411    ///
412    /// ```rust
413    /// use http_handle::http3_profile::{Http3ProductionProfile, RouteDecision, RouteReason, ProtocolRoute};
414    /// let p = Http3ProductionProfile::default();
415    /// let d = RouteDecision { selected: ProtocolRoute::Http2, reason: RouteReason::Negotiated, negotiated_alpn: Some("h2".into()), fallback_chain: vec![ProtocolRoute::Http3, ProtocolRoute::Http2] };
416    /// let line = p.telemetry_line(&d);
417    /// assert!(line.contains("http3.route"));
418    /// ```
419    ///
420    /// # Panics
421    ///
422    /// This function does not panic.
423    pub fn telemetry_line(&self, decision: &RouteDecision) -> String {
424        format!(
425            "http3.route={} reason={} negotiated={} chain={}",
426            decision.selected,
427            decision.reason,
428            decision.negotiated_alpn.as_deref().unwrap_or("none"),
429            decision
430                .fallback_chain
431                .iter()
432                .map(ToString::to_string)
433                .collect::<Vec<_>>()
434                .join(">")
435        )
436    }
437}
438
439#[cfg(feature = "http3-profile")]
440#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
441impl std::fmt::Display for ProtocolRoute {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        let s = match self {
444            ProtocolRoute::Http3 => "h3",
445            ProtocolRoute::Http2 => "h2",
446            ProtocolRoute::Http11 => "http/1.1",
447        };
448        write!(f, "{s}")
449    }
450}
451
452#[cfg(feature = "http3-profile")]
453#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
454impl std::fmt::Display for RouteReason {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        let s = match self {
457            RouteReason::Negotiated => "negotiated",
458            RouteReason::H3Disabled => "h3_disabled",
459            RouteReason::AlpnMissing => "alpn_missing",
460            RouteReason::AlpnUnsupported => "alpn_unsupported",
461            RouteReason::H3HandshakeFailedFallback => {
462                "h3_handshake_failed_fallback"
463            }
464            RouteReason::H3HandshakeFailedNoFallback => {
465                "h3_handshake_failed_no_fallback"
466            }
467        };
468        write!(f, "{s}")
469    }
470}
471
472#[cfg(all(test, feature = "http3-profile"))]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn production_baseline_prefers_h3() {
478        let p = Http3ProductionProfile::production_baseline();
479        assert!(p.enabled);
480        assert_eq!(p.alpn_order[0], "h3");
481        assert!(p.fallback_on_h3_error);
482    }
483
484    #[test]
485    fn route_for_alpn_handles_known_protocols() {
486        let p = Http3ProductionProfile::default();
487        assert_eq!(p.route_for_alpn(Some(b"h3")), ProtocolRoute::Http3);
488        assert_eq!(p.route_for_alpn(Some(b"h2")), ProtocolRoute::Http2);
489        assert_eq!(
490            p.route_for_alpn(Some(b"http/1.1")),
491            ProtocolRoute::Http11
492        );
493        assert_eq!(
494            p.route_for_alpn(Some(b"h3-29")),
495            ProtocolRoute::Http3
496        );
497        assert_eq!(p.route_for_alpn(None), ProtocolRoute::Http11);
498    }
499
500    #[test]
501    fn fallback_chain_is_unique_and_ordered() {
502        let p = Http3ProductionProfile {
503            alpn_order: vec![
504                "h3".into(),
505                "h2".into(),
506                "h2".into(),
507                "http/1.1".into(),
508            ],
509            ..Http3ProductionProfile::default()
510        };
511        assert_eq!(
512            p.fallback_chain(),
513            vec![
514                ProtocolRoute::Http3,
515                ProtocolRoute::Http2,
516                ProtocolRoute::Http11
517            ]
518        );
519    }
520
521    #[test]
522    fn route_for_client_alpns_respects_server_order() {
523        let p = Http3ProductionProfile {
524            alpn_order: vec![
525                "h2".into(),
526                "h3".into(),
527                "http/1.1".into(),
528            ],
529            ..Http3ProductionProfile::default()
530        };
531        let client = vec![b"h3".to_vec(), b"h2".to_vec()];
532        assert_eq!(
533            p.route_for_client_alpns(&client),
534            ProtocolRoute::Http2
535        );
536    }
537
538    #[test]
539    fn resolve_route_falls_back_on_h3_handshake_failure() {
540        let p = Http3ProductionProfile::default();
541        let decision = p.resolve_route(Some(b"h3"), false);
542        assert_eq!(decision.selected, ProtocolRoute::Http2);
543        assert_eq!(
544            decision.reason,
545            RouteReason::H3HandshakeFailedFallback
546        );
547    }
548
549    #[test]
550    fn resolve_route_handles_no_fallback_mode() {
551        let p = Http3ProductionProfile {
552            fallback_on_h3_error: false,
553            ..Http3ProductionProfile::default()
554        };
555        let decision = p.resolve_route(Some(b"h3"), false);
556        assert_eq!(decision.selected, ProtocolRoute::Http11);
557        assert_eq!(
558            decision.reason,
559            RouteReason::H3HandshakeFailedNoFallback
560        );
561    }
562
563    #[test]
564    fn quic_preset_changes_tuning_envelope() {
565        let conservative = Http3ProductionProfile {
566            quic_preset: QuicTuningPreset::Conservative,
567            ..Http3ProductionProfile::default()
568        }
569        .quic_tuning();
570        let aggressive = Http3ProductionProfile {
571            quic_preset: QuicTuningPreset::Aggressive,
572            ..Http3ProductionProfile::default()
573        }
574        .quic_tuning();
575        assert!(
576            aggressive.max_bidi_streams > conservative.max_bidi_streams
577        );
578        assert!(
579            aggressive.datagram_receive_buffer_bytes
580                > conservative.datagram_receive_buffer_bytes
581        );
582    }
583
584    #[test]
585    fn telemetry_line_contains_decision_fields() {
586        let p = Http3ProductionProfile::default();
587        let decision = p.resolve_route(Some(b"h2"), true);
588        let line = p.telemetry_line(&decision);
589        assert!(line.contains("http3.route=h2"));
590        assert!(line.contains("reason=negotiated"));
591        assert!(line.contains("chain=h3>h2>http/1.1"));
592    }
593}