Skip to main content

zentinel_proxy/proxy/
context.rs

1//! Request context for the proxy request lifecycle.
2//!
3//! The `RequestContext` struct maintains state throughout a single request,
4//! including timing, routing decisions, and metadata for logging.
5
6use 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/// Reason why fallback routing was triggered
15#[derive(Debug, Clone)]
16pub enum FallbackReason {
17    /// Primary upstream health check failed
18    HealthCheckFailed,
19    /// Token budget exhausted for the request
20    BudgetExhausted,
21    /// Response latency exceeded threshold
22    LatencyThreshold { observed_ms: u64, threshold_ms: u64 },
23    /// Upstream returned an error code that triggers fallback
24    ErrorCode(u16),
25    /// Connection to upstream failed
26    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/// Cache status for the Cache-Status response header (RFC 9211)
49#[derive(Debug, Clone)]
50pub(crate) enum CacheStatus {
51    /// Cache hit from memory tier (hybrid cache)
52    HitMemory,
53    /// Cache hit from disk tier, promoted to memory (hybrid cache)
54    HitDisk,
55    /// Cache hit (non-hybrid / tier unknown)
56    Hit,
57    /// Cache hit but response was stale (revalidation needed)
58    HitStale,
59    /// Cache miss (response fetched from upstream)
60    Miss,
61    /// Cache bypassed (not eligible for caching)
62    Bypass(&'static str),
63}
64
65/// Rate limit header information for response headers
66#[derive(Debug, Clone)]
67pub struct RateLimitHeaderInfo {
68    /// Maximum requests allowed per window
69    pub limit: u32,
70    /// Remaining requests in current window
71    pub remaining: u32,
72    /// Unix timestamp (seconds) when the window resets
73    pub reset_at: u64,
74}
75
76/// Request context maintained throughout the request lifecycle.
77///
78/// This struct uses a hybrid approach:
79/// - Immutable fields (start_time) are private with getters
80/// - Mutable fields are public(crate) for efficient access within the proxy module
81pub struct RequestContext {
82    /// Request start time (immutable after creation)
83    start_time: Instant,
84
85    // === Tracing ===
86    /// Unique trace ID for request tracing (also used as correlation_id)
87    pub(crate) trace_id: String,
88
89    // === Global config (cached once per request) ===
90    /// Cached global configuration snapshot for this request
91    pub(crate) config: Option<Arc<Config>>,
92
93    // === Routing ===
94    /// Selected route ID
95    pub(crate) route_id: Option<String>,
96    /// Cached route configuration (avoids duplicate route matching)
97    pub(crate) route_config: Option<Arc<RouteConfig>>,
98    /// Selected upstream pool ID
99    pub(crate) upstream: Option<String>,
100    /// Selected upstream peer address (IP:port) for feedback reporting
101    pub(crate) selected_upstream_address: Option<String>,
102    /// Number of upstream attempts
103    pub(crate) upstream_attempts: u32,
104    /// Times the request itself has been sent upstream.
105    ///
106    /// Distinct from `upstream_attempts`, which counts tries at *selecting* a
107    /// backend. This one backs the route's `retry-policy` and is incremented
108    /// in `upstream_peer`, which Pingora re-enters on every retry.
109    pub(crate) request_attempts: u32,
110
111    // === Scope (for namespaced configurations) ===
112    /// Namespace for this request (if routed to a namespace scope)
113    pub(crate) namespace: Option<String>,
114    /// Service for this request (if routed to a service scope)
115    pub(crate) service: Option<String>,
116
117    // === Request metadata (cached for logging) ===
118    /// HTTP method
119    pub(crate) method: String,
120    /// Request path
121    pub(crate) path: String,
122    /// Query string
123    pub(crate) query: Option<String>,
124
125    // === Client info ===
126    /// Client IP address
127    pub(crate) client_ip: String,
128    /// User-Agent header
129    pub(crate) user_agent: Option<String>,
130    /// Referer header
131    pub(crate) referer: Option<String>,
132    /// Host header
133    pub(crate) host: Option<String>,
134
135    // === Body tracking ===
136    /// Request body bytes received
137    pub(crate) request_body_bytes: u64,
138    /// Response body bytes (set during response)
139    pub(crate) response_bytes: u64,
140
141    // === Connection tracking ===
142    /// Whether the upstream connection was reused
143    pub(crate) connection_reused: bool,
144    /// Whether this request is a WebSocket upgrade
145    pub(crate) is_websocket_upgrade: bool,
146
147    // === WebSocket Inspection ===
148    /// Whether WebSocket frame inspection is enabled for this connection
149    pub(crate) websocket_inspection_enabled: bool,
150    /// Whether to skip inspection (e.g., due to compression negotiation)
151    pub(crate) websocket_skip_inspection: bool,
152    /// Agent IDs for WebSocket frame inspection
153    pub(crate) websocket_inspection_agents: Vec<String>,
154    /// WebSocket frame handler (created after 101 upgrade)
155    pub(crate) websocket_handler: Option<Arc<WebSocketHandler>>,
156
157    // === Caching ===
158    /// Whether this request is eligible for caching
159    pub(crate) cache_eligible: bool,
160    /// Cache status for Cache-Status response header (RFC 9211)
161    pub(crate) cache_status: Option<CacheStatus>,
162
163    // === Body Inspection ===
164    /// Whether body inspection is enabled for this request
165    pub(crate) body_inspection_enabled: bool,
166    /// Bytes already sent to agent for inspection
167    pub(crate) body_bytes_inspected: u64,
168    /// Accumulated body buffer for agent inspection
169    pub(crate) body_buffer: Vec<u8>,
170    /// Agent IDs to use for body inspection
171    pub(crate) body_inspection_agents: Vec<String>,
172
173    // === Agentic protocol policy (MCP / A2A) ===
174    /// Request body accumulated for MCP/A2A policy evaluation.
175    ///
176    /// Separate from `body_buffer`, which serves agent inspection and is
177    /// governed by the WAF's streaming mode. Policy here is resolved from the
178    /// JSON-RPC envelope and must see the whole envelope before the request
179    /// reaches an upstream, whatever the agent configuration happens to be.
180    pub(crate) agentic_body: Vec<u8>,
181    /// Set when the body exceeded what the evaluator will parse, so evaluation
182    /// treats it as uninspectable rather than judging a truncated prefix.
183    pub(crate) agentic_body_oversize: bool,
184    /// MCP method resolved **from the body**, for metrics and audit. Recording
185    /// the header value instead would describe what a client claimed rather
186    /// than what the upstream will execute.
187    pub(crate) mcp_method: Option<String>,
188    /// MCP tool or resource resolved from the body.
189    pub(crate) mcp_target: Option<String>,
190    /// A2A method resolved from the body.
191    pub(crate) a2a_method: Option<String>,
192
193    // === Body Decompression ===
194    /// Whether decompression is enabled for body inspection
195    pub(crate) decompression_enabled: bool,
196    /// Content-Encoding of the request body (if compressed)
197    pub(crate) body_content_encoding: Option<String>,
198    /// Maximum decompression ratio allowed
199    pub(crate) max_decompression_ratio: f64,
200    /// Maximum decompressed size allowed
201    pub(crate) max_decompression_bytes: usize,
202    /// Whether decompression was performed
203    pub(crate) body_was_decompressed: bool,
204
205    // === Rate Limiting ===
206    /// Rate limit info for response headers (set during request_filter)
207    pub(crate) rate_limit_info: Option<RateLimitHeaderInfo>,
208
209    // === GeoIP Filtering ===
210    /// Country code from GeoIP lookup (ISO 3166-1 alpha-2)
211    pub(crate) geo_country_code: Option<String>,
212    /// Whether a geo lookup was performed for this request
213    pub(crate) geo_lookup_performed: bool,
214
215    // === Body Streaming ===
216    /// Body streaming mode for request body inspection
217    pub(crate) request_body_streaming_mode: BodyStreamingMode,
218    /// Current chunk index for request body streaming
219    pub(crate) request_body_chunk_index: u32,
220    /// Whether agent needs more data (streaming mode)
221    pub(crate) agent_needs_more: bool,
222    /// Body streaming mode for response body inspection
223    pub(crate) response_body_streaming_mode: BodyStreamingMode,
224    /// Current chunk index for response body streaming
225    pub(crate) response_body_chunk_index: u32,
226    /// Response body bytes inspected
227    pub(crate) response_body_bytes_inspected: u64,
228    /// Response body inspection enabled
229    pub(crate) response_body_inspection_enabled: bool,
230    /// Agent IDs for response body inspection
231    pub(crate) response_body_inspection_agents: Vec<String>,
232
233    // === OpenTelemetry Tracing ===
234    /// OpenTelemetry request span (if tracing enabled)
235    pub(crate) otel_span: Option<crate::otel::RequestSpan>,
236    /// W3C trace context parsed from incoming request
237    pub(crate) trace_context: Option<crate::otel::TraceContext>,
238
239    // === Inference Rate Limiting ===
240    /// Whether inference rate limiting is enabled for this route
241    pub(crate) inference_rate_limit_enabled: bool,
242    /// Estimated tokens for this request (used for rate limiting)
243    pub(crate) inference_estimated_tokens: u64,
244    /// Rate limit key used (client IP, API key, etc.)
245    pub(crate) inference_rate_limit_key: Option<String>,
246    /// Model name detected from request
247    pub(crate) inference_model: Option<String>,
248    /// Provider override from model-based routing (for cross-provider routing)
249    pub(crate) inference_provider_override: Option<zentinel_config::InferenceProvider>,
250    /// Whether model-based routing was used to select the upstream
251    pub(crate) model_routing_used: bool,
252    /// Actual tokens from response (filled in after response)
253    pub(crate) inference_actual_tokens: Option<u64>,
254
255    // === Token Budget Tracking ===
256    /// Whether budget tracking is enabled for this route
257    pub(crate) inference_budget_enabled: bool,
258    /// Budget remaining after this request (set after response)
259    pub(crate) inference_budget_remaining: Option<i64>,
260    /// Period reset timestamp (Unix seconds)
261    pub(crate) inference_budget_period_reset: Option<u64>,
262    /// Whether budget was exhausted (429 sent)
263    pub(crate) inference_budget_exhausted: bool,
264
265    // === Cost Attribution ===
266    /// Whether cost attribution is enabled for this route
267    pub(crate) inference_cost_enabled: bool,
268    /// Calculated cost for this request (set after response)
269    pub(crate) inference_request_cost: Option<f64>,
270    /// Input tokens for cost calculation
271    pub(crate) inference_input_tokens: u64,
272    /// Output tokens for cost calculation
273    pub(crate) inference_output_tokens: u64,
274
275    // === Streaming Token Counting ===
276    /// Whether this is a streaming (SSE) response
277    pub(crate) inference_streaming_response: bool,
278    /// Streaming token counter for SSE responses
279    pub(crate) inference_streaming_counter: Option<StreamingTokenCounter>,
280
281    // === Fallback Routing ===
282    /// Current fallback attempt number (0 = primary, 1+ = fallback)
283    pub(crate) fallback_attempt: u32,
284    /// List of upstream IDs that have been tried
285    pub(crate) tried_upstreams: Vec<String>,
286    /// Reason for triggering fallback (if fallback was used)
287    pub(crate) fallback_reason: Option<FallbackReason>,
288    /// Original upstream ID before fallback (primary)
289    pub(crate) original_upstream: Option<String>,
290    /// Model mapping applied: (original_model, mapped_model)
291    pub(crate) model_mapping_applied: Option<(String, String)>,
292    /// Whether fallback should be retried after response
293    pub(crate) should_retry_with_fallback: bool,
294
295    // === Semantic Guardrails ===
296    /// Whether guardrails are enabled for this route
297    pub(crate) guardrails_enabled: bool,
298    /// Prompt injection detected but allowed (add warning header)
299    pub(crate) guardrail_warning: bool,
300    /// Categories of prompt injection detected (for logging)
301    pub(crate) guardrail_detection_categories: Vec<String>,
302    /// PII categories detected in response (for logging)
303    pub(crate) pii_detection_categories: Vec<String>,
304
305    // === Shadow Traffic ===
306    /// Pending shadow request info (stored for deferred execution after body buffering)
307    pub(crate) shadow_pending: Option<ShadowPendingRequest>,
308    /// Whether shadow request was sent for this request
309    pub(crate) shadow_sent: bool,
310
311    // === Sticky Sessions ===
312    /// Whether a new sticky session assignment was made (needs Set-Cookie header)
313    pub(crate) sticky_session_new_assignment: bool,
314    /// Set-Cookie header value to include in response (full header value)
315    pub(crate) sticky_session_set_cookie: Option<String>,
316    /// Target index for sticky session (for logging)
317    pub(crate) sticky_target_index: Option<usize>,
318
319    // === Listener Overrides ===
320    /// Keepalive timeout from listener config (seconds, for response phase)
321    pub(crate) listener_keepalive_timeout_secs: Option<u64>,
322
323    // === Filter Overrides ===
324    /// Upstream connect timeout override from Timeout filter (seconds)
325    pub(crate) filter_connect_timeout_secs: Option<u64>,
326    /// Upstream read timeout override from Timeout filter (seconds)
327    pub(crate) filter_upstream_timeout_secs: Option<u64>,
328    /// CORS origin matched by a CORS filter (for response headers)
329    pub(crate) cors_origin: Option<String>,
330    /// Whether response compression is enabled by a Compress filter
331    pub(crate) compress_enabled: bool,
332
333    // === Response-Phase Agent Processing ===
334    /// Agent IDs resolved from route filters (saved in request phase for response phase)
335    pub(crate) route_agent_ids: Vec<String>,
336    /// Whether response-phase agent processing is enabled (agent subscribes to response events)
337    pub(crate) response_agent_processing_enabled: bool,
338    /// Accumulated response body buffer for agent processing (when agent needs full body)
339    pub(crate) response_agent_body_buffer: Vec<u8>,
340    /// Whether response body has been fully received by agent
341    pub(crate) response_agent_body_complete: bool,
342}
343
344/// Pending shadow request information stored in context for deferred execution
345#[derive(Clone)]
346pub struct ShadowPendingRequest {
347    /// Cloned request headers for shadow
348    pub headers: pingora::http::RequestHeader,
349    /// Shadow manager (wrapped in Arc for Clone)
350    pub manager: std::sync::Arc<crate::shadow::ShadowManager>,
351    /// Request context for shadow (client IP, path, method, etc.)
352    pub request_ctx: crate::upstream::RequestContext,
353    /// Whether body should be included
354    pub include_body: bool,
355}
356
357impl RequestContext {
358    /// Create a new empty request context with the current timestamp.
359    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, // 10MB
402            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    // === Immutable field accessors ===
461
462    /// Get the request start time.
463    #[inline]
464    pub fn start_time(&self) -> Instant {
465        self.start_time
466    }
467
468    /// Get elapsed duration since request start.
469    #[inline]
470    pub fn elapsed(&self) -> std::time::Duration {
471        self.start_time.elapsed()
472    }
473
474    // === Read-only accessors ===
475
476    /// Get trace_id (alias for backwards compatibility with correlation_id usage).
477    #[inline]
478    pub fn correlation_id(&self) -> &str {
479        &self.trace_id
480    }
481
482    /// Get the trace ID.
483    #[inline]
484    pub fn trace_id(&self) -> &str {
485        &self.trace_id
486    }
487
488    /// Get the route ID, if set.
489    #[inline]
490    pub fn route_id(&self) -> Option<&str> {
491        self.route_id.as_deref()
492    }
493
494    /// Get the upstream ID, if set.
495    #[inline]
496    pub fn upstream(&self) -> Option<&str> {
497        self.upstream.as_deref()
498    }
499
500    /// Get the selected upstream peer address (IP:port), if set.
501    #[inline]
502    pub fn selected_upstream_address(&self) -> Option<&str> {
503        self.selected_upstream_address.as_deref()
504    }
505
506    /// Get the cached route configuration, if set.
507    #[inline]
508    pub fn route_config(&self) -> Option<&Arc<RouteConfig>> {
509        self.route_config.as_ref()
510    }
511
512    /// Get the cached global configuration, if set.
513    #[inline]
514    pub fn global_config(&self) -> Option<&Arc<Config>> {
515        self.config.as_ref()
516    }
517
518    /// Get the service type from cached route config.
519    #[inline]
520    pub fn service_type(&self) -> Option<ServiceType> {
521        self.route_config.as_ref().map(|c| c.service_type.clone())
522    }
523
524    /// Get the number of upstream attempts.
525    #[inline]
526    /// Times the request itself has been sent upstream.
527    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    /// Get the HTTP method.
536    #[inline]
537    pub fn method(&self) -> &str {
538        &self.method
539    }
540
541    /// Get the request path.
542    #[inline]
543    pub fn path(&self) -> &str {
544        &self.path
545    }
546
547    /// Get the query string, if present.
548    #[inline]
549    pub fn query(&self) -> Option<&str> {
550        self.query.as_deref()
551    }
552
553    /// Get the client IP address.
554    #[inline]
555    pub fn client_ip(&self) -> &str {
556        &self.client_ip
557    }
558
559    /// Get the User-Agent header, if present.
560    #[inline]
561    pub fn user_agent(&self) -> Option<&str> {
562        self.user_agent.as_deref()
563    }
564
565    /// Get the Referer header, if present.
566    #[inline]
567    pub fn referer(&self) -> Option<&str> {
568        self.referer.as_deref()
569    }
570
571    /// Get the Host header, if present.
572    #[inline]
573    pub fn host(&self) -> Option<&str> {
574        self.host.as_deref()
575    }
576
577    /// Get the response body size in bytes.
578    #[inline]
579    pub fn response_bytes(&self) -> u64 {
580        self.response_bytes
581    }
582
583    /// Get the GeoIP country code, if determined.
584    #[inline]
585    pub fn geo_country_code(&self) -> Option<&str> {
586        self.geo_country_code.as_deref()
587    }
588
589    /// Check if a geo lookup was performed for this request.
590    #[inline]
591    pub fn geo_lookup_performed(&self) -> bool {
592        self.geo_lookup_performed
593    }
594
595    /// Get traceparent header value for distributed tracing.
596    ///
597    /// Returns the W3C Trace Context traceparent header value if tracing is enabled.
598    /// Format: `{version}-{trace-id}-{span-id}-{trace-flags}`
599    #[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    // === Mutation helpers ===
612
613    /// Set the trace ID.
614    #[inline]
615    pub fn set_trace_id(&mut self, trace_id: impl Into<String>) {
616        self.trace_id = trace_id.into();
617    }
618
619    /// Set the route ID.
620    #[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    /// Set the upstream ID.
626    #[inline]
627    pub fn set_upstream(&mut self, upstream: impl Into<String>) {
628        self.upstream = Some(upstream.into());
629    }
630
631    /// Set the selected upstream peer address (IP:port).
632    #[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    /// Increment upstream attempt counter.
638    #[inline]
639    pub fn inc_upstream_attempts(&mut self) {
640        self.upstream_attempts += 1;
641    }
642
643    /// Set response bytes.
644    #[inline]
645    pub fn set_response_bytes(&mut self, bytes: u64) {
646        self.response_bytes = bytes;
647    }
648
649    // === Fallback accessors ===
650
651    /// Get the current fallback attempt number (0 = primary).
652    #[inline]
653    pub fn fallback_attempt(&self) -> u32 {
654        self.fallback_attempt
655    }
656
657    /// Get the list of upstreams that have been tried.
658    #[inline]
659    pub fn tried_upstreams(&self) -> &[String] {
660        &self.tried_upstreams
661    }
662
663    /// Get the fallback reason, if fallback was triggered.
664    #[inline]
665    pub fn fallback_reason(&self) -> Option<&FallbackReason> {
666        self.fallback_reason.as_ref()
667    }
668
669    /// Get the original upstream ID (before fallback).
670    #[inline]
671    pub fn original_upstream(&self) -> Option<&str> {
672        self.original_upstream.as_deref()
673    }
674
675    /// Get the model mapping that was applied: (original, mapped).
676    #[inline]
677    pub fn model_mapping_applied(&self) -> Option<&(String, String)> {
678        self.model_mapping_applied.as_ref()
679    }
680
681    /// Check if fallback was used for this request.
682    #[inline]
683    pub fn used_fallback(&self) -> bool {
684        self.fallback_attempt > 0
685    }
686
687    /// Record that a fallback attempt is being made.
688    #[inline]
689    pub fn record_fallback(&mut self, reason: FallbackReason, new_upstream: &str) {
690        if self.fallback_attempt == 0 {
691            // First fallback - save original upstream
692            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    /// Record model mapping applied during fallback.
703    #[inline]
704    pub fn record_model_mapping(&mut self, original: String, mapped: String) {
705        self.model_mapping_applied = Some((original, mapped));
706    }
707
708    // === Model Routing accessors ===
709
710    /// Check if model-based routing was used to select the upstream.
711    #[inline]
712    pub fn used_model_routing(&self) -> bool {
713        self.model_routing_used
714    }
715
716    /// Get the provider override from model-based routing (if any).
717    #[inline]
718    pub fn inference_provider_override(&self) -> Option<zentinel_config::InferenceProvider> {
719        self.inference_provider_override
720    }
721
722    /// Record model-based routing result.
723    ///
724    /// Called when model-based routing selects an upstream based on the model name.
725    #[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// ============================================================================
748// Tests
749// ============================================================================
750
751#[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        // Initially no rate limit info
783        assert!(ctx.rate_limit_info.is_none());
784
785        // Set rate limit info
786        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        // Elapsed time should be very small (less than 1 second)
804        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        // Initially no fallback
835        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        // Set initial upstream
842        ctx.set_upstream("openai-primary");
843
844        // Record first fallback
845        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        // Record second fallback
858        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        // Original upstream should still be the first one
873        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}