Skip to main content

rust_web_server/proxy_config/
upstream.rs

1//! What an `[[upstream]]` adds to choosing a backend in config mode
2//! (since 18.6.0): `weights`, cookie stickiness (`[upstream.sticky]`), a
3//! per-backend circuit breaker — passive health checking —
4//! (`[upstream.circuit_breaker]`), retries on another backend
5//! (`[upstream.retry]`), and the per-upstream metrics on `/metrics`.
6//!
7//! One [`UpstreamPolicy`] per declared upstream, shared by every route
8//! that proxies to it, rebuilt on reload.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex, OnceLock, RwLock};
12use std::time::Instant;
13
14use crate::circuit_breaker::{BreakerState, CircuitBreaker};
15
16/// `[upstream.sticky]`: keep a client on one backend with a cookie.
17#[derive(Debug, Clone, PartialEq)]
18pub struct StickyConfig {
19    /// The cookie's name.
20    pub cookie: String,
21    /// `Max-Age` in seconds; `None` = a session cookie.
22    pub max_age_secs: Option<u64>,
23}
24
25/// `[upstream.circuit_breaker]`: take a failing backend out of rotation.
26#[derive(Debug, Clone, PartialEq)]
27pub struct BreakerConfig {
28    /// Consecutive failures (connection errors, 502/503/504) that open it.
29    pub failure_threshold: u32,
30    /// How long an open backend is left alone before a probe request.
31    pub recovery_secs: u64,
32    /// Probe requests allowed at once while half-open.
33    pub half_open_probes: u32,
34}
35
36/// `[upstream.retry]`: try another backend.
37#[derive(Debug, Clone, PartialEq)]
38pub struct RetryConfig {
39    /// Further backends tried after the first (so up to `attempts + 1`).
40    pub attempts: u32,
41    /// Statuses retried besides connection errors (default 502, 503, 504).
42    pub status: Vec<i16>,
43}
44
45/// One upstream's selection state.
46pub(crate) struct UpstreamPolicy {
47    pub(crate) name: String,
48    /// The health-checked backend list (for metrics).
49    live: Arc<RwLock<Vec<String>>>,
50    weights: HashMap<String, u32>,
51    /// The configured backends in file order (for listing).
52    order: Vec<String>,
53    pub(crate) sticky: Option<StickyConfig>,
54    breaker: Option<Mutex<CircuitBreaker>>,
55    pub(crate) retry: Option<RetryConfig>,
56    /// Smooth weighted round-robin's current weights.
57    current: Mutex<HashMap<String, i64>>,
58}
59
60impl UpstreamPolicy {
61    pub(crate) fn new(
62        name: &str,
63        live: Arc<RwLock<Vec<String>>>,
64        backends: &[String],
65        weights: &[u32],
66        sticky: Option<StickyConfig>,
67        breaker: Option<&BreakerConfig>,
68        retry: Option<RetryConfig>,
69    ) -> Self {
70        UpstreamPolicy {
71            name: name.to_string(),
72            live,
73            // Every backend, weight 1 unless given (also the gauges' list).
74            weights: backends.iter().enumerate().map(|(i, b)| (b.clone(), weights.get(i).copied().unwrap_or(1))).collect(),
75            order: backends.to_vec(),
76            sticky,
77            breaker: breaker.map(|b| Mutex::new(CircuitBreaker::new(b.failure_threshold.max(1), b.recovery_secs).max_half_open_probes(b.half_open_probes))),
78            retry,
79            current: Mutex::new(HashMap::new()),
80        }
81    }
82
83    /// A backend's weight (1 unless configured).
84    pub(crate) fn weight(&self, backend: &str) -> u32 {
85        self.weights.get(backend).copied().unwrap_or(1).max(1)
86    }
87
88    /// Whether any backend has a weight other than 1.
89    pub(crate) fn weighted(&self) -> bool {
90        self.weights.values().any(|w| *w != 1)
91    }
92
93    /// Smooth weighted round-robin (nginx's): each pick raises every
94    /// candidate by its weight and lowers the chosen one by the total, so
95    /// weights 3:1 give a, a, b, a, ... — evenly interleaved.
96    pub(crate) fn weighted_round_robin(&self, candidates: &[String]) -> Option<String> {
97        let mut current = self.current.lock().unwrap_or_else(|e| e.into_inner());
98        let total: i64 = candidates.iter().map(|b| self.weight(b) as i64).sum();
99        let mut best: Option<(&String, i64)> = None;
100        for backend in candidates {
101            let value = current.entry(backend.clone()).or_insert(0);
102            *value += self.weight(backend) as i64;
103            if best.is_none_or(|(_, top)| *value > top) {
104                best = Some((backend, *value));
105            }
106        }
107        let (chosen, _) = best?;
108        *current.get_mut(chosen.as_str()).expect("just inserted") -= total;
109        Some(chosen.clone())
110    }
111
112    /// Weighted pick for `random`: `roll` is any number.
113    pub(crate) fn weighted_index(&self, candidates: &[String], roll: usize) -> usize {
114        let total: usize = candidates.iter().map(|b| self.weight(b) as usize).sum();
115        let mut point = roll % total.max(1);
116        for (i, backend) in candidates.iter().enumerate() {
117            let weight = self.weight(backend) as usize;
118            if point < weight {
119                return i;
120            }
121            point -= weight;
122        }
123        0
124    }
125
126    /// Whether the breaker lets a request through to `backend` now (a
127    /// half-open probe counts as one).
128    pub(crate) fn admits(&self, backend: &str) -> bool {
129        match &self.breaker {
130            Some(breaker) => breaker.lock().unwrap_or_else(|e| e.into_inner()).is_available(backend),
131            None => true,
132        }
133    }
134
135    /// Whether the breaker holds any of `backends` open.
136    pub(crate) fn any_open(&self, backends: &[String]) -> bool {
137        self.breaker.as_ref().is_some_and(|breaker| {
138            let breaker = breaker.lock().unwrap_or_else(|e| e.into_inner());
139            backends.iter().any(|b| breaker.state(b) != BreakerState::Closed)
140        })
141    }
142
143    pub(crate) fn record(&self, backend: &str, failed: bool) {
144        if let Some(breaker) = &self.breaker {
145            let mut breaker = breaker.lock().unwrap_or_else(|e| e.into_inner());
146            if failed {
147                breaker.record_failure(backend);
148            } else {
149                breaker.record_success(backend);
150            }
151        }
152    }
153
154    fn breaker_state(&self, backend: &str) -> Option<BreakerState> {
155        self.breaker.as_ref().map(|b| b.lock().unwrap_or_else(|e| e.into_inner()).state(backend))
156    }
157
158    /// Whether `status` from a backend should be retried elsewhere.
159    pub(crate) fn retries_status(&self, status: i16) -> bool {
160        self.retry.as_ref().is_some_and(|r| r.status.contains(&status))
161    }
162}
163
164/// A backend's id in the sticky cookie: 12 hex digits of its URL's SHA-256
165/// — stable across restarts and replicas, without revealing the address.
166pub(crate) fn backend_id(backend: &str) -> String {
167    crate::core::sha256::sha256(backend.as_bytes())[..6].iter().map(|b| format!("{b:02x}")).collect()
168}
169
170/// The value of cookie `name` in the request's `Cookie` headers.
171pub(crate) fn cookie_value(request: &crate::request::Request, name: &str) -> Option<String> {
172    request
173        .headers
174        .iter()
175        .filter(|h| h.name.eq_ignore_ascii_case("cookie"))
176        .flat_map(|h| h.value.split(';').map(str::trim).map(str::to_string).collect::<Vec<_>>())
177        .find_map(|pair| pair.split_once('=').filter(|(n, _)| *n == name).map(|(_, v)| v.to_string()))
178}
179
180/// Whether a request may be sent again to another backend: an idempotent
181/// method and a body still in memory.
182pub(crate) fn replayable(request: &crate::request::Request) -> bool {
183    matches!(request.method.as_str(), "GET" | "HEAD" | "OPTIONS" | "PUT" | "DELETE" | "TRACE") && !request.body.is_stream()
184}
185
186// ── metrics ───────────────────────────────────────────────────────────────────
187
188const BUCKETS: [f64; 11] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
189
190#[derive(Default)]
191struct BackendStats {
192    /// By status code, `"error"` for a request that got no response.
193    requests: HashMap<String, u64>,
194    buckets: [u64; 11],
195    sum: f64,
196    count: u64,
197}
198
199#[derive(Default)]
200struct Stats {
201    /// `(upstream, backend)` → stats.
202    backends: HashMap<(String, String), BackendStats>,
203    retries: HashMap<String, u64>,
204    /// `protocol = "h3"` upstreams: requests that fell back to TCP.
205    h3_fallbacks: HashMap<String, u64>,
206    /// The current configuration's upstreams, for gauges.
207    policies: Vec<Arc<UpstreamPolicy>>,
208}
209
210fn stats() -> &'static Mutex<Stats> {
211    static STATS: OnceLock<Mutex<Stats>> = OnceLock::new();
212    STATS.get_or_init(Default::default)
213}
214
215/// A request to `upstream` went over TCP because QUIC couldn't connect.
216#[cfg(feature = "http3")]
217pub(crate) fn observe_h3_fallback(upstream: &str) {
218    *stats().lock().unwrap_or_else(|e| e.into_inner()).h3_fallbacks.entry(upstream.to_string()).or_default() += 1;
219}
220
221/// Make `policies` the ones `/metrics` reports gauges for (each compile).
222pub(crate) fn register(policies: Vec<Arc<UpstreamPolicy>>) {
223    stats().lock().unwrap_or_else(|e| e.into_inner()).policies = policies;
224}
225
226// ── drain ─────────────────────────────────────────────────────────────────────
227
228/// Any backend drained: the per-request check is one load when none is.
229static ANY_DRAINED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
230
231fn drained_set() -> &'static RwLock<std::collections::HashSet<(String, String)>> {
232    static SET: OnceLock<RwLock<std::collections::HashSet<(String, String)>>> = OnceLock::new();
233    SET.get_or_init(RwLock::default)
234}
235
236/// Take `backend` of `upstream` out of rotation (`true`) or put it back.
237/// New requests skip a drained backend; requests already on it finish; its
238/// health checks go on. Kept across reloads (by name), not across restarts.
239pub(crate) fn set_drained(upstream: &str, backend: &str, drained: bool) {
240    let mut set = drained_set().write().unwrap_or_else(|e| e.into_inner());
241    let key = (upstream.to_string(), backend.to_string());
242    if drained {
243        set.insert(key);
244    } else {
245        set.remove(&key);
246    }
247    ANY_DRAINED.store(!set.is_empty(), std::sync::atomic::Ordering::SeqCst);
248}
249
250pub(crate) fn is_drained(upstream: &str, backend: &str) -> bool {
251    if !ANY_DRAINED.load(std::sync::atomic::Ordering::Relaxed) {
252        return false;
253    }
254    let set = drained_set().read().unwrap_or_else(|e| e.into_inner());
255    set.iter().any(|(u, b)| u == upstream && b == backend)
256}
257
258/// One backend of an upstream as the admin API shows it.
259#[derive(Debug, Clone, PartialEq)]
260pub(crate) struct BackendView {
261    pub(crate) url: String,
262    pub(crate) weight: u32,
263    /// In the health-checked live list.
264    pub(crate) healthy: bool,
265    /// `closed`, `open`, `half_open`; `None` without a circuit breaker.
266    pub(crate) breaker: Option<&'static str>,
267    pub(crate) drained: bool,
268}
269
270/// The current configuration's upstreams and their backends, in order.
271pub(crate) fn snapshot() -> Vec<(String, Vec<BackendView>)> {
272    let policies = stats().lock().unwrap_or_else(|e| e.into_inner()).policies.clone();
273    policies
274        .iter()
275        .map(|p| {
276            let live = p.live.read().unwrap_or_else(|e| e.into_inner()).clone();
277            let mut backends: Vec<&String> = p.weights.keys().collect();
278            backends.sort_by_key(|b| p.order.iter().position(|o| o == *b).unwrap_or(usize::MAX));
279            let views = backends
280                .into_iter()
281                .map(|b| BackendView {
282                    url: b.clone(),
283                    weight: p.weight(b),
284                    healthy: live.contains(b),
285                    breaker: p.breaker_state(b).map(|s| match s {
286                        BreakerState::Closed => "closed",
287                        BreakerState::Open => "open",
288                        BreakerState::HalfOpen => "half_open",
289                    }),
290                    drained: is_drained(&p.name, b),
291                })
292                .collect();
293            (p.name.clone(), views)
294        })
295        .collect()
296}
297
298/// One request to `backend` of `upstream`: its status (`None`: no
299/// response) and how long it took.
300pub(crate) fn observe(upstream: &str, backend: &str, status: Option<i16>, started: Instant) {
301    let secs = started.elapsed().as_secs_f64();
302    let mut stats = stats().lock().unwrap_or_else(|e| e.into_inner());
303    let entry = stats.backends.entry((upstream.to_string(), backend.to_string())).or_default();
304    *entry.requests.entry(status.map_or_else(|| "error".to_string(), |s| s.to_string())).or_default() += 1;
305    for (i, upper) in BUCKETS.iter().enumerate() {
306        if secs <= *upper {
307            entry.buckets[i] += 1;
308        }
309    }
310    entry.sum += secs;
311    entry.count += 1;
312}
313
314pub(crate) fn observe_retry(upstream: &str) {
315    *stats().lock().unwrap_or_else(|e| e.into_inner()).retries.entry(upstream.to_string()).or_default() += 1;
316}
317
318fn label(value: &str) -> String {
319    value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
320}
321
322/// The `rws_upstream_*` series for `/metrics`; empty without upstreams.
323pub(crate) fn prometheus_text() -> String {
324    let stats = stats().lock().unwrap_or_else(|e| e.into_inner());
325    let mut out = String::new();
326    if !stats.backends.is_empty() {
327        let mut keys: Vec<&(String, String)> = stats.backends.keys().collect();
328        keys.sort();
329        out.push_str("# HELP rws_upstream_requests_total Requests sent to each upstream backend, by response status (\"error\": no response)\n");
330        out.push_str("# TYPE rws_upstream_requests_total counter\n");
331        for key in &keys {
332            let entry = &stats.backends[*key];
333            let mut codes: Vec<&String> = entry.requests.keys().collect();
334            codes.sort();
335            for code in codes {
336                out.push_str(&format!(
337                    "rws_upstream_requests_total{{upstream=\"{}\",backend=\"{}\",code=\"{}\"}} {}\n",
338                    label(&key.0), label(&key.1), code, entry.requests[code]
339                ));
340            }
341        }
342        out.push_str("# HELP rws_upstream_request_duration_seconds Time from sending a request to an upstream backend to its response head\n");
343        out.push_str("# TYPE rws_upstream_request_duration_seconds histogram\n");
344        for key in &keys {
345            let entry = &stats.backends[*key];
346            let labels = format!("upstream=\"{}\",backend=\"{}\"", label(&key.0), label(&key.1));
347            for (i, upper) in BUCKETS.iter().enumerate() {
348                out.push_str(&format!("rws_upstream_request_duration_seconds_bucket{{{labels},le=\"{upper}\"}} {}\n", entry.buckets[i]));
349            }
350            out.push_str(&format!("rws_upstream_request_duration_seconds_bucket{{{labels},le=\"+Inf\"}} {}\n", entry.count));
351            out.push_str(&format!("rws_upstream_request_duration_seconds_sum{{{labels}}} {:.6}\n", entry.sum));
352            out.push_str(&format!("rws_upstream_request_duration_seconds_count{{{labels}}} {}\n", entry.count));
353        }
354    }
355    if !stats.retries.is_empty() {
356        let mut names: Vec<&String> = stats.retries.keys().collect();
357        names.sort();
358        out.push_str("# HELP rws_upstream_retries_total Requests sent again to another backend\n");
359        out.push_str("# TYPE rws_upstream_retries_total counter\n");
360        for name in names {
361            out.push_str(&format!("rws_upstream_retries_total{{upstream=\"{}\"}} {}\n", label(name), stats.retries[name]));
362        }
363    }
364    if !stats.policies.is_empty() {
365        out.push_str("# HELP rws_upstream_backend_up Whether a backend is in rotation: passing health checks and its circuit breaker not open (1) or not (0)\n");
366        out.push_str("# TYPE rws_upstream_backend_up gauge\n");
367        let mut breaker_lines = String::new();
368        for policy in &stats.policies {
369            let live = policy.live.read().unwrap_or_else(|e| e.into_inner()).clone();
370            let mut backends: Vec<&String> = policy.weights.keys().collect();
371            backends.sort();
372            for backend in backends {
373                let state = policy.breaker_state(backend);
374                let up = live.contains(backend) && state.as_ref().is_none_or(|s| *s != BreakerState::Open);
375                let labels = format!("upstream=\"{}\",backend=\"{}\"", label(&policy.name), label(backend));
376                out.push_str(&format!("rws_upstream_backend_up{{{labels}}} {}\n", u8::from(up)));
377                if let Some(state) = state {
378                    let value = match state {
379                        BreakerState::Closed => 0,
380                        BreakerState::HalfOpen => 1,
381                        BreakerState::Open => 2,
382                    };
383                    breaker_lines.push_str(&format!("rws_upstream_circuit_breaker_state{{{labels}}} {value}\n"));
384                }
385            }
386        }
387        if !breaker_lines.is_empty() {
388            out.push_str("# HELP rws_upstream_circuit_breaker_state Each backend's circuit breaker (0=closed, 1=half_open, 2=open)\n");
389            out.push_str("# TYPE rws_upstream_circuit_breaker_state gauge\n");
390            out.push_str(&breaker_lines);
391        }
392    }
393    if !stats.h3_fallbacks.is_empty() {
394        let mut names: Vec<&String> = stats.h3_fallbacks.keys().collect();
395        names.sort();
396        out.push_str("# HELP rws_upstream_h3_fallbacks_total Requests to a protocol = \"h3\" upstream sent over TCP because QUIC couldn't connect\n");
397        out.push_str("# TYPE rws_upstream_h3_fallbacks_total counter\n");
398        for name in names {
399            let upstream = name.replace('\\', "\\\\").replace('"', "\\\"");
400            out.push_str(&format!("rws_upstream_h3_fallbacks_total{{upstream=\"{upstream}\"}} {}\n", stats.h3_fallbacks[name]));
401        }
402    }
403    out
404}
405
406#[cfg(test)]
407mod tests;