Skip to main content

rust_web_server/canary/
mod.rs

1//! Weighted canary / A-B traffic splitting middleware.
2//!
3//! [`CanaryLayer`] implements [`Middleware`] and distributes incoming requests
4//! across a set of backends according to configurable weights, using the same
5//! *smooth* weighted round-robin (SWRR) algorithm nginx uses: each backend has
6//! a `current_weight` that accumulates its configured weight every selection
7//! and is decremented by the total weight whenever it's picked. That spreads
8//! high-weight backends evenly through the sequence instead of bursting them
9//! consecutively — weights `5, 1, 1` select roughly `A A B A C A A` (repeating),
10//! never five `A`s in a row, unlike a flat pre-expanded rotation list would.
11//!
12//! Backends are contacted over plain HTTP/1.1, or over TLS when the backend
13//! URL uses an `https://`, `h2s://`, or `grpcs://` scheme (requires the
14//! `http-client` or `http2` feature — both pull in `rustls`). If a backend is
15//! unavailable the next one in the fallback order is tried; after exhausting
16//! all backends the middleware returns `502 Bad Gateway`.
17//!
18//! A group's members can also come from a [`crate::service_discovery::BackendPool`]
19//! instead of a fixed URL — see [`WeightedPool`] / [`CanaryLayer::add_pool`] —
20//! so e.g. "10% of traffic to the canary group" keeps working as pods in that
21//! group come and go, without touching the weight itself.
22//!
23//! Weights can be replaced at runtime with [`CanaryLayer::update`] — clone the
24//! layer *before* wrapping it to keep a handle for later:
25//!
26//! ```rust,no_run
27//! use rust_web_server::app::App;
28//! use rust_web_server::core::New;
29//! use rust_web_server::canary::{CanaryLayer, WeightedBackend};
30//! use rust_web_server::middleware::WithMiddleware;
31//!
32//! // 75 % of traffic → stable, 25 % → canary
33//! let layer = CanaryLayer::new(vec![
34//!     WeightedBackend::new("http://stable:8080", 3),
35//!     WeightedBackend::new("http://canary:8080", 1),
36//! ])
37//! .path_prefix("/api");
38//!
39//! let handle = layer.clone(); // keep this — `layer` moves into `.wrap(...)`
40//! let app = WithMiddleware::new(App::new()).wrap(layer);
41//!
42//! // Later — e.g. from an admin endpoint or a rollout script — shift more
43//! // traffic to canary without restarting the process:
44//! handle.update(
45//!     vec![
46//!         WeightedBackend::new("http://stable:8080", 1),
47//!         WeightedBackend::new("http://canary:8080", 1),
48//!     ],
49//!     vec![],
50//! );
51//! ```
52
53#[cfg(test)]
54mod tests;
55
56use std::sync::atomic::{AtomicUsize, Ordering};
57use std::sync::{Arc, Mutex};
58use std::time::Duration;
59
60use crate::application::Application;
61use crate::core::New;
62use crate::middleware::Middleware;
63use crate::mime_type::MimeType;
64use crate::range::Range;
65use crate::request::Request;
66use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
67use crate::server::ConnectionInfo;
68use crate::service_discovery::BackendPool;
69
70// ── WeightedBackend / WeightedPool ────────────────────────────────────────────
71
72/// A single fixed backend URL together with a relative traffic weight.
73///
74/// A weight of 0 causes the backend to be skipped entirely.
75#[derive(Clone)]
76pub struct WeightedBackend {
77    pub url: String,
78    pub weight: u32,
79}
80
81impl WeightedBackend {
82    /// Create a new weighted backend.
83    pub fn new(url: impl Into<String>, weight: u32) -> Self {
84        Self { url: url.into(), weight }
85    }
86}
87
88/// A dynamically-discovered group of backends (a [`BackendPool`]) together
89/// with a relative traffic weight for the *group as a whole*. Which specific
90/// pool member answers a given request is a plain round-robin over
91/// `pool.backends()` at the time of selection — the weight only controls how
92/// often this group is picked relative to other groups/backends, not which
93/// member within it.
94///
95/// A weight of 0 causes the group to be skipped entirely. Always treated as
96/// a plain HTTP/1.1 backend — `BackendPool` addresses are bare `"host:port"`
97/// strings with no scheme to carry TLS intent.
98#[derive(Clone)]
99pub struct WeightedPool {
100    pub pool: BackendPool,
101    pub weight: u32,
102}
103
104impl WeightedPool {
105    /// Create a new weighted pool.
106    pub fn new(pool: BackendPool, weight: u32) -> Self {
107        Self { pool, weight }
108    }
109}
110
111// ── internal state ────────────────────────────────────────────────────────────
112
113enum Target {
114    /// A fixed `(host, port, tls)`, parsed once from a [`WeightedBackend`] URL.
115    Static(String, u16, bool),
116    /// A live-discovered group; round-robins its own current members.
117    Pool(BackendPool, AtomicUsize),
118}
119
120struct SwrrEntry {
121    target: Target,
122    /// Configured weight — fixed until the next [`CanaryLayer::update`] or
123    /// [`CanaryLayer::add_pool`] call.
124    weight: i64,
125    /// Evolves every [`CanaryState::tick`] call; this is the actual SWRR state.
126    current_weight: i64,
127}
128
129struct CanaryState {
130    entries: Vec<SwrrEntry>,
131    total_weight: i64,
132}
133
134impl CanaryState {
135    fn new() -> Self {
136        Self { entries: Vec::new(), total_weight: 0 }
137    }
138
139    fn push_static(&mut self, host: String, port: u16, tls: bool, weight: u32) {
140        if weight == 0 {
141            return;
142        }
143        self.total_weight += weight as i64;
144        self.entries.push(SwrrEntry {
145            target: Target::Static(host, port, tls),
146            weight: weight as i64,
147            current_weight: 0,
148        });
149    }
150
151    fn push_pool(&mut self, pool: BackendPool, weight: u32) {
152        if weight == 0 {
153            return;
154        }
155        self.total_weight += weight as i64;
156        self.entries.push(SwrrEntry {
157            target: Target::Pool(pool, AtomicUsize::new(0)),
158            weight: weight as i64,
159            current_weight: 0,
160        });
161    }
162
163    /// One smooth-weighted-round-robin step (the nginx algorithm): every
164    /// entry's `current_weight` accumulates its configured weight, the
165    /// maximum is picked, then that entry's `current_weight` is reduced by
166    /// the total weight. Returns entry indices in fallback-try order — the
167    /// primary pick first, then the rest ranked by (post-tick)
168    /// `current_weight` descending.
169    ///
170    /// Ranking the *rest* is a read-only sort with no further mutation, so
171    /// trying several backends as failover for one request doesn't perturb
172    /// the sequence subsequent requests see — only one entry's state changes
173    /// per `tick()` call, exactly as if a single backend had been chosen.
174    fn tick(&mut self) -> Vec<usize> {
175        if self.entries.is_empty() {
176            return Vec::new();
177        }
178        for e in &mut self.entries {
179            e.current_weight += e.weight;
180        }
181        let best = self
182            .entries
183            .iter()
184            .enumerate()
185            .max_by_key(|(_, e)| e.current_weight)
186            .map(|(i, _)| i)
187            .unwrap();
188        self.entries[best].current_weight -= self.total_weight;
189
190        let weights: Vec<i64> = self.entries.iter().map(|e| e.current_weight).collect();
191        let mut order: Vec<usize> = (0..self.entries.len()).collect();
192        order.sort_by(|&a, &b| {
193            if a == best {
194                return std::cmp::Ordering::Less;
195            }
196            if b == best {
197                return std::cmp::Ordering::Greater;
198            }
199            weights[b].cmp(&weights[a])
200        });
201        order
202    }
203}
204
205// ── CanaryLayer ───────────────────────────────────────────────────────────────
206
207/// Weighted traffic-splitting proxy middleware.
208///
209/// `Clone` is cheap and shares state — all clones distribute traffic against
210/// the same underlying [`CanaryState`], so a [`CanaryLayer::update`] on one
211/// clone is immediately visible to a clone already wrapped into a running
212/// `Application` (see the module docs for the clone-before-wrapping pattern).
213#[derive(Clone)]
214pub struct CanaryLayer {
215    state: Arc<Mutex<CanaryState>>,
216    connect_timeout: Duration,
217    read_timeout: Duration,
218    path_prefix: Option<String>,
219}
220
221impl CanaryLayer {
222    /// Build a `CanaryLayer` from the given weighted backends.
223    ///
224    /// Backends with `weight == 0` are ignored. Equivalent to
225    /// `CanaryLayer::from_parts(backends, vec![])`.
226    pub fn new(backends: Vec<WeightedBackend>) -> Self {
227        Self::from_parts(backends, Vec::new())
228    }
229
230    /// Build a `CanaryLayer` whose groups are all dynamically-discovered
231    /// [`BackendPool`]s rather than fixed URLs. Equivalent to
232    /// `CanaryLayer::from_parts(vec![], pools)`.
233    pub fn with_pools(pools: Vec<WeightedPool>) -> Self {
234        Self::from_parts(Vec::new(), pools)
235    }
236
237    /// Build a `CanaryLayer` from a mix of fixed backends and dynamic pools.
238    pub fn from_parts(backends: Vec<WeightedBackend>, pools: Vec<WeightedPool>) -> Self {
239        let mut state = CanaryState::new();
240        for wb in backends {
241            if let Some((host, port, tls)) = parse_backend_url(&wb.url) {
242                state.push_static(host, port, tls, wb.weight);
243            }
244        }
245        for wp in pools {
246            state.push_pool(wp.pool, wp.weight);
247        }
248        Self {
249            state: Arc::new(Mutex::new(state)),
250            connect_timeout: Duration::from_secs(5),
251            read_timeout: Duration::from_secs(30),
252            path_prefix: None,
253        }
254    }
255
256    /// Append one more dynamically-discovered group to an already-built
257    /// layer (e.g. adding a canary `BackendPool` alongside stable backends
258    /// passed to [`CanaryLayer::new`]). A weight of 0 is a no-op.
259    ///
260    /// This is a builder method for use before `.wrap(...)` — for changing
261    /// an already-wrapped, already-running layer's configuration, use
262    /// [`CanaryLayer::update`] instead.
263    pub fn add_pool(self, pool: BackendPool, weight: u32) -> Self {
264        self.state.lock().unwrap().push_pool(pool, weight);
265        self
266    }
267
268    /// Replace the entire backend/pool configuration in place — the runtime
269    /// equivalent of building a fresh layer with
270    /// [`CanaryLayer::from_parts`], except every existing clone of this
271    /// layer (including one already wrapped into a running `Application`)
272    /// picks up the change on its very next request. No restart required.
273    pub fn update(&self, backends: Vec<WeightedBackend>, pools: Vec<WeightedPool>) {
274        let mut new_state = CanaryState::new();
275        for wb in backends {
276            if let Some((host, port, tls)) = parse_backend_url(&wb.url) {
277                new_state.push_static(host, port, tls, wb.weight);
278            }
279        }
280        for wp in pools {
281            new_state.push_pool(wp.pool, wp.weight);
282        }
283        *self.state.lock().unwrap() = new_state;
284    }
285
286    /// Only proxy requests whose URI starts with `prefix`; pass others through.
287    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
288        self.path_prefix = Some(prefix.into());
289        self
290    }
291
292    /// Override the TCP connect timeout (default: 5 000 ms).
293    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
294        self.connect_timeout = Duration::from_millis(ms);
295        self
296    }
297
298    /// Override the response read timeout (default: 30 000 ms).
299    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
300        self.read_timeout = Duration::from_millis(ms);
301        self
302    }
303
304    /// Runs one SWRR tick and resolves the resulting fallback order into
305    /// actual dial targets — a `Pool` entry expands into *all* of its
306    /// current members (starting from its own round-robin cursor), so a
307    /// request exhausts every live member of its chosen group before
308    /// falling through to the next group in the order. Pure/no I/O — safe
309    /// to unit test directly.
310    fn next_candidates(&self) -> Vec<(String, u16, bool)> {
311        let mut state = self.state.lock().unwrap();
312        let order = state.tick();
313        let mut out = Vec::new();
314        for idx in order {
315            match &state.entries[idx].target {
316                Target::Static(host, port, tls) => out.push((host.clone(), *port, *tls)),
317                Target::Pool(pool, cursor) => {
318                    let members = pool.backends();
319                    if members.is_empty() {
320                        continue;
321                    }
322                    let n = members.len();
323                    let start = cursor.fetch_add(1, Ordering::Relaxed);
324                    for i in 0..n {
325                        if let Some((host, port)) = split_host_port(&members[(start + i) % n]) {
326                            out.push((host, port, false));
327                        }
328                    }
329                }
330            }
331        }
332        out
333    }
334
335    /// Try candidates in fallback order until one succeeds.
336    fn proxy(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
337        let candidates = self.next_candidates();
338        if candidates.is_empty() {
339            return Err("CanaryLayer: no backends in rotation".to_string());
340        }
341        for (host, port, tls) in &candidates {
342            let result = if *tls {
343                #[cfg(any(feature = "http-client", feature = "http2"))]
344                {
345                    crate::proxy::proxy_https1(
346                        request,
347                        &connection.client.ip,
348                        host,
349                        *port,
350                        self.connect_timeout,
351                        self.read_timeout,
352                    )
353                }
354                #[cfg(not(any(feature = "http-client", feature = "http2")))]
355                {
356                    Err("CanaryLayer: TLS backend requires the http-client or http2 feature".to_string())
357                }
358            } else {
359                crate::proxy::proxy_http1(
360                    request,
361                    &connection.client.ip,
362                    host,
363                    *port,
364                    self.connect_timeout,
365                    self.read_timeout,
366                )
367            };
368            if let Ok(resp) = result {
369                return Ok(resp);
370            }
371        }
372        Err("CanaryLayer: all backends failed".to_string())
373    }
374}
375
376impl Middleware for CanaryLayer {
377    fn handle(
378        &self,
379        request: &Request,
380        connection: &ConnectionInfo,
381        next: &dyn Application,
382    ) -> Result<Response, String> {
383        if let Some(prefix) = &self.path_prefix {
384            if !request.request_uri.starts_with(prefix.as_str()) {
385                return next.execute(request, connection);
386            }
387        }
388        match self.proxy(request, connection) {
389            Ok(resp) => Ok(resp),
390            Err(_) => Ok(bad_gateway()),
391        }
392    }
393}
394
395// ── helpers ───────────────────────────────────────────────────────────────────
396
397/// Parse a backend URL of the form `[scheme://]host[:port][/path]` into
398/// `(host, port, tls)`.
399///
400/// `https://`, `h2s://`, and `grpcs://` set `tls = true` and default to port
401/// 443; `http://`, `h2://`, `grpc://`, and a bare `host[:port]` set
402/// `tls = false` and default to port 80 — matching `proxy::Backend::parse`'s
403/// scheme conventions.
404fn parse_backend_url(url: &str) -> Option<(String, u16, bool)> {
405    let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("https://") {
406        (r, true, 443u16)
407    } else if let Some(r) = url.strip_prefix("h2s://") {
408        (r, true, 443u16)
409    } else if let Some(r) = url.strip_prefix("grpcs://") {
410        (r, true, 443u16)
411    } else if let Some(r) = url.strip_prefix("http://") {
412        (r, false, 80u16)
413    } else if let Some(r) = url.strip_prefix("h2://") {
414        (r, false, 80u16)
415    } else if let Some(r) = url.strip_prefix("grpc://") {
416        (r, false, 80u16)
417    } else {
418        (url, false, 80u16)
419    };
420    // Drop any path component
421    let host_port = rest.split('/').next().unwrap_or(rest);
422    let (host, port) = if let Some(colon) = host_port.rfind(':') {
423        let port_str = &host_port[colon + 1..];
424        if let Ok(p) = port_str.parse::<u16>() {
425            (host_port[..colon].to_string(), p)
426        } else {
427            (host_port.to_string(), default_port)
428        }
429    } else {
430        (host_port.to_string(), default_port)
431    };
432    if host.is_empty() { None } else { Some((host, port, tls)) }
433}
434
435/// Splits a [`BackendPool`]-style `"host:port"` address. `BackendPool`
436/// addresses have no scheme, so a pool-sourced target is always plain HTTP.
437fn split_host_port(addr: &str) -> Option<(String, u16)> {
438    let colon = addr.rfind(':')?;
439    let port: u16 = addr[colon + 1..].parse().ok()?;
440    let host = addr[..colon].to_string();
441    if host.is_empty() { None } else { Some((host, port)) }
442}
443
444fn bad_gateway() -> Response {
445    let cr = Range::get_content_range(
446        b"502 Bad Gateway".to_vec(),
447        MimeType::TEXT_PLAIN.to_string(),
448    );
449    let mut r = Response::new();
450    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
451    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
452    r.content_range_list = vec![cr];
453    r
454}