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}