Skip to main content

praxis_core/config/cluster/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Upstream cluster definitions: endpoints, load-balancing strategies, and timeouts.
5
6mod endpoint;
7mod health_check;
8mod load_balancer_strategy;
9
10use std::sync::Arc;
11
12pub use endpoint::Endpoint;
13pub use health_check::{HealthCheckConfig, HealthCheckType};
14pub use load_balancer_strategy::{ConsistentHashOpts, LoadBalancerStrategy, ParameterisedStrategy, SimpleStrategy};
15use serde::{Deserialize, Serialize};
16
17// -----------------------------------------------------------------------------
18// Cluster
19// -----------------------------------------------------------------------------
20
21/// A named group of upstream endpoints.
22///
23/// ```
24/// # use praxis_core::config::Cluster;
25/// let yaml = r#"
26/// name: "backend"
27/// endpoints: ["10.0.0.1:8080"]
28/// connection_timeout_ms: 5000
29/// idle_timeout_ms: 30000
30/// "#;
31/// let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
32/// assert_eq!(cluster.connection_timeout_ms, Some(5000));
33/// assert_eq!(cluster.idle_timeout_ms, Some(30000));
34/// assert!(cluster.read_timeout_ms.is_none());
35/// assert!(cluster.tls.is_none());
36/// ```
37#[derive(Debug, Clone, Deserialize, Serialize)]
38#[serde(deny_unknown_fields)]
39pub struct Cluster {
40    /// Unique name for the cluster.
41    pub name: Arc<str>,
42
43    /// TCP connection timeout in milliseconds.
44    ///
45    /// Applies to the TCP handshake only (before TLS). When
46    /// exceeded, the connection attempt fails and the load
47    /// balancer may retry on the next endpoint. `None` (the
48    /// default) uses Pingora's built-in timeout.
49    #[serde(default)]
50    pub connection_timeout_ms: Option<u64>,
51
52    /// List of endpoints for the cluster. Each entry is either a plain
53    /// `"host:port"` string or a `{ address, weight }` object.
54    pub endpoints: Vec<Endpoint>,
55
56    /// Active health check configuration for this cluster.
57    #[serde(default)]
58    pub health_check: Option<HealthCheckConfig>,
59
60    /// Idle connection timeout in milliseconds.
61    ///
62    /// Closes pooled upstream connections that have been idle
63    /// longer than this duration. `None` uses Pingora's default.
64    #[serde(default)]
65    pub idle_timeout_ms: Option<u64>,
66
67    /// Load-balancing algorithm for this cluster. Defaults to `round_robin`.
68    #[serde(default)]
69    pub load_balancer_strategy: LoadBalancerStrategy,
70
71    /// Maximum concurrent in-flight requests to this cluster.
72    ///
73    /// When set, excess requests receive 503. Prevents a single
74    /// slow upstream from consuming all available capacity.
75    ///
76    /// ```
77    /// # use praxis_core::config::Cluster;
78    /// let yaml = r#"
79    /// name: backend
80    /// endpoints: ["10.0.0.1:80"]
81    /// max_connections: 100
82    /// "#;
83    /// let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
84    /// assert_eq!(cluster.max_connections, Some(100));
85    /// ```
86    #[serde(default)]
87    pub max_connections: Option<u32>,
88
89    /// Per-read timeout in milliseconds.
90    ///
91    /// Applies to each individual read operation on an
92    /// established upstream connection. A timeout fires a 502
93    /// response to the client. Use [`total_connection_timeout_ms`]
94    /// to bound the entire exchange instead.
95    ///
96    /// [`total_connection_timeout_ms`]: Cluster::total_connection_timeout_ms
97    #[serde(default)]
98    pub read_timeout_ms: Option<u64>,
99
100    /// TLS settings for upstream connections.
101    ///
102    /// Presence implies TLS is enabled. Omit for plaintext HTTP.
103    #[serde(default)]
104    pub tls: Option<praxis_tls::ClusterTls>,
105
106    /// Total connection timeout in milliseconds (TCP + TLS).
107    ///
108    /// Bounds the combined TCP handshake and TLS negotiation.
109    /// When exceeded, the connection attempt fails with a 502
110    /// response. Prefer this over [`connection_timeout_ms`] for
111    /// TLS-enabled clusters where the handshake dominates latency.
112    ///
113    /// [`connection_timeout_ms`]: Cluster::connection_timeout_ms
114    #[serde(default)]
115    pub total_connection_timeout_ms: Option<u64>,
116
117    /// Per-write timeout in milliseconds.
118    ///
119    /// Applies to each individual write operation on an
120    /// established upstream connection. A timeout fires a 502
121    /// response to the client.
122    #[serde(default)]
123    pub write_timeout_ms: Option<u64>,
124}
125
126impl Cluster {
127    /// Build a cluster with only a name and endpoints; all other
128    /// fields use their defaults (no timeouts, no TLS, no health
129    /// check, `round_robin` strategy).
130    ///
131    /// ```
132    /// use praxis_core::config::Cluster;
133    /// use praxis_tls::ClusterTls;
134    ///
135    /// let c = Cluster {
136    ///     tls: Some(ClusterTls::default()),
137    ///     ..Cluster::with_defaults("backend", vec!["10.0.0.1:443".into()])
138    /// };
139    /// assert_eq!(&*c.name, "backend");
140    /// assert!(c.tls.is_some());
141    /// assert!(c.tls.as_ref().unwrap().verify);
142    /// ```
143    pub fn with_defaults(name: &str, endpoints: Vec<Endpoint>) -> Self {
144        Self {
145            name: Arc::from(name),
146            connection_timeout_ms: None,
147            endpoints,
148            health_check: None,
149            idle_timeout_ms: None,
150            load_balancer_strategy: LoadBalancerStrategy::default(),
151            max_connections: None,
152            read_timeout_ms: None,
153            tls: None,
154            total_connection_timeout_ms: None,
155            write_timeout_ms: None,
156        }
157    }
158}
159
160// -----------------------------------------------------------------------------
161// Tests
162// -----------------------------------------------------------------------------
163
164#[cfg(test)]
165#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
166#[allow(
167    clippy::unwrap_used,
168    clippy::expect_used,
169    clippy::indexing_slicing,
170    clippy::needless_raw_strings,
171    clippy::needless_raw_string_hashes,
172    reason = "tests use unwrap/expect/indexing/raw strings for brevity"
173)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_cluster_minimal() {
179        let yaml = r#"
180name: "backend"
181endpoints: ["10.0.0.1:8080"]
182"#;
183        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
184        assert_eq!(&*cluster.name, "backend", "cluster name mismatch");
185        assert_eq!(
186            cluster.endpoints[0].address(),
187            "10.0.0.1:8080",
188            "endpoint address mismatch"
189        );
190        assert_eq!(cluster.endpoints[0].weight(), 1, "default weight should be 1");
191        assert_eq!(
192            cluster.load_balancer_strategy,
193            LoadBalancerStrategy::default(),
194            "strategy should default"
195        );
196        assert!(
197            cluster.connection_timeout_ms.is_none(),
198            "connection_timeout should default to None"
199        );
200    }
201
202    #[test]
203    fn parse_cluster_with_weights() {
204        let yaml = r#"
205name: "backend"
206endpoints:
207  - "10.0.0.1:8080"
208  - address: "10.0.0.2:8080"
209    weight: 3
210"#;
211        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
212        assert_eq!(cluster.endpoints.len(), 2, "should parse two endpoints");
213        assert_eq!(cluster.endpoints[0].weight(), 1, "simple endpoint weight should be 1");
214        assert_eq!(cluster.endpoints[1].weight(), 3, "weighted endpoint weight should be 3");
215    }
216
217    #[test]
218    fn parse_cluster_with_timeouts() {
219        let yaml = r#"
220name: "backend"
221endpoints: ["10.0.0.1:8080"]
222connection_timeout_ms: 5000
223idle_timeout_ms: 30000
224read_timeout_ms: 10000
225write_timeout_ms: 10000
226"#;
227        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
228        assert_eq!(
229            cluster.connection_timeout_ms,
230            Some(5000),
231            "connection_timeout_ms mismatch"
232        );
233        assert_eq!(cluster.idle_timeout_ms, Some(30000), "idle_timeout_ms mismatch");
234        assert_eq!(cluster.read_timeout_ms, Some(10000), "read_timeout_ms mismatch");
235        assert_eq!(cluster.write_timeout_ms, Some(10000), "write_timeout_ms mismatch");
236    }
237
238    #[test]
239    fn cluster_roundtrips_via_serde() {
240        let cluster = Cluster {
241            connection_timeout_ms: Some(1000),
242            ..Cluster::with_defaults("web", vec!["10.0.0.1:80".into()])
243        };
244        let value = serde_yaml::to_value(&cluster).unwrap();
245        let back: Cluster = serde_yaml::from_value(value).unwrap();
246        assert_eq!(back.name, cluster.name, "name should roundtrip");
247        assert_eq!(back.endpoints, cluster.endpoints, "endpoints should roundtrip");
248        assert_eq!(
249            back.connection_timeout_ms, cluster.connection_timeout_ms,
250            "timeout should roundtrip"
251        );
252    }
253
254    #[test]
255    fn tls_and_sni_parse_correctly() {
256        let yaml = r#"
257name: "backend"
258endpoints: ["10.0.0.1:443"]
259tls:
260  sni: "api.example.com"
261"#;
262        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
263        assert!(cluster.tls.is_some(), "tls should be present");
264        assert_eq!(
265            cluster.tls.as_ref().unwrap().sni.as_deref(),
266            Some("api.example.com"),
267            "sni mismatch"
268        );
269    }
270
271    #[test]
272    fn tls_verify_defaults_to_true() {
273        let yaml = r#"
274name: "backend"
275endpoints: ["10.0.0.1:443"]
276tls: {}
277"#;
278        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
279        assert!(cluster.tls.as_ref().unwrap().verify, "verify should default to true");
280    }
281
282    #[test]
283    fn tls_verify_can_be_disabled() {
284        let yaml = r#"
285name: "backend"
286endpoints: ["10.0.0.1:443"]
287tls:
288  verify: false
289"#;
290        let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
291        assert!(
292            !cluster.tls.as_ref().unwrap().verify,
293            "verify should be false when explicitly set"
294        );
295    }
296
297    #[test]
298    fn no_tls_by_default() {
299        let cluster = Cluster::with_defaults("web", vec!["10.0.0.1:80".into()]);
300        assert!(cluster.tls.is_none(), "tls should be None by default");
301    }
302}