1pub const MIN_HANDLER_TIMEOUT_MS: u64 = 100;
22pub const MIN_RETRY_AFTER_SECS: u64 = 1;
26pub const MAX_RETRY_AFTER_SECS: u64 = 30;
27
28pub 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
37pub 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
49pub const DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL: usize = 64;
58
59pub 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
75pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct HttpLimitsResolved {
124 pub max_handlers: usize,
125 pub execution_workers: usize,
129 pub handler_timeout_ms: u64,
130 pub retry_after_secs: u64,
131 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
148pub 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 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"), ("red.http.execution_workers", "0"), ("red.http.handler_timeout_ms", "5"), ("red.http.retry_after_secs", "9999"), ]));
333 let resolved = resolve_http_limits(&input, lookup);
334 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 assert_eq!(resolved.handler_timeout_ms, DEFAULT_HANDLER_TIMEOUT_MS);
344 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 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 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 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 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 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}