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}