Skip to main content

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