Skip to main content

reddb_server/server/
http_limits.rs

1//! Resolution of the HTTP admission and execution knobs.
2//!
3//! The values are configurable through the standard precedence chain
4//! used elsewhere in the boot path:
5//!
6//!   flag > red_config > env > built-in default
7//!
8//! Built-in defaults preserve the existing request envelope and add a
9//! CPU-sized synchronous execution bound:
10//!   - max_handlers      = (2 * num_cpus).clamp(8, 256)
11//!   - execution_workers = num_cpus, clamped to max_handlers
12//!   - handler_timeout   = 30_000 ms
13//!   - retry_after_secs  = 5
14//!   - max_inflight_per_principal = 64   (issue #934; 0 disables)
15//!
16//! Each knob is validated at parse time and at resolution time so a
17//! stale red_config value cannot corrupt the running server.
18
19/// Lower bound for `handler_timeout_ms`. Anything below this is so
20/// short the deadline trips on healthy requests; we reject the value.
21pub const MIN_HANDLER_TIMEOUT_MS: u64 = 100;
22/// Inclusive bounds for `retry_after_secs`. Below 1s means clients
23/// hammer the server; above 30s means a transient overload looks like
24/// a permanent outage to load balancers.
25pub const MIN_RETRY_AFTER_SECS: u64 = 1;
26pub const MAX_RETRY_AFTER_SECS: u64 = 30;
27
28/// Built-in default for `max_handlers`. Matches
29/// `HttpConnectionLimiter::with_default_cap`.
30pub fn default_max_handlers() -> usize {
31    let cores = std::thread::available_parallelism()
32        .map(|n| n.get())
33        .unwrap_or(1);
34    (2 * cores).clamp(8, 256)
35}
36
37/// Synchronous engine calls are CPU/storage work, not connection work. Keep
38/// their default parallelism tied to the CPU budget while the larger
39/// `max_handlers` allowance becomes the bounded wait envelope.
40pub fn default_execution_workers() -> usize {
41    std::thread::available_parallelism()
42        .map(|parallelism| parallelism.get())
43        .unwrap_or(1)
44}
45
46pub const DEFAULT_HANDLER_TIMEOUT_MS: u64 = 30_000;
47pub const DEFAULT_RETRY_AFTER_SECS: u64 = 5;
48
49/// Built-in default for `max_inflight_per_principal` (issue #934). Bounds
50/// any single principal's concurrent in-flight requests at the async edge so
51/// one caller can't drain the whole global handler cap and starve the rest.
52/// `0` disables the per-principal cap entirely; a single-tenant deployment
53/// can set it there to pay nothing. Chosen below the typical multi-core
54/// global cap (256) so it provides real fairness headroom, while sitting
55/// above the global cap on tiny boxes (where there is no abuse pressure) so
56/// it never trips spuriously.
57pub const DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL: usize = 64;
58
59/// Validate a `max_handlers` candidate from any source. Returns the
60/// value unchanged on success.
61pub fn validate_max_handlers(value: usize) -> Result<usize, String> {
62    if value == 0 {
63        return Err("http max_handlers must be >= 1".to_string());
64    }
65    Ok(value)
66}
67
68pub fn validate_execution_workers(value: usize) -> Result<usize, String> {
69    if value == 0 {
70        return Err("http execution_workers must be >= 1".to_string());
71    }
72    Ok(value)
73}
74
75/// Validate a `max_inflight_per_principal` candidate (issue #934). Every
76/// `usize` is acceptable: a positive value caps each principal's concurrent
77/// in-flight requests, and `0` disables the per-principal cap. Present for
78/// symmetry with the other knobs so the CLI parser can run all four through
79/// the same validated-parse helper.
80pub fn validate_max_inflight_per_principal(value: usize) -> Result<usize, String> {
81    Ok(value)
82}
83
84pub fn validate_handler_timeout_ms(value: u64) -> Result<u64, String> {
85    if value < MIN_HANDLER_TIMEOUT_MS {
86        return Err(format!(
87            "http handler_timeout_ms must be >= {MIN_HANDLER_TIMEOUT_MS}"
88        ));
89    }
90    Ok(value)
91}
92
93pub fn validate_retry_after_secs(value: u64) -> Result<u64, String> {
94    if !(MIN_RETRY_AFTER_SECS..=MAX_RETRY_AFTER_SECS).contains(&value) {
95        return Err(format!(
96            "http retry_after_secs must be in [{MIN_RETRY_AFTER_SECS}, {MAX_RETRY_AFTER_SECS}]"
97        ));
98    }
99    Ok(value)
100}
101
102/// CLI-layer input. Each pair holds the already-validated value coming
103/// from a flag and from an env var, respectively. The resolver applies
104/// the `flag > red_config > env > default` precedence using these
105/// inputs plus a config-store lookup.
106#[derive(Debug, Default, Clone)]
107pub struct HttpLimitsCliInput {
108    pub max_handlers_flag: Option<usize>,
109    pub max_handlers_env: Option<usize>,
110    pub execution_workers_flag: Option<usize>,
111    pub execution_workers_env: Option<usize>,
112    pub handler_timeout_ms_flag: Option<u64>,
113    pub handler_timeout_ms_env: Option<u64>,
114    pub retry_after_secs_flag: Option<u64>,
115    pub retry_after_secs_env: Option<u64>,
116    pub max_inflight_per_principal_flag: Option<usize>,
117    pub max_inflight_per_principal_env: Option<usize>,
118}
119
120/// Resolved values after applying the full precedence chain. Stamped
121/// into both the `RedDBServer` and the startup log line.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct HttpLimitsResolved {
124    pub max_handlers: usize,
125    /// Maximum synchronous engine calls running concurrently. Values from
126    /// configuration are clamped to `max_handlers`, which is the outer
127    /// admitted-request envelope.
128    pub execution_workers: usize,
129    pub handler_timeout_ms: u64,
130    pub retry_after_secs: u64,
131    /// Per-principal concurrent in-flight cap (issue #934). `0` disables.
132    pub max_inflight_per_principal: usize,
133}
134
135impl HttpLimitsResolved {
136    pub fn builtin_defaults() -> Self {
137        let max_handlers = default_max_handlers();
138        Self {
139            max_handlers,
140            execution_workers: default_execution_workers().min(max_handlers),
141            handler_timeout_ms: DEFAULT_HANDLER_TIMEOUT_MS,
142            retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
143            max_inflight_per_principal: DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL,
144        }
145    }
146}
147
148/// Apply the `flag > red_config > env > default` chain.
149///
150/// `config_lookup` is a closure so this function is independent of the
151/// runtime/config-store type — keeps the resolver pure and testable.
152/// Each lookup returns the raw text value stored under the given key,
153/// matching how `set_config_tree` persists scalars.
154pub fn resolve_http_limits<F>(input: &HttpLimitsCliInput, config_lookup: F) -> HttpLimitsResolved
155where
156    F: Fn(&str) -> Option<String>,
157{
158    let defaults = HttpLimitsResolved::builtin_defaults();
159
160    let max_handlers = input
161        .max_handlers_flag
162        .or_else(|| {
163            config_lookup("red.http.max_handlers")
164                .and_then(|raw| raw.parse::<usize>().ok())
165                .and_then(|v| validate_max_handlers(v).ok())
166        })
167        .or(input.max_handlers_env)
168        .unwrap_or(defaults.max_handlers);
169
170    let execution_workers = input
171        .execution_workers_flag
172        .or_else(|| {
173            config_lookup("red.http.execution_workers")
174                .and_then(|raw| raw.parse::<usize>().ok())
175                .and_then(|value| validate_execution_workers(value).ok())
176        })
177        .or(input.execution_workers_env)
178        .unwrap_or(defaults.execution_workers)
179        .min(max_handlers);
180
181    let handler_timeout_ms = input
182        .handler_timeout_ms_flag
183        .or_else(|| {
184            config_lookup("red.http.handler_timeout_ms")
185                .and_then(|raw| raw.parse::<u64>().ok())
186                .and_then(|v| validate_handler_timeout_ms(v).ok())
187        })
188        .or(input.handler_timeout_ms_env)
189        .unwrap_or(defaults.handler_timeout_ms);
190
191    let retry_after_secs = input
192        .retry_after_secs_flag
193        .or_else(|| {
194            config_lookup("red.http.retry_after_secs")
195                .and_then(|raw| raw.parse::<u64>().ok())
196                .and_then(|v| validate_retry_after_secs(v).ok())
197        })
198        .or(input.retry_after_secs_env)
199        .unwrap_or(defaults.retry_after_secs);
200
201    let max_inflight_per_principal = input
202        .max_inflight_per_principal_flag
203        .or_else(|| {
204            config_lookup("red.http.max_inflight_per_principal")
205                .and_then(|raw| raw.parse::<usize>().ok())
206                .and_then(|v| validate_max_inflight_per_principal(v).ok())
207        })
208        .or(input.max_inflight_per_principal_env)
209        .unwrap_or(defaults.max_inflight_per_principal);
210
211    HttpLimitsResolved {
212        max_handlers,
213        execution_workers,
214        handler_timeout_ms,
215        retry_after_secs,
216        max_inflight_per_principal,
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::collections::HashMap;
224
225    fn no_config() -> impl Fn(&str) -> Option<String> {
226        |_| None
227    }
228
229    fn map_lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
230        move |key| map.get(key).map(|v| v.to_string())
231    }
232
233    #[test]
234    fn defaults_when_nothing_set() {
235        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
236        assert_eq!(resolved, HttpLimitsResolved::builtin_defaults());
237    }
238
239    #[test]
240    fn flag_wins_over_env_and_default() {
241        let input = HttpLimitsCliInput {
242            max_handlers_flag: Some(16),
243            max_handlers_env: Some(99),
244            execution_workers_flag: Some(4),
245            execution_workers_env: Some(12),
246            handler_timeout_ms_flag: Some(5_000),
247            handler_timeout_ms_env: Some(7_000),
248            retry_after_secs_flag: Some(3),
249            retry_after_secs_env: Some(7),
250            ..Default::default()
251        };
252        let resolved = resolve_http_limits(&input, no_config());
253        assert_eq!(resolved.max_handlers, 16);
254        assert_eq!(resolved.execution_workers, 4);
255        assert_eq!(resolved.handler_timeout_ms, 5_000);
256        assert_eq!(resolved.retry_after_secs, 3);
257    }
258
259    #[test]
260    fn flag_wins_over_red_config() {
261        let input = HttpLimitsCliInput {
262            max_handlers_flag: Some(16),
263            execution_workers_flag: Some(4),
264            handler_timeout_ms_flag: Some(5_000),
265            retry_after_secs_flag: Some(3),
266            ..Default::default()
267        };
268        let lookup = map_lookup(HashMap::from([
269            ("red.http.max_handlers", "64"),
270            ("red.http.execution_workers", "32"),
271            ("red.http.handler_timeout_ms", "9000"),
272            ("red.http.retry_after_secs", "9"),
273        ]));
274        let resolved = resolve_http_limits(&input, lookup);
275        assert_eq!(resolved.max_handlers, 16);
276        assert_eq!(resolved.execution_workers, 4);
277        assert_eq!(resolved.handler_timeout_ms, 5_000);
278        assert_eq!(resolved.retry_after_secs, 3);
279    }
280
281    #[test]
282    fn red_config_wins_over_env() {
283        let input = HttpLimitsCliInput {
284            max_handlers_env: Some(99),
285            execution_workers_env: Some(12),
286            handler_timeout_ms_env: Some(7_000),
287            retry_after_secs_env: Some(7),
288            ..Default::default()
289        };
290        let lookup = map_lookup(HashMap::from([
291            ("red.http.max_handlers", "64"),
292            ("red.http.execution_workers", "8"),
293            ("red.http.handler_timeout_ms", "9000"),
294            ("red.http.retry_after_secs", "9"),
295        ]));
296        let resolved = resolve_http_limits(&input, lookup);
297        assert_eq!(resolved.max_handlers, 64);
298        assert_eq!(resolved.execution_workers, 8);
299        assert_eq!(resolved.handler_timeout_ms, 9_000);
300        assert_eq!(resolved.retry_after_secs, 9);
301    }
302
303    #[test]
304    fn env_wins_over_default() {
305        let input = HttpLimitsCliInput {
306            max_handlers_env: Some(11),
307            execution_workers_env: Some(3),
308            handler_timeout_ms_env: Some(1_500),
309            retry_after_secs_env: Some(2),
310            ..Default::default()
311        };
312        let resolved = resolve_http_limits(&input, no_config());
313        assert_eq!(resolved.max_handlers, 11);
314        assert_eq!(resolved.execution_workers, 3);
315        assert_eq!(resolved.handler_timeout_ms, 1_500);
316        assert_eq!(resolved.retry_after_secs, 2);
317    }
318
319    #[test]
320    fn invalid_red_config_is_ignored_in_favor_of_lower_layers() {
321        // Garbage in red_config — must not break boot. Env value wins;
322        // if env is absent, default wins.
323        let input = HttpLimitsCliInput {
324            max_handlers_env: Some(11),
325            ..Default::default()
326        };
327        let lookup = map_lookup(HashMap::from([
328            ("red.http.max_handlers", "0"),        // rejected by validate
329            ("red.http.execution_workers", "0"),   // rejected by validate
330            ("red.http.handler_timeout_ms", "5"),  // rejected by validate
331            ("red.http.retry_after_secs", "9999"), // rejected by validate
332        ]));
333        let resolved = resolve_http_limits(&input, lookup);
334        // max_handlers: red_config invalid -> env (11)
335        assert_eq!(resolved.max_handlers, 11);
336        assert_eq!(
337            resolved.execution_workers,
338            HttpLimitsResolved::builtin_defaults()
339                .execution_workers
340                .min(11)
341        );
342        // handler_timeout_ms: red_config invalid, no env -> default
343        assert_eq!(resolved.handler_timeout_ms, DEFAULT_HANDLER_TIMEOUT_MS);
344        // retry_after_secs: red_config invalid, no env -> default
345        assert_eq!(resolved.retry_after_secs, DEFAULT_RETRY_AFTER_SECS);
346    }
347
348    #[test]
349    fn validators_reject_zero_equivalent_values() {
350        assert!(validate_max_handlers(0).is_err());
351        assert!(validate_max_handlers(1).is_ok());
352
353        assert!(validate_execution_workers(0).is_err());
354        assert!(validate_execution_workers(1).is_ok());
355
356        assert!(validate_handler_timeout_ms(0).is_err());
357        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS - 1).is_err());
358        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS).is_ok());
359
360        assert!(validate_retry_after_secs(0).is_err());
361        assert!(validate_retry_after_secs(MIN_RETRY_AFTER_SECS).is_ok());
362        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS).is_ok());
363        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS + 1).is_err());
364    }
365
366    #[test]
367    fn default_max_handlers_in_bounds() {
368        let cap = default_max_handlers();
369        assert!((8..=256).contains(&cap));
370    }
371
372    #[test]
373    fn execution_workers_are_positive_and_never_exceed_handler_envelope() {
374        let defaults = HttpLimitsResolved::builtin_defaults();
375        assert!(defaults.execution_workers >= 1);
376        assert!(defaults.execution_workers <= defaults.max_handlers);
377
378        let input = HttpLimitsCliInput {
379            max_handlers_flag: Some(2),
380            execution_workers_flag: Some(16),
381            ..Default::default()
382        };
383        assert_eq!(
384            resolve_http_limits(&input, no_config()).execution_workers,
385            2
386        );
387    }
388
389    #[test]
390    fn max_inflight_per_principal_follows_precedence_chain() {
391        // default
392        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
393        assert_eq!(
394            resolved.max_inflight_per_principal,
395            DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL
396        );
397
398        // env over default
399        let input = HttpLimitsCliInput {
400            max_inflight_per_principal_env: Some(17),
401            ..Default::default()
402        };
403        assert_eq!(
404            resolve_http_limits(&input, no_config()).max_inflight_per_principal,
405            17
406        );
407
408        // red_config over env
409        let input = HttpLimitsCliInput {
410            max_inflight_per_principal_env: Some(17),
411            ..Default::default()
412        };
413        let lookup = map_lookup(HashMap::from([(
414            "red.http.max_inflight_per_principal",
415            "9",
416        )]));
417        assert_eq!(
418            resolve_http_limits(&input, lookup).max_inflight_per_principal,
419            9
420        );
421
422        // flag over everything
423        let input = HttpLimitsCliInput {
424            max_inflight_per_principal_flag: Some(3),
425            max_inflight_per_principal_env: Some(17),
426            ..Default::default()
427        };
428        let lookup = map_lookup(HashMap::from([(
429            "red.http.max_inflight_per_principal",
430            "9",
431        )]));
432        assert_eq!(
433            resolve_http_limits(&input, lookup).max_inflight_per_principal,
434            3
435        );
436    }
437
438    #[test]
439    fn max_inflight_per_principal_zero_disables_and_is_honored() {
440        // 0 is a legal value (disables the per-principal cap) and must
441        // survive the resolve chain rather than being treated as unset.
442        let lookup = map_lookup(HashMap::from([(
443            "red.http.max_inflight_per_principal",
444            "0",
445        )]));
446        assert_eq!(
447            resolve_http_limits(&HttpLimitsCliInput::default(), lookup).max_inflight_per_principal,
448            0
449        );
450        assert!(validate_max_inflight_per_principal(0).is_ok());
451    }
452}