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}