1#[cfg(feature = "http3-profile")]
21#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ProtocolRoute {
24 Http3,
26 Http2,
28 Http11,
30}
31
32#[cfg(feature = "http3-profile")]
44#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum QuicTuningPreset {
47 Conservative,
49 Balanced,
51 Aggressive,
53}
54
55#[cfg(feature = "http3-profile")]
68#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct QuicTuning {
71 pub idle_timeout_ms: u64,
73 pub keep_alive_interval_ms: u64,
75 pub max_bidi_streams: u64,
77 pub datagram_receive_buffer_bytes: usize,
79}
80
81#[cfg(feature = "http3-profile")]
93#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum RouteReason {
96 Negotiated,
98 H3Disabled,
100 AlpnMissing,
102 AlpnUnsupported,
104 H3HandshakeFailedFallback,
106 H3HandshakeFailedNoFallback,
108}
109
110#[cfg(feature = "http3-profile")]
123#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct RouteDecision {
126 pub selected: ProtocolRoute,
128 pub reason: RouteReason,
130 pub negotiated_alpn: Option<String>,
132 pub fallback_chain: Vec<ProtocolRoute>,
134}
135
136#[cfg(feature = "http3-profile")]
149#[cfg_attr(docsrs, doc(cfg(feature = "http3-profile")))]
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct Http3ProductionProfile {
152 pub enabled: bool,
154 pub alpn_order: Vec<String>,
156 pub quic_idle_timeout_ms: u64,
158 pub quic_preset: QuicTuningPreset,
160 pub allow_h3_draft: bool,
162 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 pub fn production_baseline() -> Self {
202 Self::default()
203 }
204
205 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 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 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 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 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 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}