1use serde::Deserialize;
2use std::{collections::HashSet, fs};
3use thiserror::Error;
4use tracing::{error, info, warn};
5
6fn resolve_env_vars_in_urls(endpoints: &mut [EndpointConfig]) {
7 use std::env;
8
9 for endpoint in endpoints {
10 if endpoint.url.starts_with("${") && endpoint.url.ends_with('}') {
11 let var_name = endpoint.url[2..endpoint.url.len() - 1].to_string();
12
13 if let Ok(value) = env::var(&var_name) {
14 endpoint.url = value;
15 info!(var_name = var_name, "Resolved endpoint URL from environment variable");
16 } else {
17 warn!(var_name = var_name, "Environment variable not found for endpoint URL");
18 }
19 }
20 }
21}
22
23#[derive(Debug, Error)]
24pub enum ConfigError {
25 #[error("Configuration error: {0}")]
26 ConfigError(String),
27}
28
29#[derive(Debug, Deserialize, Clone, Default)]
30pub struct Config {
31 pub server: Option<ServerConfig>,
32 pub balancer: Option<BalancerConfig>,
33}
34
35impl Config {
36 pub fn finalize(mut self) -> Result<Self, ConfigError> {
40 let mut server_cfg = self.server.take().unwrap_or_default();
41 server_cfg.bind_addr = server_cfg.bind_addr.or_else(|| Some(DEFAULT_BIND_ADDR.to_string()));
42 self.server = Some(server_cfg);
43
44 let mut balancer_cfg = self.balancer.take().unwrap_or_default();
45
46 balancer_cfg.health_check_interval_secs =
47 balancer_cfg.health_check_interval_secs.or(Some(DEFAULT_HEALTH_CHECK_INTERVAL_SECS));
48 balancer_cfg.health_check_timeout_secs =
49 balancer_cfg.health_check_timeout_secs.or(Some(DEFAULT_HEALTH_CHECK_TIMEOUT_SECS));
50 balancer_cfg.base_cooldown_secs =
51 balancer_cfg.base_cooldown_secs.or(Some(DEFAULT_BASE_COOLDOWN_SECS));
52 balancer_cfg.max_cooldown_secs =
53 balancer_cfg.max_cooldown_secs.or(Some(DEFAULT_MAX_COOLDOWN_SECS));
54
55 balancer_cfg.latency_smoothing_factor = Some(
56 balancer_cfg
57 .latency_smoothing_factor
58 .unwrap_or(DEFAULT_LATENCY_SMOOTHING_FACTOR)
59 .clamp(0.0, 1.0),
60 );
61
62 balancer_cfg.max_batch_size =
63 Some(balancer_cfg.max_batch_size.unwrap_or(DEFAULT_MAX_BATCH_SIZE).max(1));
64 balancer_cfg.max_concurrency =
65 Some(balancer_cfg.max_concurrency.unwrap_or(DEFAULT_MAX_CONCURRENCY).max(1));
66
67 balancer_cfg.connect_timeout_ms =
69 balancer_cfg.connect_timeout_ms.or(Some(DEFAULT_CONNECT_TIMEOUT_MS));
70 balancer_cfg.timeout_secs = balancer_cfg.timeout_secs.or(Some(DEFAULT_TIMEOUT_SECS));
71 balancer_cfg.pool_idle_timeout_secs =
72 balancer_cfg.pool_idle_timeout_secs.or(Some(DEFAULT_POOL_IDLE_TIMEOUT_SECS));
73 balancer_cfg.pool_max_idle_per_host =
74 balancer_cfg.pool_max_idle_per_host.or(Some(DEFAULT_POOL_MAX_IDLE_PER_HOST));
75
76 let mut endpoints = balancer_cfg.endpoints.take().unwrap_or_else(get_default_endpoints);
77
78 resolve_env_vars_in_urls(&mut endpoints);
79
80 endpoints = validate_and_dedupe_endpoints(endpoints)?;
81
82 for ep in &mut endpoints {
84 ep.rate_limit_per_sec = ep.rate_limit_per_sec.max(1);
85 ep.burst_size = ep.burst_size.max(1);
86 if ep.weight.is_none() {
87 ep.weight = Some(DEFAULT_ENDPOINT_WEIGHT);
88 }
89 }
90
91 balancer_cfg.endpoints = Some(endpoints);
92 self.balancer = Some(balancer_cfg);
93
94 Ok(self)
95 }
96}
97
98#[derive(Debug, Deserialize, Clone, Default)]
99pub struct ServerConfig {
100 pub bind_addr: Option<String>,
101}
102
103#[derive(Debug, Deserialize, Clone, Default)]
104pub struct BalancerConfig {
105 pub health_check_interval_secs: Option<u64>,
106 pub health_check_timeout_secs: Option<u64>,
107 pub base_cooldown_secs: Option<u64>,
108 pub max_cooldown_secs: Option<u64>,
109 pub latency_smoothing_factor: Option<f64>,
110 pub endpoints: Option<Vec<EndpointConfig>>,
111 pub max_batch_size: Option<usize>,
112 pub max_concurrency: Option<usize>,
113 pub connect_timeout_ms: Option<u64>,
115 pub timeout_secs: Option<u64>,
116 pub pool_idle_timeout_secs: Option<u64>,
117 pub pool_max_idle_per_host: Option<usize>,
118}
119
120#[derive(Debug, Deserialize, Clone)]
121pub struct EndpointConfig {
122 pub name: Option<String>, pub url: String,
124 pub rate_limit_per_sec: u32,
125 #[serde(default = "default_burst_size")]
126 pub burst_size: u32,
127 #[serde(default)]
128 pub weight: Option<u32>,
129}
130
131impl Default for EndpointConfig {
132 fn default() -> Self {
133 Self {
134 name: None, url: String::new(),
136 rate_limit_per_sec: DEFAULT_ENDPOINT_RATE_LIMIT,
137 burst_size: DEFAULT_BURST_SIZE,
138 weight: None,
139 }
140 }
141}
142
143pub const DEFAULT_BURST_SIZE: u32 = 10;
145pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8549";
146pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 30;
147pub const DEFAULT_HEALTH_CHECK_TIMEOUT_SECS: u64 = 5;
148pub const DEFAULT_BASE_COOLDOWN_SECS: u64 = 3;
149pub const DEFAULT_MAX_COOLDOWN_SECS: u64 = 60;
150pub const DEFAULT_LATENCY_SMOOTHING_FACTOR: f64 = 0.1;
151pub const DEFAULT_ENDPOINT_RATE_LIMIT: u32 = 5;
152pub const DEFAULT_ENDPOINT_WEIGHT: u32 = 20;
153pub const DEFAULT_MAX_BATCH_SIZE: usize = 100;
154
155pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 500;
157pub const DEFAULT_TIMEOUT_SECS: u64 = 5;
158pub const DEFAULT_POOL_IDLE_TIMEOUT_SECS: u64 = 25;
159pub const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 100;
160pub const DEFAULT_MAX_CONCURRENCY: usize = 100;
161
162pub const DEFAULT_ENDPOINTS: [&str; 4] = [
164 "https://arbitrum-one-public.nodies.app",
165 "https://arbitrum-one.public.blastapi.io",
166 "https://arbitrum.meowrpc.com",
167 "https://arbitrum.drpc.org/",
168];
169
170pub fn default_burst_size() -> u32 {
171 DEFAULT_BURST_SIZE
172}
173
174pub fn try_load_config(path: &str) -> Result<Option<Config>, ConfigError> {
175 match fs::read_to_string(path) {
176 Ok(raw) => match toml::from_str::<Config>(&raw) {
177 Ok(cfg) => {
178 info!(path = %path, "Loaded config");
179 Ok(Some(cfg))
180 }
181 Err(e) => {
182 error!(path = %path, error = %e, "Failed to parse config");
183 Err(ConfigError::ConfigError(e.to_string()))
184 }
185 },
186 Err(e) => {
187 if e.kind() == std::io::ErrorKind::NotFound {
188 info!(path = %path, "No config file found, using defaults");
189 Ok(None)
190 } else {
191 Err(ConfigError::ConfigError(e.to_string()))
192 }
193 }
194 }
195}
196
197pub fn validate_and_dedupe_endpoints(
198 endpoints: Vec<EndpointConfig>,
199) -> Result<Vec<EndpointConfig>, ConfigError> {
200 let mut seen = HashSet::new();
201 const MAX_URL_LEN: usize = 2048;
202 const MAX_RATE_LIMIT: u32 = 100_000;
203 const MAX_BURST_SIZE: u32 = 10_000;
204
205 let validated_endpoints: Vec<EndpointConfig> = endpoints
206 .into_iter()
207 .filter_map(|mut e| {
208 e.url = e.url.trim().to_string();
209
210 if e.url.is_empty() {
211 warn!("Skipping empty endpoint URL");
212 return None;
213 }
214
215 if !e.url.to_lowercase().starts_with("http://") && !e.url.to_lowercase().starts_with("https://") {
216 warn!(url = %e.url, "Skipping invalid endpoint URL");
217 return None;
218 }
219
220 if e.url.len() > MAX_URL_LEN {
221 warn!(url = %e.url, "Skipping endpoint exceeding max length");
222 return None;
223 }
224
225 if e.url.chars().any(|c| c.is_control() || c.is_whitespace()) {
226 warn!(url = %e.url, "Skipping endpoint with invalid characters");
227 return None;
228 }
229
230 if e.rate_limit_per_sec == 0 || e.rate_limit_per_sec > MAX_RATE_LIMIT {
231 warn!(url = %e.url, rate = e.rate_limit_per_sec, "Skipping endpoint with invalid rate_limit_per_sec");
232 return None;
233 }
234
235 if e.burst_size == 0 || e.burst_size > MAX_BURST_SIZE {
236 warn!(url = %e.url, burst = e.burst_size, "Skipping endpoint with invalid burst_size");
237 return None;
238 }
239
240 let mut canonical_url = e.url.clone();
242 if canonical_url.to_lowercase().starts_with("http://") {
243 canonical_url.replace_range(0..7, "http://");
244 } else if canonical_url.to_lowercase().starts_with("https://") {
245 canonical_url.replace_range(0..8, "https://");
246 }
247 while canonical_url.ends_with('/') {
248 canonical_url.pop();
249 }
250 e.url = canonical_url;
251
252 if seen.insert(e.url.clone()) {
253 Some(e)
254 } else {
255 None
256 }
257 })
258 .collect();
259
260 if validated_endpoints.is_empty() {
261 return Err(ConfigError::ConfigError("No valid endpoints configured".to_string()));
262 }
263
264 Ok(validated_endpoints)
265}
266
267pub fn get_default_endpoints() -> Vec<EndpointConfig> {
268 DEFAULT_ENDPOINTS
269 .iter()
270 .map(|&s| EndpointConfig {
271 name: None,
272 url: s.to_string(),
273 rate_limit_per_sec: DEFAULT_ENDPOINT_RATE_LIMIT,
274 burst_size: default_burst_size(),
275 weight: Some(DEFAULT_ENDPOINT_WEIGHT),
276 })
277 .collect()
278}
279
280#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::io::Write;
285 use tempfile::NamedTempFile;
286
287 #[test]
288 fn test_try_load_config_valid_file() {
289 let mut file = NamedTempFile::new().unwrap();
290 writeln!(file, "[server]\nbind_addr = \"127.0.0.1:8070\"").unwrap();
291 let path = file.path().to_str().unwrap();
292 let result = try_load_config(path).unwrap();
293 assert!(result.is_some());
294 let config = result.unwrap();
295 assert_eq!(config.server.unwrap().bind_addr.unwrap(), "127.0.0.1:8070");
296 }
297
298 #[test]
299 fn test_try_load_config_file_not_found() {
300 let result = try_load_config("nonexistent.toml").unwrap();
301 assert!(result.is_none());
302 }
303
304 #[test]
305 fn test_try_load_config_invalid_file() {
306 let mut file = NamedTempFile::new().unwrap();
307 writeln!(file, "[server]\nbind_addr = 12345").unwrap();
308 let path = file.path().to_str().unwrap();
309 let result = try_load_config(path);
310 assert!(result.is_err());
311 }
312
313 #[test]
314 fn test_validate_and_dedupe_endpoints() {
315 let endpoints = vec![
316 EndpointConfig {
317 name: None,
318 url: "https://valid1.com".into(),
319 rate_limit_per_sec: 10,
320 burst_size: 5,
321 weight: Some(1),
322 },
323 EndpointConfig {
324 name: None,
325 url: "https://valid1.com".into(),
326 rate_limit_per_sec: 10,
327 burst_size: 5,
328 weight: Some(1),
329 },
330 EndpointConfig {
331 name: None,
332 url: "".into(),
333 rate_limit_per_sec: 10,
334 burst_size: 5,
335 weight: Some(1),
336 },
337 EndpointConfig {
338 name: None,
339 url: "invalid_url".into(),
340 rate_limit_per_sec: 10,
341 burst_size: 5,
342 weight: Some(1),
343 },
344 ];
345 let result = validate_and_dedupe_endpoints(endpoints);
346 assert!(result.is_ok());
347 let list = result.unwrap();
348 assert_eq!(list.len(), 1);
349 assert_eq!(list[0].url, "https://valid1.com");
350 }
351
352 #[test]
353 fn test_get_default_endpoints() {
354 let endpoints = get_default_endpoints();
355 assert_eq!(endpoints.len(), 4);
356 assert!(endpoints.iter().all(|e| e.url.starts_with("https://")));
357 }
358
359 #[test]
360 fn test_validate_and_dedupe_all_invalid() {
361 let endpoints = vec![
362 EndpointConfig {
363 name: None,
364 url: "not-a-url".into(),
365 rate_limit_per_sec: 10,
366 burst_size: 5,
367 weight: Some(1),
368 },
369 EndpointConfig {
370 name: None,
371 url: "".into(),
372 rate_limit_per_sec: 10,
373 burst_size: 5,
374 weight: Some(1),
375 },
376 ];
377 let result = validate_and_dedupe_endpoints(endpoints);
378 assert!(result.is_err());
379 }
380
381 #[test]
382 fn test_validate_and_dedupe_multiple_valid() {
383 let endpoints = vec![
384 EndpointConfig {
385 name: None,
386 url: "https://mainnet.infura.io/v3/123".into(),
387 rate_limit_per_sec: 20,
388 burst_size: 5,
389 weight: Some(1),
390 },
391 EndpointConfig {
392 name: None,
393 url: "https://rpc.ankr.com/eth".into(),
394 rate_limit_per_sec: 20,
395 burst_size: 5,
396 weight: Some(1),
397 },
398 ];
399 let result = validate_and_dedupe_endpoints(endpoints).unwrap();
400 assert_eq!(result.len(), 2);
401 assert!(result.iter().any(|e| e.url.contains("infura")));
402 assert!(result.iter().any(|e| e.url.contains("ankr")));
403 }
404
405 #[test]
406 fn test_balancer_config_optional_fields() {
407 let toml = r#"
408 [balancer]
409 max_batch_size = 42
410 max_concurrency = 10
411 "#;
412
413 let mut file = NamedTempFile::new().unwrap();
414 writeln!(file, "{}", toml).unwrap();
415 let path = file.path().to_str().unwrap();
416
417 let cfg = try_load_config(path).unwrap().unwrap();
418 let balancer = cfg.balancer.unwrap();
419 assert_eq!(balancer.max_batch_size.unwrap(), 42);
420 assert_eq!(balancer.max_concurrency.unwrap(), 10);
421 assert!(balancer.base_cooldown_secs.is_none());
422 assert!(balancer.endpoints.is_none());
423 }
424
425 #[test]
426 fn test_endpoint_default_values() {
427 let endpoint = EndpointConfig {
428 url: "https://example.com".to_string(),
429 rate_limit_per_sec: 10,
430 ..Default::default()
431 };
432 assert_eq!(endpoint.burst_size, DEFAULT_BURST_SIZE);
433 assert!(endpoint.weight.is_none());
434 }
435
436 #[test]
437 fn test_get_default_endpoints_not_empty() {
438 let endpoints = get_default_endpoints();
439 assert!(!endpoints.is_empty());
440 for e in &endpoints {
441 assert!(e.url.starts_with("https://"));
442 assert_eq!(e.rate_limit_per_sec, DEFAULT_ENDPOINT_RATE_LIMIT);
443 assert_eq!(e.burst_size, DEFAULT_BURST_SIZE);
444 assert_eq!(e.weight.unwrap(), DEFAULT_ENDPOINT_WEIGHT);
445 }
446 }
447
448 #[test]
449 fn test_server_config_defaults() {
450 let cfg = Config::default();
451 assert!(cfg.server.is_none());
452 }
453
454 #[test]
455 fn test_endpoint_custom_values() {
456 let endpoint = EndpointConfig {
457 name: None,
458 url: "https://example.com".to_string(),
459 rate_limit_per_sec: 0,
460 burst_size: 50,
461 weight: Some(5),
462 };
463 assert_eq!(endpoint.rate_limit_per_sec, 0);
464 assert_eq!(endpoint.burst_size, 50);
465 assert_eq!(endpoint.weight, Some(5));
466 }
467
468 #[test]
469 fn test_validate_endpoints_trim_and_canonicalize() {
470 let endpoints = vec![
471 EndpointConfig {
472 name: None,
473 url: " HTTPS://example.com/ ".into(),
474 rate_limit_per_sec: 10,
475 burst_size: 5,
476 weight: None,
477 },
478 EndpointConfig {
479 name: None,
480 url: "https://example.com".into(),
481 rate_limit_per_sec: 10,
482 burst_size: 5,
483 weight: None,
484 },
485 ];
486 let validated = validate_and_dedupe_endpoints(endpoints).unwrap();
487 assert_eq!(validated.len(), 1);
488 assert_eq!(validated[0].url, "https://example.com");
489 }
490
491 #[test]
492 fn test_validate_and_dedupe_mixed() {
493 let endpoints = vec![
494 EndpointConfig {
495 name: None,
496 url: "https://good.com".into(),
497 rate_limit_per_sec: 10,
498 burst_size: 5,
499 weight: None,
500 },
501 EndpointConfig {
502 name: None,
503 url: "bad".into(),
504 rate_limit_per_sec: 10,
505 burst_size: 5,
506 weight: None,
507 },
508 EndpointConfig {
509 name: None,
510 url: "".into(),
511 rate_limit_per_sec: 10,
512 burst_size: 5,
513 weight: None,
514 },
515 ];
516 let validated = validate_and_dedupe_endpoints(endpoints).unwrap();
517 assert_eq!(validated.len(), 1);
518 assert_eq!(validated[0].url, "https://good.com");
519 }
520
521 #[test]
522 fn test_get_default_endpoints_exact() {
523 let endpoints = get_default_endpoints();
524 for (i, e) in endpoints.iter().enumerate() {
525 assert_eq!(e.url, DEFAULT_ENDPOINTS[i]);
526 assert_eq!(e.burst_size, DEFAULT_BURST_SIZE);
527 assert_eq!(e.rate_limit_per_sec, DEFAULT_ENDPOINT_RATE_LIMIT);
528 assert_eq!(e.weight.unwrap(), DEFAULT_ENDPOINT_WEIGHT);
529 }
530 }
531
532 #[test]
533 fn test_try_load_empty_file() {
534 let mut file = NamedTempFile::new().unwrap();
535 writeln!(file).unwrap();
536 let path = file.path().to_str().unwrap();
537 let result = try_load_config(path).unwrap();
538 assert!(result.is_some());
539 let cfg = result.unwrap();
540 assert!(cfg.server.is_none());
541 assert!(cfg.balancer.is_none());
542 }
543
544 #[test]
545 fn test_validate_endpoints_invalid_characters() {
546 let endpoints = vec![
547 EndpointConfig {
548 name: None,
549 url: "https://good.com/ bad".into(),
550 rate_limit_per_sec: 10,
551 burst_size: 5,
552 weight: None,
553 },
554 EndpointConfig {
555 name: None,
556 url: "https://ok.com/\x07".into(),
557 rate_limit_per_sec: 10,
558 burst_size: 5,
559 weight: None,
560 },
561 ];
562 let result = validate_and_dedupe_endpoints(endpoints);
563 assert!(result.is_err());
564 }
565
566 #[test]
567 fn test_validate_endpoints_max_url_length() {
568 let long_url = format!("https://example.com/{}", "a".repeat(2048));
569 let endpoints = vec![EndpointConfig {
570 name: None,
571 url: long_url.clone(),
572 rate_limit_per_sec: 10,
573 burst_size: 5,
574 weight: None,
575 }];
576 let result = validate_and_dedupe_endpoints(endpoints);
577 assert!(result.is_err(), "URL exceeding max length should be rejected");
578 }
579
580 #[test]
581 fn test_validate_endpoints_sane_rate_and_burst() {
582 let endpoints = vec![
583 EndpointConfig {
584 name: None,
585 url: "https://good.com".into(),
586 rate_limit_per_sec: 0,
587 burst_size: 0,
588 weight: None,
589 },
590 EndpointConfig {
591 name: None,
592 url: "https://ok.com".into(),
593 rate_limit_per_sec: 1_000_000,
594 burst_size: 1_000_000,
595 weight: None,
596 },
597 ];
598 let result = validate_and_dedupe_endpoints(endpoints);
599 assert!(result.is_err(), "Endpoints with insane rate or burst should be rejected");
600 }
601
602 #[test]
603 fn test_validate_endpoints_canonicalization() {
604 let endpoints = vec![
605 EndpointConfig {
606 name: None,
607 url: "https://example.com".into(),
608 rate_limit_per_sec: 10,
609 burst_size: 5,
610 weight: None,
611 },
612 EndpointConfig {
613 name: None,
614 url: "https://example.com/".into(),
615 rate_limit_per_sec: 10,
616 burst_size: 5,
617 weight: None,
618 },
619 EndpointConfig {
620 name: None,
621 url: "HTTPS://example.com".into(),
622 rate_limit_per_sec: 10,
623 burst_size: 5,
624 weight: None,
625 },
626 ];
627 let result = validate_and_dedupe_endpoints(endpoints).unwrap();
628 assert_eq!(
629 result.len(),
630 1,
631 "Duplicates differing only by slash or case should be deduplicated"
632 );
633 }
634
635 #[test]
638 fn test_validate_endpoints_invalid_weight() {
639 let endpoints = vec![EndpointConfig {
640 name: None,
641 url: "https://example.com".into(),
642 rate_limit_per_sec: 10,
643 burst_size: 5,
644 weight: Some(0),
645 }];
646 let result = validate_and_dedupe_endpoints(endpoints);
647 assert!(result.is_ok());
648 let list = result.unwrap();
649 assert_eq!(list[0].weight, Some(0));
650 }
651
652 #[test]
653 fn test_validate_endpoints_multiple_trailing_slashes() {
654 let endpoints = vec![EndpointConfig {
655 name: None,
656 url: "https://example.com///".into(),
657 rate_limit_per_sec: 10,
658 burst_size: 5,
659 weight: None,
660 }];
661 let result = validate_and_dedupe_endpoints(endpoints).unwrap();
662 assert_eq!(result[0].url, "https://example.com");
663 }
664
665 #[test]
666 fn test_validate_endpoints_error_message() {
667 let endpoints = vec![EndpointConfig {
668 name: None,
669 url: "invalid".into(),
670 rate_limit_per_sec: 10,
671 burst_size: 5,
672 weight: None,
673 }];
674 let result = validate_and_dedupe_endpoints(endpoints);
675 assert!(matches!(
676 result,
677 Err(ConfigError::ConfigError(msg)) if msg == "No valid endpoints configured"
678 ));
679 }
680
681 #[test]
682 fn test_balancer_config_client_settings() {
683 let toml = r#"
684 [balancer]
685 connect_timeout_ms = 1000
686 timeout_secs = 10
687 pool_idle_timeout_secs = 30
688 pool_max_idle_per_host = 100
689 "#;
690
691 let mut file = NamedTempFile::new().unwrap();
692 writeln!(file, "{}", toml).unwrap();
693 let path = file.path().to_str().unwrap();
694
695 let cfg = try_load_config(path).unwrap().unwrap();
696 let balancer = cfg.balancer.unwrap();
697
698 assert_eq!(balancer.connect_timeout_ms.unwrap(), 1000);
699 assert_eq!(balancer.timeout_secs.unwrap(), 10);
700 assert_eq!(balancer.pool_idle_timeout_secs.unwrap(), 30);
701 assert_eq!(balancer.pool_max_idle_per_host.unwrap(), 100);
702 }
703
704 #[test]
705 fn test_get_default_endpoints_urls() {
706 let endpoints = get_default_endpoints();
707 let expected_urls: Vec<String> = DEFAULT_ENDPOINTS.iter().map(|&s| s.to_string()).collect();
708 let actual_urls: Vec<String> = endpoints.iter().map(|e| e.url.clone()).collect();
709
710 assert_eq!(actual_urls, expected_urls);
711 }
712
713 #[test]
714 fn test_default_constants() {
715 let endpoint = EndpointConfig::default();
716 assert_eq!(endpoint.burst_size, DEFAULT_BURST_SIZE);
717 assert_eq!(endpoint.rate_limit_per_sec, DEFAULT_ENDPOINT_RATE_LIMIT);
718
719 let default_endpoints = get_default_endpoints();
720 assert!(default_endpoints.iter().all(|e| e.burst_size == DEFAULT_BURST_SIZE));
721 assert!(default_endpoints
722 .iter()
723 .all(|e| e.rate_limit_per_sec == DEFAULT_ENDPOINT_RATE_LIMIT));
724 }
725
726 #[test]
727 fn test_endpoint_scheme_mixed_case() {
728 let endpoints = vec![EndpointConfig {
729 name: None,
730 url: "HtTpS://example.com".into(),
731 rate_limit_per_sec: 10,
732 burst_size: 5,
733 weight: None,
734 }];
735 let validated = validate_and_dedupe_endpoints(endpoints).unwrap();
736 assert_eq!(validated[0].url, "https://example.com");
737 }
738
739 #[test]
740 fn test_endpoint_with_query_and_fragment() {
741 let endpoints = vec![EndpointConfig {
742 name: None,
743 url: "https://example.com/path?query=1#frag".into(),
744 rate_limit_per_sec: 10,
745 burst_size: 5,
746 weight: None,
747 }];
748 let validated = validate_and_dedupe_endpoints(endpoints).unwrap();
749 assert_eq!(validated[0].url, "https://example.com/path?query=1#frag");
750 }
751
752 #[test]
753 fn test_balancer_extreme_values() {
754 let cfg = BalancerConfig {
755 max_batch_size: Some(0),
756 max_concurrency: Some(100_000),
757 ..Default::default()
758 };
759 assert_eq!(cfg.max_batch_size.unwrap(), 0);
760 assert_eq!(cfg.max_concurrency.unwrap(), 100_000);
761 }
762
763 #[test]
764 fn test_latency_smoothing_edge() {
765 let cfg = BalancerConfig { latency_smoothing_factor: Some(0.0), ..Default::default() };
766 assert_eq!(cfg.latency_smoothing_factor.unwrap(), 0.0);
767 }
768
769 #[test]
770 fn test_validate_endpoints_all_invalid_branches() {
771 let endpoints = vec![
772 EndpointConfig {
774 name: None,
775 url: "".into(),
776 rate_limit_per_sec: 10,
777 burst_size: 5,
778 weight: None,
779 },
780 EndpointConfig {
782 name: None,
783 url: "not-a-url".into(),
784 rate_limit_per_sec: 10,
785 burst_size: 5,
786 weight: None,
787 },
788 EndpointConfig {
790 name: None,
791 url: "https://ok.com".into(),
792 rate_limit_per_sec: 0,
793 burst_size: 5,
794 weight: None,
795 },
796 EndpointConfig {
798 name: None,
799 url: "https://ok2.com".into(),
800 rate_limit_per_sec: 10,
801 burst_size: 0,
802 weight: None,
803 },
804 EndpointConfig {
806 name: None,
807 url: format!("https://example.com/{}", "a".repeat(2048)),
808 rate_limit_per_sec: 10,
809 burst_size: 5,
810 weight: None,
811 },
812 ];
813
814 let result = validate_and_dedupe_endpoints(endpoints);
815 assert!(matches!(
816 result,
817 Err(ConfigError::ConfigError(msg)) if msg == "No valid endpoints configured"
818 ));
819 }
820
821 #[test]
822 fn test_validate_endpoints_mixed_stress() {
823 let endpoints = vec![
824 EndpointConfig {
826 name: None,
827 url: "https://valid1.com".into(),
828 rate_limit_per_sec: 10,
829 burst_size: 5,
830 weight: Some(1),
831 },
832 EndpointConfig {
833 name: None,
834 url: "http://valid2.com/".into(),
835 rate_limit_per_sec: 20,
836 burst_size: 10,
837 weight: None,
838 },
839 EndpointConfig {
841 name: None,
842 url: "".into(),
843 rate_limit_per_sec: 10,
844 burst_size: 5,
845 weight: None,
846 },
847 EndpointConfig {
848 name: None,
849 url: "bad-url".into(),
850 rate_limit_per_sec: 10,
851 burst_size: 5,
852 weight: None,
853 },
854 EndpointConfig {
855 name: None,
856 url: "https://example.com".into(),
857 rate_limit_per_sec: 0,
858 burst_size: 5,
859 weight: None,
860 },
861 EndpointConfig {
862 name: None,
863 url: "https://example.com".into(),
864 rate_limit_per_sec: 10,
865 burst_size: 0,
866 weight: None,
867 },
868 EndpointConfig {
869 name: None,
870 url: format!("https://toolong.com/{}", "a".repeat(2048)),
871 rate_limit_per_sec: 10,
872 burst_size: 5,
873 weight: None,
874 },
875 ];
876
877 let result = validate_and_dedupe_endpoints(endpoints).unwrap();
878
879 assert_eq!(result.len(), 2);
881 assert!(result.iter().any(|e| e.url == "https://valid1.com"));
882 assert!(result.iter().any(|e| e.url == "http://valid2.com"));
883 }
884
885 #[test]
886 fn test_finalize_applies_defaults() {
887 let cfg = Config::default();
888 let finalized = cfg.finalize().unwrap();
889
890 assert_eq!(finalized.server.unwrap().bind_addr.unwrap(), DEFAULT_BIND_ADDR);
891 assert_eq!(
892 finalized.balancer.as_ref().unwrap().health_check_interval_secs.unwrap(),
893 DEFAULT_HEALTH_CHECK_INTERVAL_SECS
894 );
895 assert_eq!(
896 finalized.balancer.as_ref().unwrap().max_batch_size.unwrap(),
897 DEFAULT_MAX_BATCH_SIZE
898 );
899 }
900
901 #[test]
902 fn test_finalize_clamps_latency_smoothing() {
903 let cfg = Config {
904 balancer: Some(BalancerConfig {
905 latency_smoothing_factor: Some(1.5),
906 ..Default::default()
907 }),
908 ..Default::default()
909 };
910
911 let finalized = cfg.finalize().unwrap();
912 assert_eq!(finalized.balancer.unwrap().latency_smoothing_factor.unwrap(), 1.0);
913 }
914
915 #[test]
916 fn test_finalize_enforces_min_batch_size() {
917 let cfg = Config {
918 balancer: Some(BalancerConfig { max_batch_size: Some(0), ..Default::default() }),
919 ..Default::default()
920 };
921
922 let finalized = cfg.finalize().unwrap();
923 assert_eq!(finalized.balancer.unwrap().max_batch_size.unwrap(), 1);
924 }
925
926 #[test]
927 fn test_finalize_applies_default_weight() {
928 let cfg = Config {
929 balancer: Some(BalancerConfig {
930 endpoints: Some(vec![EndpointConfig {
931 name: None,
932 url: "https://test.com".into(),
933 rate_limit_per_sec: 10,
934 burst_size: 5,
935 weight: None,
936 }]),
937 ..Default::default()
938 }),
939 ..Default::default()
940 };
941
942 let finalized = cfg.finalize().unwrap();
943 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
944 assert_eq!(endpoints[0].weight.unwrap(), DEFAULT_ENDPOINT_WEIGHT);
945 }
946
947 #[test]
948 fn test_resolve_env_vars_in_urls() {
949 std::env::set_var("TEST_ENDPOINT", "https://resolved.com");
950
951 let cfg = Config {
952 balancer: Some(BalancerConfig {
953 endpoints: Some(vec![EndpointConfig {
954 name: None,
955 url: "${TEST_ENDPOINT}".into(),
956 rate_limit_per_sec: 10,
957 burst_size: 5,
958 weight: None,
959 }]),
960 ..Default::default()
961 }),
962 ..Default::default()
963 };
964
965 let finalized = cfg.finalize().unwrap();
966 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
967 assert_eq!(endpoints[0].url, "https://resolved.com");
968
969 std::env::remove_var("TEST_ENDPOINT");
970 }
971
972 #[test]
973 fn test_endpoint_name_field() {
974 let endpoint = EndpointConfig {
975 name: Some("primary".to_string()),
976 url: "https://test.com".into(),
977 rate_limit_per_sec: 10,
978 burst_size: 5,
979 weight: None,
980 };
981
982 assert_eq!(endpoint.name.unwrap(), "primary");
983 }
984
985 #[test]
986 fn test_finalize_with_empty_config() {
987 let cfg = Config::default();
988 let finalized = cfg.finalize().unwrap();
989
990 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
992 assert!(!endpoints.is_empty());
993 }
994
995 #[test]
996 fn test_finalize_enforces_min_concurrency() {
997 let cfg = Config {
998 balancer: Some(BalancerConfig { max_concurrency: Some(0), ..Default::default() }),
999 ..Default::default()
1000 };
1001
1002 let finalized = cfg.finalize().unwrap();
1003 assert_eq!(finalized.balancer.unwrap().max_concurrency.unwrap(), 1);
1004 }
1005
1006 #[test]
1007 fn test_resolve_env_vars_missing() {
1008 let cfg = Config {
1010 balancer: Some(BalancerConfig {
1011 endpoints: Some(vec![EndpointConfig {
1012 name: None,
1013 url: "https://${MISSING_VAR}.example.com".into(), rate_limit_per_sec: 10,
1015 burst_size: 5,
1016 weight: None,
1017 }]),
1018 ..Default::default()
1019 }),
1020 ..Default::default()
1021 };
1022
1023 let finalized = cfg.finalize().unwrap();
1024 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
1025 assert_eq!(endpoints[0].url, "https://${MISSING_VAR}.example.com");
1027 }
1028
1029 #[test]
1030 fn test_finalize_enforces_min_rate_limit() {
1031 let cfg = Config {
1034 balancer: Some(BalancerConfig {
1035 endpoints: Some(vec![EndpointConfig {
1036 name: None,
1037 url: "https://test.com".into(),
1038 rate_limit_per_sec: 1, burst_size: 5,
1040 weight: None,
1041 }]),
1042 ..Default::default()
1043 }),
1044 ..Default::default()
1045 };
1046
1047 let finalized = cfg.finalize().unwrap();
1048 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
1049 assert_eq!(endpoints[0].rate_limit_per_sec, 1);
1051 }
1052
1053 #[test]
1055 fn test_finalize_clamps_low_but_valid_rate_limit() {
1056 let cfg = Config {
1057 balancer: Some(BalancerConfig {
1058 endpoints: Some(vec![EndpointConfig {
1059 name: None,
1060 url: "https://test.com".into(),
1061 rate_limit_per_sec: 1, burst_size: 5,
1063 weight: None,
1064 }]),
1065 ..Default::default()
1066 }),
1067 ..Default::default()
1068 };
1069
1070 let finalized = cfg.finalize().unwrap();
1071 let endpoints = finalized.balancer.unwrap().endpoints.unwrap();
1072 assert_eq!(endpoints[0].rate_limit_per_sec, 1);
1074 }
1075}