Skip to main content

zentinel_common/
types.rs

1//! Common type definitions for Zentinel proxy.
2//!
3//! This module provides shared type definitions used throughout the platform,
4//! with a focus on type safety and operational clarity.
5//!
6//! For identifier types (CorrelationId, RequestId, etc.), see the `ids` module.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::str::FromStr;
11use std::time::Duration;
12
13/// HTTP method wrapper with validation
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum HttpMethod {
16    GET,
17    POST,
18    PUT,
19    DELETE,
20    HEAD,
21    OPTIONS,
22    PATCH,
23    CONNECT,
24    TRACE,
25    #[serde(untagged)]
26    Custom(String),
27}
28
29impl FromStr for HttpMethod {
30    type Err = std::convert::Infallible;
31
32    fn from_str(s: &str) -> Result<Self, Self::Err> {
33        Ok(match s.to_uppercase().as_str() {
34            "GET" => Self::GET,
35            "POST" => Self::POST,
36            "PUT" => Self::PUT,
37            "DELETE" => Self::DELETE,
38            "HEAD" => Self::HEAD,
39            "OPTIONS" => Self::OPTIONS,
40            "PATCH" => Self::PATCH,
41            "CONNECT" => Self::CONNECT,
42            "TRACE" => Self::TRACE,
43            other => Self::Custom(other.to_string()),
44        })
45    }
46}
47
48impl fmt::Display for HttpMethod {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::GET => write!(f, "GET"),
52            Self::POST => write!(f, "POST"),
53            Self::PUT => write!(f, "PUT"),
54            Self::DELETE => write!(f, "DELETE"),
55            Self::HEAD => write!(f, "HEAD"),
56            Self::OPTIONS => write!(f, "OPTIONS"),
57            Self::PATCH => write!(f, "PATCH"),
58            Self::CONNECT => write!(f, "CONNECT"),
59            Self::TRACE => write!(f, "TRACE"),
60            Self::Custom(method) => write!(f, "{}", method),
61        }
62    }
63}
64
65/// TLS version
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum TlsVersion {
68    #[serde(rename = "TLS1.2")]
69    Tls12,
70    #[serde(rename = "TLS1.3")]
71    Tls13,
72}
73
74impl fmt::Display for TlsVersion {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Tls12 => write!(f, "TLS1.2"),
78            Self::Tls13 => write!(f, "TLS1.3"),
79        }
80    }
81}
82
83/// Trace ID format selection.
84///
85/// Controls how trace IDs are generated for request tracing.
86///
87/// # Formats
88///
89/// - **TinyFlake** (default): 11-character Base58 encoded ID with time prefix.
90///   Operator-friendly format designed for easy copying and log correlation.
91///   Example: `k7BxR3nVp2Ym`
92///
93/// - **UUID**: Standard 36-character UUID v4 format with dashes.
94///   Guaranteed unique, widely compatible.
95///   Example: `550e8400-e29b-41d4-a716-446655440000`
96///
97/// # Configuration
98///
99/// ```kdl
100/// server {
101///     trace-id-format "tinyflake"  // or "uuid"
102/// }
103/// ```
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
105#[serde(rename_all = "lowercase")]
106pub enum TraceIdFormat {
107    /// TinyFlake format: 11-char Base58, time-prefixed (default)
108    #[default]
109    TinyFlake,
110
111    /// UUID v4 format: 36-char with dashes
112    Uuid,
113}
114
115impl TraceIdFormat {
116    /// Parse format from string (case-insensitive)
117    pub fn from_str_loose(s: &str) -> Self {
118        match s.to_lowercase().as_str() {
119            "uuid" | "uuid4" | "uuidv4" => TraceIdFormat::Uuid,
120            _ => TraceIdFormat::TinyFlake, // Default to TinyFlake
121        }
122    }
123}
124
125impl fmt::Display for TraceIdFormat {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            TraceIdFormat::TinyFlake => write!(f, "tinyflake"),
129            TraceIdFormat::Uuid => write!(f, "uuid"),
130        }
131    }
132}
133
134/// Load balancing algorithm
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum LoadBalancingAlgorithm {
138    RoundRobin,
139    LeastConnections,
140    Random,
141    IpHash,
142    Weighted,
143    ConsistentHash,
144    PowerOfTwoChoices,
145    Adaptive,
146    /// Least tokens queued - for inference/LLM workloads
147    ///
148    /// Selects the upstream with the fewest estimated tokens currently
149    /// being processed. Useful for LLM inference backends where token
150    /// throughput varies significantly between requests.
151    LeastTokensQueued,
152    /// Maglev consistent hashing - Google's load balancing algorithm
153    ///
154    /// Provides minimal disruption when backend servers are added/removed,
155    /// with better load distribution than traditional consistent hashing.
156    /// Uses a permutation-based lookup table for O(1) selection.
157    Maglev,
158    /// Locality-aware load balancing
159    ///
160    /// Prefers targets in the same zone/region as the proxy, falling back
161    /// to other zones when local targets are unhealthy or overloaded.
162    /// Useful for multi-region deployments to minimize latency.
163    LocalityAware,
164    /// Peak EWMA (Exponentially Weighted Moving Average)
165    ///
166    /// Twitter Finagle's algorithm that tracks latency using EWMA and selects
167    /// the backend with the lowest predicted completion time. Reacts quickly
168    /// to latency spikes by using the peak of EWMA and recent latency.
169    PeakEwma,
170    /// Deterministic Subsetting
171    ///
172    /// For very large clusters (1000+ backends), limits each proxy instance
173    /// to a deterministic subset of backends. Reduces connection overhead
174    /// while ensuring even distribution across all proxies.
175    DeterministicSubset,
176    /// Weighted Least Connections
177    ///
178    /// Combines weight with connection counting. Selects the backend with
179    /// the lowest ratio of active connections to weight. Useful when backends
180    /// have different capacities.
181    WeightedLeastConnections,
182    /// Cookie-based sticky sessions
183    ///
184    /// Routes requests to the same backend based on an affinity cookie.
185    /// Falls back to a configurable algorithm when no cookie is present or
186    /// the target is unavailable. Useful for stateful applications that
187    /// require session affinity.
188    Sticky,
189}
190
191/// Health check type
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum HealthCheckType {
195    Http {
196        path: String,
197        expected_status: u16,
198        #[serde(skip_serializing_if = "Option::is_none")]
199        host: Option<String>,
200    },
201    Tcp,
202    Grpc {
203        service: String,
204    },
205    /// Inference health check for LLM/AI backends
206    ///
207    /// Probes the `/v1/models` endpoint (or custom endpoint) to verify
208    /// the inference server is running and expected models are available.
209    /// Optionally includes enhanced readiness checks for model availability.
210    Inference {
211        /// Endpoint to probe (default: "/v1/models")
212        endpoint: String,
213        /// Expected models that must be available (optional)
214        #[serde(default, skip_serializing_if = "Vec::is_empty")]
215        expected_models: Vec<String>,
216        /// Enhanced readiness checks (optional)
217        #[serde(default, skip_serializing_if = "Option::is_none")]
218        readiness: Option<Box<crate::inference::InferenceReadinessConfig>>,
219    },
220}
221
222/// Retry policy
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RetryPolicy {
225    /// Total attempts, including the first. `3` means one try and two retries.
226    pub max_attempts: u32,
227
228    /// Upstream status codes that cause the response to be discarded and the
229    /// request retried.
230    ///
231    /// Empty by default: retrying on a status code replays the request, and
232    /// which codes are safe to replay depends on the application. `503` from
233    /// a load shedder is worth retrying; `500` from a handler that already
234    /// charged a card is not.
235    #[serde(default)]
236    pub retryable_status_codes: Vec<u16>,
237
238    /// Delay before the first retry. Doubles each attempt, capped at
239    /// `max_backoff`.
240    #[serde(default = "default_retry_backoff")]
241    pub backoff: Duration,
242
243    /// Ceiling for the doubling backoff.
244    #[serde(default = "default_retry_max_backoff")]
245    pub max_backoff: Duration,
246
247    /// Connection timeout applied to each attempt.
248    ///
249    /// Without this, three attempts against a black-holed upstream each wait
250    /// the full connect timeout, so a policy meant to improve availability
251    /// triples the worst-case latency instead.
252    #[serde(default)]
253    pub per_attempt_timeout: Option<Duration>,
254
255    /// Whether a request may be retried when its method is not idempotent.
256    ///
257    /// Off by default. Replaying a POST can duplicate a side effect the
258    /// origin already performed, and the proxy cannot tell whether it did.
259    #[serde(default)]
260    pub retry_non_idempotent: bool,
261}
262
263fn default_retry_backoff() -> Duration {
264    Duration::from_millis(100)
265}
266
267fn default_retry_max_backoff() -> Duration {
268    Duration::from_secs(2)
269}
270
271impl Default for RetryPolicy {
272    fn default() -> Self {
273        Self {
274            max_attempts: 3,
275            retryable_status_codes: Vec::new(),
276            backoff: default_retry_backoff(),
277            max_backoff: default_retry_max_backoff(),
278            per_attempt_timeout: None,
279            retry_non_idempotent: false,
280        }
281    }
282}
283
284impl RetryPolicy {
285    /// Backoff before `attempt` (1-based: the delay before attempt 2 is
286    /// `backoff`, before attempt 3 is `backoff * 2`, and so on).
287    pub fn backoff_for(&self, attempt: u32) -> Duration {
288        if attempt <= 1 {
289            return Duration::ZERO;
290        }
291        let doublings = attempt.saturating_sub(2).min(16);
292        let scaled = self
293            .backoff
294            .saturating_mul(1u32.checked_shl(doublings).unwrap_or(u32::MAX));
295        scaled.min(self.max_backoff)
296    }
297
298    /// Whether this status code should cause a retry.
299    pub fn is_retryable_status(&self, status: u16) -> bool {
300        self.retryable_status_codes.contains(&status)
301    }
302
303    /// Whether a request using `method` may be replayed.
304    ///
305    /// Idempotent methods are safe by definition. Anything else needs
306    /// `retry_non_idempotent`, because replaying it can duplicate a side
307    /// effect the origin already performed.
308    pub fn may_retry_method(&self, method: &str) -> bool {
309        if self.retry_non_idempotent {
310            return true;
311        }
312        matches!(
313            method.to_ascii_uppercase().as_str(),
314            "GET" | "HEAD" | "PUT" | "DELETE" | "OPTIONS" | "TRACE"
315        )
316    }
317}
318
319/// Circuit breaker configuration
320#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
321pub struct CircuitBreakerConfig {
322    pub failure_threshold: u32,
323    pub success_threshold: u32,
324    pub timeout_seconds: u64,
325    pub half_open_max_requests: u32,
326}
327
328impl Default for CircuitBreakerConfig {
329    fn default() -> Self {
330        Self {
331            failure_threshold: 5,
332            success_threshold: 2,
333            timeout_seconds: 30,
334            half_open_max_requests: 1,
335        }
336    }
337}
338
339/// Circuit breaker state
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum CircuitBreakerState {
343    Closed,
344    Open,
345    HalfOpen,
346}
347
348/// Route evaluation priority.
349///
350/// Routes are sorted in descending priority order — higher values are
351/// evaluated first. Any `i32` value is accepted; the named constants
352/// ([`LOW`](Self::LOW), [`NORMAL`](Self::NORMAL), [`HIGH`](Self::HIGH),
353/// [`CRITICAL`](Self::CRITICAL)) exist as conveniences for common cases, but
354/// gap-based values like `Priority(500)` are fully supported so routes can be
355/// finely ordered between named tiers.
356///
357/// KDL syntax accepts either an integer (`priority 100`) or one of the named
358/// string aliases (`priority "high"`), with the aliases resolving to the
359/// matching constant defined below.
360///
361/// # Examples
362///
363/// ```
364/// use zentinel_common::types::Priority;
365///
366/// assert!(Priority::HIGH > Priority::NORMAL);
367/// assert!(Priority(500) > Priority::HIGH);
368/// assert_eq!(Priority::default(), Priority::NORMAL);
369/// ```
370#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
371#[serde(transparent)]
372pub struct Priority(pub i32);
373
374impl Priority {
375    /// Low-priority routes (default weight: `10`). Evaluated after normal routes.
376    pub const LOW: Self = Self(10);
377    /// Normal-priority routes (default weight: `50`). The default for routes
378    /// that do not specify an explicit priority.
379    pub const NORMAL: Self = Self(50);
380    /// High-priority routes (default weight: `100`). Evaluated before normal routes.
381    pub const HIGH: Self = Self(100);
382    /// Critical-priority routes (default weight: `1000`). Evaluated first; intended
383    /// for health checks and other infrastructure-critical routes.
384    pub const CRITICAL: Self = Self(1000);
385
386    /// Returns the underlying integer weight.
387    #[inline]
388    pub const fn as_i32(self) -> i32 {
389        self.0
390    }
391}
392
393impl Default for Priority {
394    fn default() -> Self {
395        Self::NORMAL
396    }
397}
398
399impl std::fmt::Display for Priority {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        write!(f, "{}", self.0)
402    }
403}
404
405impl From<i32> for Priority {
406    fn from(value: i32) -> Self {
407        Self(value)
408    }
409}
410
411/// Time window for rate limiting and metrics
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413pub struct TimeWindow {
414    pub seconds: u64,
415}
416
417impl TimeWindow {
418    pub fn new(seconds: u64) -> Self {
419        Self { seconds }
420    }
421
422    pub fn as_duration(&self) -> std::time::Duration {
423        std::time::Duration::from_secs(self.seconds)
424    }
425}
426
427/// Byte size with human-readable serialization
428#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
429pub struct ByteSize(pub usize);
430
431impl ByteSize {
432    pub const KB: usize = 1024;
433    pub const MB: usize = 1024 * 1024;
434    pub const GB: usize = 1024 * 1024 * 1024;
435
436    pub fn from_kb(kb: usize) -> Self {
437        Self(kb * Self::KB)
438    }
439
440    pub fn from_mb(mb: usize) -> Self {
441        Self(mb * Self::MB)
442    }
443
444    pub fn from_gb(gb: usize) -> Self {
445        Self(gb * Self::GB)
446    }
447
448    pub fn as_bytes(&self) -> usize {
449        self.0
450    }
451}
452
453impl fmt::Display for ByteSize {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        if self.0 >= Self::GB {
456            write!(f, "{:.2}GB", self.0 as f64 / Self::GB as f64)
457        } else if self.0 >= Self::MB {
458            write!(f, "{:.2}MB", self.0 as f64 / Self::MB as f64)
459        } else if self.0 >= Self::KB {
460            write!(f, "{:.2}KB", self.0 as f64 / Self::KB as f64)
461        } else {
462            write!(f, "{}B", self.0)
463        }
464    }
465}
466
467impl Serialize for ByteSize {
468    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
469    where
470        S: serde::Serializer,
471    {
472        serializer.serialize_str(&self.to_string())
473    }
474}
475
476impl<'de> Deserialize<'de> for ByteSize {
477    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
478    where
479        D: serde::Deserializer<'de>,
480    {
481        let s = String::deserialize(deserializer)?;
482        Self::from_str(&s).map_err(serde::de::Error::custom)
483    }
484}
485
486impl FromStr for ByteSize {
487    type Err = String;
488
489    fn from_str(s: &str) -> Result<Self, Self::Err> {
490        let s = s.trim();
491        if s.is_empty() {
492            return Err("Empty byte size string".to_string());
493        }
494
495        // Try to parse as plain number (bytes)
496        if let Ok(bytes) = s.parse::<usize>() {
497            return Ok(Self(bytes));
498        }
499
500        // Parse with unit suffix
501        let (num_part, unit_part) = s
502            .chars()
503            .position(|c| c.is_alphabetic())
504            .map(|i| s.split_at(i))
505            .ok_or_else(|| format!("Invalid byte size format: {}", s))?;
506
507        let value: f64 = num_part
508            .trim()
509            .parse()
510            .map_err(|_| format!("Invalid number: {}", num_part))?;
511
512        let multiplier = match unit_part.to_uppercase().as_str() {
513            "B" => 1,
514            "KB" | "K" => Self::KB,
515            "MB" | "M" => Self::MB,
516            "GB" | "G" => Self::GB,
517            _ => return Err(format!("Invalid unit: {}", unit_part)),
518        };
519
520        Ok(Self((value * multiplier as f64) as usize))
521    }
522}
523
524/// IP address wrapper with additional metadata
525#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
526pub struct ClientIp {
527    pub address: std::net::IpAddr,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub forwarded_for: Option<Vec<std::net::IpAddr>>,
530}
531
532impl fmt::Display for ClientIp {
533    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534        write!(f, "{}", self.address)
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_http_method_parsing() {
544        assert_eq!(HttpMethod::from_str("GET").unwrap(), HttpMethod::GET);
545        assert_eq!(HttpMethod::from_str("post").unwrap(), HttpMethod::POST);
546        assert_eq!(
547            HttpMethod::from_str("PROPFIND").unwrap(),
548            HttpMethod::Custom("PROPFIND".to_string())
549        );
550    }
551
552    #[test]
553    fn test_byte_size_parsing() {
554        assert_eq!(ByteSize::from_str("1024").unwrap().0, 1024);
555        assert_eq!(ByteSize::from_str("10KB").unwrap().0, 10 * 1024);
556        assert_eq!(
557            ByteSize::from_str("5.5MB").unwrap().0,
558            (5.5 * 1024.0 * 1024.0) as usize
559        );
560        assert_eq!(ByteSize::from_str("2GB").unwrap().0, 2 * 1024 * 1024 * 1024);
561        assert_eq!(ByteSize::from_str("100 B").unwrap().0, 100);
562    }
563
564    #[test]
565    fn test_byte_size_display() {
566        assert_eq!(ByteSize(512).to_string(), "512B");
567        assert_eq!(ByteSize(2048).to_string(), "2.00KB");
568        assert_eq!(ByteSize(1024 * 1024).to_string(), "1.00MB");
569        assert_eq!(ByteSize(1024 * 1024 * 1024).to_string(), "1.00GB");
570    }
571
572    #[test]
573    fn test_trace_id_format() {
574        assert_eq!(TraceIdFormat::from_str_loose("uuid"), TraceIdFormat::Uuid);
575        assert_eq!(
576            TraceIdFormat::from_str_loose("tinyflake"),
577            TraceIdFormat::TinyFlake
578        );
579        assert_eq!(
580            TraceIdFormat::from_str_loose("unknown"),
581            TraceIdFormat::TinyFlake
582        );
583    }
584}
585
586#[cfg(test)]
587mod retry_policy_tests {
588    use super::*;
589
590    fn policy() -> RetryPolicy {
591        RetryPolicy {
592            backoff: Duration::from_millis(100),
593            max_backoff: Duration::from_secs(2),
594            ..RetryPolicy::default()
595        }
596    }
597
598    /// `backoff_for` takes the attempt number the delay precedes, so attempt 1
599    /// (the first try) has no delay and attempt 2 waits the base backoff.
600    /// Getting this off by one silently skips the first retry's backoff, which
601    /// is the difference between spacing retries out and hammering a struggling
602    /// upstream three times in a millisecond.
603    #[test]
604    fn backoff_doubles_from_the_second_attempt() {
605        let p = policy();
606        assert_eq!(p.backoff_for(1), Duration::ZERO);
607        assert_eq!(p.backoff_for(2), Duration::from_millis(100));
608        assert_eq!(p.backoff_for(3), Duration::from_millis(200));
609        assert_eq!(p.backoff_for(4), Duration::from_millis(400));
610    }
611
612    #[test]
613    fn backoff_is_capped() {
614        let p = policy();
615        assert_eq!(p.backoff_for(20), Duration::from_secs(2));
616        // And does not overflow at absurd attempt counts.
617        assert_eq!(p.backoff_for(u32::MAX), Duration::from_secs(2));
618    }
619
620    #[test]
621    fn zero_backoff_stays_zero() {
622        let p = RetryPolicy {
623            backoff: Duration::ZERO,
624            ..RetryPolicy::default()
625        };
626        assert_eq!(p.backoff_for(3), Duration::ZERO);
627    }
628
629    #[test]
630    fn only_configured_status_codes_are_retryable() {
631        let p = RetryPolicy {
632            retryable_status_codes: vec![502, 503],
633            ..RetryPolicy::default()
634        };
635        assert!(p.is_retryable_status(503));
636        assert!(!p.is_retryable_status(500));
637        assert!(!p.is_retryable_status(200));
638    }
639
640    /// Nothing is retryable by default. Replaying a request has side effects
641    /// the proxy cannot see, so it should take a deliberate choice.
642    #[test]
643    fn no_status_code_is_retryable_by_default() {
644        let p = RetryPolicy::default();
645        for status in [500, 502, 503, 504] {
646            assert!(!p.is_retryable_status(status));
647        }
648    }
649
650    #[test]
651    fn only_idempotent_methods_are_replayed_by_default() {
652        let p = RetryPolicy::default();
653        for method in ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE", "get"] {
654            assert!(p.may_retry_method(method), "{method} should be retryable");
655        }
656        for method in ["POST", "PATCH", "post", "LOCK"] {
657            assert!(
658                !p.may_retry_method(method),
659                "{method} must not be replayed without opting in"
660            );
661        }
662    }
663
664    #[test]
665    fn opting_in_allows_replaying_any_method() {
666        let p = RetryPolicy {
667            retry_non_idempotent: true,
668            ..RetryPolicy::default()
669        };
670        assert!(p.may_retry_method("POST"));
671        assert!(p.may_retry_method("PATCH"));
672    }
673}