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