1use std::sync::Arc;
7use std::time::Instant;
8
9use zentinel_config::{BodyStreamingMode, Config, RouteConfig, ServiceType};
10
11use crate::inference::StreamingTokenCounter;
12use crate::websocket::WebSocketHandler;
13
14#[derive(Debug, Clone)]
16pub enum FallbackReason {
17 HealthCheckFailed,
19 BudgetExhausted,
21 LatencyThreshold { observed_ms: u64, threshold_ms: u64 },
23 ErrorCode(u16),
25 ConnectionError(String),
27}
28
29impl std::fmt::Display for FallbackReason {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 FallbackReason::HealthCheckFailed => write!(f, "health_check_failed"),
33 FallbackReason::BudgetExhausted => write!(f, "budget_exhausted"),
34 FallbackReason::LatencyThreshold {
35 observed_ms,
36 threshold_ms,
37 } => write!(
38 f,
39 "latency_threshold_{}ms_exceeded_{}ms",
40 observed_ms, threshold_ms
41 ),
42 FallbackReason::ErrorCode(code) => write!(f, "error_code_{}", code),
43 FallbackReason::ConnectionError(msg) => write!(f, "connection_error_{}", msg),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub(crate) enum CacheStatus {
51 HitMemory,
53 HitDisk,
55 Hit,
57 HitStale,
59 Miss,
61 Bypass(&'static str),
63}
64
65#[derive(Debug, Clone)]
67pub struct RateLimitHeaderInfo {
68 pub limit: u32,
70 pub remaining: u32,
72 pub reset_at: u64,
74}
75
76pub struct RequestContext {
82 start_time: Instant,
84
85 pub(crate) trace_id: String,
88
89 pub(crate) config: Option<Arc<Config>>,
92
93 pub(crate) route_id: Option<String>,
96 pub(crate) route_config: Option<Arc<RouteConfig>>,
98 pub(crate) upstream: Option<String>,
100 pub(crate) selected_upstream_address: Option<String>,
102 pub(crate) upstream_attempts: u32,
104 pub(crate) request_attempts: u32,
110
111 pub(crate) namespace: Option<String>,
114 pub(crate) service: Option<String>,
116
117 pub(crate) method: String,
120 pub(crate) path: String,
122 pub(crate) query: Option<String>,
124
125 pub(crate) client_ip: String,
128 pub(crate) user_agent: Option<String>,
130 pub(crate) referer: Option<String>,
132 pub(crate) host: Option<String>,
134
135 pub(crate) request_body_bytes: u64,
138 pub(crate) response_bytes: u64,
140
141 pub(crate) connection_reused: bool,
144 pub(crate) is_websocket_upgrade: bool,
146
147 pub(crate) websocket_inspection_enabled: bool,
150 pub(crate) websocket_skip_inspection: bool,
152 pub(crate) websocket_inspection_agents: Vec<String>,
154 pub(crate) websocket_handler: Option<Arc<WebSocketHandler>>,
156
157 pub(crate) cache_eligible: bool,
160 pub(crate) cache_status: Option<CacheStatus>,
162
163 pub(crate) body_inspection_enabled: bool,
166 pub(crate) body_bytes_inspected: u64,
168 pub(crate) body_buffer: Vec<u8>,
170 pub(crate) body_inspection_agents: Vec<String>,
172
173 pub(crate) agentic_body: Vec<u8>,
181 pub(crate) agentic_body_oversize: bool,
184 pub(crate) mcp_method: Option<String>,
188 pub(crate) mcp_target: Option<String>,
190 pub(crate) a2a_method: Option<String>,
192
193 pub(crate) decompression_enabled: bool,
196 pub(crate) body_content_encoding: Option<String>,
198 pub(crate) max_decompression_ratio: f64,
200 pub(crate) max_decompression_bytes: usize,
202 pub(crate) body_was_decompressed: bool,
204
205 pub(crate) rate_limit_info: Option<RateLimitHeaderInfo>,
208
209 pub(crate) geo_country_code: Option<String>,
212 pub(crate) geo_lookup_performed: bool,
214
215 pub(crate) request_body_streaming_mode: BodyStreamingMode,
218 pub(crate) request_body_chunk_index: u32,
220 pub(crate) agent_needs_more: bool,
222 pub(crate) response_body_streaming_mode: BodyStreamingMode,
224 pub(crate) response_body_chunk_index: u32,
226 pub(crate) response_body_bytes_inspected: u64,
228 pub(crate) response_body_inspection_enabled: bool,
230 pub(crate) response_body_inspection_agents: Vec<String>,
232
233 pub(crate) otel_span: Option<crate::otel::RequestSpan>,
236 pub(crate) trace_context: Option<crate::otel::TraceContext>,
238
239 pub(crate) inference_rate_limit_enabled: bool,
242 pub(crate) inference_estimated_tokens: u64,
244 pub(crate) inference_rate_limit_key: Option<String>,
246 pub(crate) inference_model: Option<String>,
248 pub(crate) inference_provider_override: Option<zentinel_config::InferenceProvider>,
250 pub(crate) model_routing_used: bool,
252 pub(crate) inference_actual_tokens: Option<u64>,
254
255 pub(crate) inference_budget_enabled: bool,
258 pub(crate) inference_budget_remaining: Option<i64>,
260 pub(crate) inference_budget_period_reset: Option<u64>,
262 pub(crate) inference_budget_exhausted: bool,
264
265 pub(crate) inference_cost_enabled: bool,
268 pub(crate) inference_request_cost: Option<f64>,
270 pub(crate) inference_input_tokens: u64,
272 pub(crate) inference_output_tokens: u64,
274
275 pub(crate) inference_streaming_response: bool,
278 pub(crate) inference_streaming_counter: Option<StreamingTokenCounter>,
280
281 pub(crate) fallback_attempt: u32,
284 pub(crate) tried_upstreams: Vec<String>,
286 pub(crate) fallback_reason: Option<FallbackReason>,
288 pub(crate) original_upstream: Option<String>,
290 pub(crate) model_mapping_applied: Option<(String, String)>,
292 pub(crate) should_retry_with_fallback: bool,
294
295 pub(crate) guardrails_enabled: bool,
298 pub(crate) guardrail_warning: bool,
300 pub(crate) guardrail_detection_categories: Vec<String>,
302 pub(crate) pii_detection_categories: Vec<String>,
304
305 pub(crate) shadow_pending: Option<ShadowPendingRequest>,
308 pub(crate) shadow_sent: bool,
310
311 pub(crate) sticky_session_new_assignment: bool,
314 pub(crate) sticky_session_set_cookie: Option<String>,
316 pub(crate) sticky_target_index: Option<usize>,
318
319 pub(crate) listener_keepalive_timeout_secs: Option<u64>,
322
323 pub(crate) filter_connect_timeout_secs: Option<u64>,
326 pub(crate) filter_upstream_timeout_secs: Option<u64>,
328 pub(crate) cors_origin: Option<String>,
330 pub(crate) compress_enabled: bool,
332
333 pub(crate) route_agent_ids: Vec<String>,
336 pub(crate) response_agent_processing_enabled: bool,
338 pub(crate) response_agent_body_buffer: Vec<u8>,
340 pub(crate) response_agent_body_complete: bool,
342}
343
344#[derive(Clone)]
346pub struct ShadowPendingRequest {
347 pub headers: pingora::http::RequestHeader,
349 pub manager: std::sync::Arc<crate::shadow::ShadowManager>,
351 pub request_ctx: crate::upstream::RequestContext,
353 pub include_body: bool,
355}
356
357impl RequestContext {
358 pub fn new() -> Self {
360 Self {
361 start_time: Instant::now(),
362 trace_id: String::new(),
363 config: None,
364 route_id: None,
365 route_config: None,
366 upstream: None,
367 selected_upstream_address: None,
368 upstream_attempts: 0,
369 request_attempts: 0,
370 namespace: None,
371 service: None,
372 method: String::new(),
373 path: String::new(),
374 query: None,
375 client_ip: String::new(),
376 user_agent: None,
377 referer: None,
378 host: None,
379 request_body_bytes: 0,
380 response_bytes: 0,
381 connection_reused: false,
382 is_websocket_upgrade: false,
383 websocket_inspection_enabled: false,
384 websocket_skip_inspection: false,
385 websocket_inspection_agents: Vec::new(),
386 websocket_handler: None,
387 cache_eligible: false,
388 cache_status: None,
389 body_inspection_enabled: false,
390 body_bytes_inspected: 0,
391 body_buffer: Vec::new(),
392 agentic_body: Vec::new(),
393 agentic_body_oversize: false,
394 mcp_method: None,
395 mcp_target: None,
396 a2a_method: None,
397 body_inspection_agents: Vec::new(),
398 decompression_enabled: false,
399 body_content_encoding: None,
400 max_decompression_ratio: 100.0,
401 max_decompression_bytes: 10 * 1024 * 1024, body_was_decompressed: false,
403 rate_limit_info: None,
404 geo_country_code: None,
405 geo_lookup_performed: false,
406 request_body_streaming_mode: BodyStreamingMode::Buffer,
407 request_body_chunk_index: 0,
408 agent_needs_more: false,
409 response_body_streaming_mode: BodyStreamingMode::Buffer,
410 response_body_chunk_index: 0,
411 response_body_bytes_inspected: 0,
412 response_body_inspection_enabled: false,
413 response_body_inspection_agents: Vec::new(),
414 otel_span: None,
415 trace_context: None,
416 inference_rate_limit_enabled: false,
417 inference_estimated_tokens: 0,
418 inference_rate_limit_key: None,
419 inference_model: None,
420 inference_provider_override: None,
421 model_routing_used: false,
422 inference_actual_tokens: None,
423 inference_budget_enabled: false,
424 inference_budget_remaining: None,
425 inference_budget_period_reset: None,
426 inference_budget_exhausted: false,
427 inference_cost_enabled: false,
428 inference_request_cost: None,
429 inference_input_tokens: 0,
430 inference_output_tokens: 0,
431 inference_streaming_response: false,
432 inference_streaming_counter: None,
433 fallback_attempt: 0,
434 tried_upstreams: Vec::new(),
435 fallback_reason: None,
436 original_upstream: None,
437 model_mapping_applied: None,
438 should_retry_with_fallback: false,
439 guardrails_enabled: false,
440 guardrail_warning: false,
441 guardrail_detection_categories: Vec::new(),
442 pii_detection_categories: Vec::new(),
443 shadow_pending: None,
444 shadow_sent: false,
445 sticky_session_new_assignment: false,
446 sticky_session_set_cookie: None,
447 sticky_target_index: None,
448 listener_keepalive_timeout_secs: None,
449 filter_connect_timeout_secs: None,
450 filter_upstream_timeout_secs: None,
451 cors_origin: None,
452 compress_enabled: false,
453 route_agent_ids: Vec::new(),
454 response_agent_processing_enabled: false,
455 response_agent_body_buffer: Vec::new(),
456 response_agent_body_complete: false,
457 }
458 }
459
460 #[inline]
464 pub fn start_time(&self) -> Instant {
465 self.start_time
466 }
467
468 #[inline]
470 pub fn elapsed(&self) -> std::time::Duration {
471 self.start_time.elapsed()
472 }
473
474 #[inline]
478 pub fn correlation_id(&self) -> &str {
479 &self.trace_id
480 }
481
482 #[inline]
484 pub fn trace_id(&self) -> &str {
485 &self.trace_id
486 }
487
488 #[inline]
490 pub fn route_id(&self) -> Option<&str> {
491 self.route_id.as_deref()
492 }
493
494 #[inline]
496 pub fn upstream(&self) -> Option<&str> {
497 self.upstream.as_deref()
498 }
499
500 #[inline]
502 pub fn selected_upstream_address(&self) -> Option<&str> {
503 self.selected_upstream_address.as_deref()
504 }
505
506 #[inline]
508 pub fn route_config(&self) -> Option<&Arc<RouteConfig>> {
509 self.route_config.as_ref()
510 }
511
512 #[inline]
514 pub fn global_config(&self) -> Option<&Arc<Config>> {
515 self.config.as_ref()
516 }
517
518 #[inline]
520 pub fn service_type(&self) -> Option<ServiceType> {
521 self.route_config.as_ref().map(|c| c.service_type.clone())
522 }
523
524 #[inline]
526 pub fn request_attempts(&self) -> u32 {
528 self.request_attempts
529 }
530
531 pub fn upstream_attempts(&self) -> u32 {
532 self.upstream_attempts
533 }
534
535 #[inline]
537 pub fn method(&self) -> &str {
538 &self.method
539 }
540
541 #[inline]
543 pub fn path(&self) -> &str {
544 &self.path
545 }
546
547 #[inline]
549 pub fn query(&self) -> Option<&str> {
550 self.query.as_deref()
551 }
552
553 #[inline]
555 pub fn client_ip(&self) -> &str {
556 &self.client_ip
557 }
558
559 #[inline]
561 pub fn user_agent(&self) -> Option<&str> {
562 self.user_agent.as_deref()
563 }
564
565 #[inline]
567 pub fn referer(&self) -> Option<&str> {
568 self.referer.as_deref()
569 }
570
571 #[inline]
573 pub fn host(&self) -> Option<&str> {
574 self.host.as_deref()
575 }
576
577 #[inline]
579 pub fn response_bytes(&self) -> u64 {
580 self.response_bytes
581 }
582
583 #[inline]
585 pub fn geo_country_code(&self) -> Option<&str> {
586 self.geo_country_code.as_deref()
587 }
588
589 #[inline]
591 pub fn geo_lookup_performed(&self) -> bool {
592 self.geo_lookup_performed
593 }
594
595 #[inline]
600 pub fn traceparent(&self) -> Option<String> {
601 self.otel_span.as_ref().map(|span| {
602 let sampled = self
603 .trace_context
604 .as_ref()
605 .map(|c| c.sampled)
606 .unwrap_or(true);
607 crate::otel::create_traceparent(&span.trace_id, &span.span_id, sampled)
608 })
609 }
610
611 #[inline]
615 pub fn set_trace_id(&mut self, trace_id: impl Into<String>) {
616 self.trace_id = trace_id.into();
617 }
618
619 #[inline]
621 pub fn set_route_id(&mut self, route_id: impl Into<String>) {
622 self.route_id = Some(route_id.into());
623 }
624
625 #[inline]
627 pub fn set_upstream(&mut self, upstream: impl Into<String>) {
628 self.upstream = Some(upstream.into());
629 }
630
631 #[inline]
633 pub fn set_selected_upstream_address(&mut self, address: impl Into<String>) {
634 self.selected_upstream_address = Some(address.into());
635 }
636
637 #[inline]
639 pub fn inc_upstream_attempts(&mut self) {
640 self.upstream_attempts += 1;
641 }
642
643 #[inline]
645 pub fn set_response_bytes(&mut self, bytes: u64) {
646 self.response_bytes = bytes;
647 }
648
649 #[inline]
653 pub fn fallback_attempt(&self) -> u32 {
654 self.fallback_attempt
655 }
656
657 #[inline]
659 pub fn tried_upstreams(&self) -> &[String] {
660 &self.tried_upstreams
661 }
662
663 #[inline]
665 pub fn fallback_reason(&self) -> Option<&FallbackReason> {
666 self.fallback_reason.as_ref()
667 }
668
669 #[inline]
671 pub fn original_upstream(&self) -> Option<&str> {
672 self.original_upstream.as_deref()
673 }
674
675 #[inline]
677 pub fn model_mapping_applied(&self) -> Option<&(String, String)> {
678 self.model_mapping_applied.as_ref()
679 }
680
681 #[inline]
683 pub fn used_fallback(&self) -> bool {
684 self.fallback_attempt > 0
685 }
686
687 #[inline]
689 pub fn record_fallback(&mut self, reason: FallbackReason, new_upstream: &str) {
690 if self.fallback_attempt == 0 {
691 self.original_upstream = self.upstream.clone();
693 }
694 self.fallback_attempt += 1;
695 self.fallback_reason = Some(reason);
696 if let Some(current) = &self.upstream {
697 self.tried_upstreams.push(current.clone());
698 }
699 self.upstream = Some(new_upstream.to_string());
700 }
701
702 #[inline]
704 pub fn record_model_mapping(&mut self, original: String, mapped: String) {
705 self.model_mapping_applied = Some((original, mapped));
706 }
707
708 #[inline]
712 pub fn used_model_routing(&self) -> bool {
713 self.model_routing_used
714 }
715
716 #[inline]
718 pub fn inference_provider_override(&self) -> Option<zentinel_config::InferenceProvider> {
719 self.inference_provider_override
720 }
721
722 #[inline]
726 pub fn record_model_routing(
727 &mut self,
728 upstream: &str,
729 model: Option<String>,
730 provider_override: Option<zentinel_config::InferenceProvider>,
731 ) {
732 self.upstream = Some(upstream.to_string());
733 self.model_routing_used = true;
734 if model.is_some() {
735 self.inference_model = model;
736 }
737 self.inference_provider_override = provider_override;
738 }
739}
740
741impl Default for RequestContext {
742 fn default() -> Self {
743 Self::new()
744 }
745}
746
747#[cfg(test)]
752mod tests {
753 use super::*;
754
755 #[test]
756 fn test_rate_limit_header_info() {
757 let info = RateLimitHeaderInfo {
758 limit: 100,
759 remaining: 42,
760 reset_at: 1704067200,
761 };
762
763 assert_eq!(info.limit, 100);
764 assert_eq!(info.remaining, 42);
765 assert_eq!(info.reset_at, 1704067200);
766 }
767
768 #[test]
769 fn test_request_context_default() {
770 let ctx = RequestContext::new();
771
772 assert!(ctx.trace_id.is_empty());
773 assert!(ctx.rate_limit_info.is_none());
774 assert!(ctx.route_id.is_none());
775 assert!(ctx.config.is_none());
776 }
777
778 #[test]
779 fn test_request_context_rate_limit_info() {
780 let mut ctx = RequestContext::new();
781
782 assert!(ctx.rate_limit_info.is_none());
784
785 ctx.rate_limit_info = Some(RateLimitHeaderInfo {
787 limit: 50,
788 remaining: 25,
789 reset_at: 1704067300,
790 });
791
792 assert!(ctx.rate_limit_info.is_some());
793 let info = ctx.rate_limit_info.as_ref().unwrap();
794 assert_eq!(info.limit, 50);
795 assert_eq!(info.remaining, 25);
796 assert_eq!(info.reset_at, 1704067300);
797 }
798
799 #[test]
800 fn test_request_context_elapsed() {
801 let ctx = RequestContext::new();
802
803 let elapsed = ctx.elapsed();
805 assert!(elapsed.as_secs() < 1);
806 }
807
808 #[test]
809 fn test_request_context_setters() {
810 let mut ctx = RequestContext::new();
811
812 ctx.set_trace_id("trace-123");
813 assert_eq!(ctx.trace_id(), "trace-123");
814 assert_eq!(ctx.correlation_id(), "trace-123");
815
816 ctx.set_route_id("my-route");
817 assert_eq!(ctx.route_id(), Some("my-route"));
818
819 ctx.set_upstream("backend-pool");
820 assert_eq!(ctx.upstream(), Some("backend-pool"));
821
822 ctx.inc_upstream_attempts();
823 ctx.inc_upstream_attempts();
824 assert_eq!(ctx.upstream_attempts(), 2);
825
826 ctx.set_response_bytes(1024);
827 assert_eq!(ctx.response_bytes(), 1024);
828 }
829
830 #[test]
831 fn test_fallback_tracking() {
832 let mut ctx = RequestContext::new();
833
834 assert_eq!(ctx.fallback_attempt(), 0);
836 assert!(!ctx.used_fallback());
837 assert!(ctx.tried_upstreams().is_empty());
838 assert!(ctx.fallback_reason().is_none());
839 assert!(ctx.original_upstream().is_none());
840
841 ctx.set_upstream("openai-primary");
843
844 ctx.record_fallback(FallbackReason::HealthCheckFailed, "anthropic-fallback");
846
847 assert_eq!(ctx.fallback_attempt(), 1);
848 assert!(ctx.used_fallback());
849 assert_eq!(ctx.tried_upstreams(), &["openai-primary".to_string()]);
850 assert!(matches!(
851 ctx.fallback_reason(),
852 Some(FallbackReason::HealthCheckFailed)
853 ));
854 assert_eq!(ctx.original_upstream(), Some("openai-primary"));
855 assert_eq!(ctx.upstream(), Some("anthropic-fallback"));
856
857 ctx.record_fallback(FallbackReason::ErrorCode(503), "local-gpu");
859
860 assert_eq!(ctx.fallback_attempt(), 2);
861 assert_eq!(
862 ctx.tried_upstreams(),
863 &[
864 "openai-primary".to_string(),
865 "anthropic-fallback".to_string()
866 ]
867 );
868 assert!(matches!(
869 ctx.fallback_reason(),
870 Some(FallbackReason::ErrorCode(503))
871 ));
872 assert_eq!(ctx.original_upstream(), Some("openai-primary"));
874 assert_eq!(ctx.upstream(), Some("local-gpu"));
875 }
876
877 #[test]
878 fn test_model_mapping_tracking() {
879 let mut ctx = RequestContext::new();
880
881 assert!(ctx.model_mapping_applied().is_none());
882
883 ctx.record_model_mapping("gpt-4".to_string(), "claude-3-opus".to_string());
884
885 let mapping = ctx.model_mapping_applied().unwrap();
886 assert_eq!(mapping.0, "gpt-4");
887 assert_eq!(mapping.1, "claude-3-opus");
888 }
889
890 #[test]
891 fn test_fallback_reason_display() {
892 assert_eq!(
893 FallbackReason::HealthCheckFailed.to_string(),
894 "health_check_failed"
895 );
896 assert_eq!(
897 FallbackReason::BudgetExhausted.to_string(),
898 "budget_exhausted"
899 );
900 assert_eq!(
901 FallbackReason::LatencyThreshold {
902 observed_ms: 5500,
903 threshold_ms: 5000
904 }
905 .to_string(),
906 "latency_threshold_5500ms_exceeded_5000ms"
907 );
908 assert_eq!(FallbackReason::ErrorCode(502).to_string(), "error_code_502");
909 assert_eq!(
910 FallbackReason::ConnectionError("timeout".to_string()).to_string(),
911 "connection_error_timeout"
912 );
913 }
914}