1use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
13pub struct LimiterConfig {
14 pub max_connections_per_peer: usize,
16 pub max_total_connections: usize,
18 pub cooldown_ticks: u64,
21}
22
23impl Default for LimiterConfig {
24 fn default() -> Self {
25 Self {
26 max_connections_per_peer: 5,
27 max_total_connections: 200,
28 cooldown_ticks: 10,
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
37pub struct PeerConnectionInfo {
38 pub peer_id: String,
40 pub active_connections: usize,
42 pub last_connection_tick: u64,
44 pub total_connections: u64,
46 pub total_rejections: u64,
48}
49
50impl PeerConnectionInfo {
51 fn new(peer_id: String) -> Self {
52 Self {
53 peer_id,
54 active_connections: 0,
55 last_connection_tick: 0,
56 total_connections: 0,
57 total_rejections: 0,
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
66pub struct LimiterStats {
67 pub total_active: usize,
69 pub tracked_peers: usize,
71 pub total_accepted: u64,
73 pub total_rejected: u64,
75}
76
77pub struct PeerConnectionLimiter {
82 config: LimiterConfig,
83 peers: HashMap<String, PeerConnectionInfo>,
84 current_tick: u64,
85 total_active: usize,
86 total_accepted: u64,
87 total_rejected: u64,
88}
89
90impl PeerConnectionLimiter {
91 pub fn new(config: LimiterConfig) -> Self {
93 Self {
94 config,
95 peers: HashMap::new(),
96 current_tick: 0,
97 total_active: 0,
98 total_accepted: 0,
99 total_rejected: 0,
100 }
101 }
102
103 pub fn try_connect(&mut self, peer_id: &str) -> Result<(), String> {
108 if self.total_active >= self.config.max_total_connections {
110 self.record_rejection(peer_id);
111 return Err(format!(
112 "global connection limit reached ({}/{})",
113 self.total_active, self.config.max_total_connections
114 ));
115 }
116
117 let info = self
118 .peers
119 .entry(peer_id.to_string())
120 .or_insert_with(|| PeerConnectionInfo::new(peer_id.to_string()));
121
122 if info.active_connections >= self.config.max_connections_per_peer {
124 info.total_rejections += 1;
125 self.total_rejected += 1;
126 return Err(format!(
127 "per-peer connection limit reached for {} ({}/{})",
128 peer_id, info.active_connections, self.config.max_connections_per_peer
129 ));
130 }
131
132 if info.total_connections > 0 {
135 let elapsed = self.current_tick.saturating_sub(info.last_connection_tick);
136 if elapsed < self.config.cooldown_ticks {
137 info.total_rejections += 1;
138 self.total_rejected += 1;
139 return Err(format!(
140 "cooldown active for {} ({} ticks remaining)",
141 peer_id,
142 self.config.cooldown_ticks - elapsed
143 ));
144 }
145 }
146
147 info.active_connections += 1;
149 info.last_connection_tick = self.current_tick;
150 info.total_connections += 1;
151 self.total_active += 1;
152 self.total_accepted += 1;
153
154 Ok(())
155 }
156
157 pub fn disconnect(&mut self, peer_id: &str) -> Result<(), String> {
161 let info = self
162 .peers
163 .get_mut(peer_id)
164 .ok_or_else(|| format!("unknown peer: {peer_id}"))?;
165
166 if info.active_connections == 0 {
167 return Err(format!("no active connections for peer: {peer_id}"));
168 }
169
170 info.active_connections -= 1;
171 self.total_active -= 1;
172 Ok(())
173 }
174
175 pub fn is_allowed(&self, peer_id: &str) -> bool {
178 if self.total_active >= self.config.max_total_connections {
179 return false;
180 }
181
182 if let Some(info) = self.peers.get(peer_id) {
183 if info.active_connections >= self.config.max_connections_per_peer {
184 return false;
185 }
186 if info.total_connections > 0 {
187 let elapsed = self.current_tick.saturating_sub(info.last_connection_tick);
188 if elapsed < self.config.cooldown_ticks {
189 return false;
190 }
191 }
192 }
193
194 true
195 }
196
197 pub fn active_for_peer(&self, peer_id: &str) -> usize {
200 self.peers.get(peer_id).map_or(0, |i| i.active_connections)
201 }
202
203 pub fn total_active(&self) -> usize {
205 self.total_active
206 }
207
208 pub fn tick(&mut self) {
210 self.current_tick += 1;
211 }
212
213 pub fn reset_peer(&mut self, peer_id: &str) {
216 if let Some(info) = self.peers.remove(peer_id) {
217 self.total_active = self.total_active.saturating_sub(info.active_connections);
218 }
219 }
220
221 pub fn stats(&self) -> LimiterStats {
223 LimiterStats {
224 total_active: self.total_active,
225 tracked_peers: self.peers.len(),
226 total_accepted: self.total_accepted,
227 total_rejected: self.total_rejected,
228 }
229 }
230
231 pub fn config(&self) -> &LimiterConfig {
233 &self.config
234 }
235
236 pub fn current_tick(&self) -> u64 {
238 self.current_tick
239 }
240
241 pub fn peer_info(&self, peer_id: &str) -> Option<&PeerConnectionInfo> {
243 self.peers.get(peer_id)
244 }
245
246 fn record_rejection(&mut self, peer_id: &str) {
250 self.peers
251 .entry(peer_id.to_string())
252 .or_insert_with(|| PeerConnectionInfo::new(peer_id.to_string()))
253 .total_rejections += 1;
254 self.total_rejected += 1;
255 }
256}
257
258#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn default_limiter() -> PeerConnectionLimiter {
265 PeerConnectionLimiter::new(LimiterConfig::default())
266 }
267
268 #[test]
271 fn per_peer_limit_allows_up_to_max() {
272 let mut lim = default_limiter();
273 for _ in 0..5 {
275 lim.tick(); for _ in 0..lim.config.cooldown_ticks {
277 lim.tick();
278 }
279 assert!(lim.try_connect("peer-a").is_ok());
280 }
281 assert_eq!(lim.active_for_peer("peer-a"), 5);
282 }
283
284 #[test]
285 fn per_peer_limit_rejects_excess() {
286 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
287 max_connections_per_peer: 2,
288 cooldown_ticks: 0,
289 ..LimiterConfig::default()
290 });
291 assert!(lim.try_connect("p").is_ok());
292 assert!(lim.try_connect("p").is_ok());
293 let err = lim.try_connect("p").unwrap_err();
294 assert!(err.contains("per-peer"));
295 }
296
297 #[test]
298 fn per_peer_limit_after_disconnect() {
299 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
300 max_connections_per_peer: 1,
301 cooldown_ticks: 0,
302 ..LimiterConfig::default()
303 });
304 assert!(lim.try_connect("p").is_ok());
305 assert!(lim.try_connect("p").is_err());
306 assert!(lim.disconnect("p").is_ok());
307 assert!(lim.try_connect("p").is_ok());
308 }
309
310 #[test]
313 fn global_limit_rejects_excess() {
314 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
315 max_connections_per_peer: 100,
316 max_total_connections: 3,
317 cooldown_ticks: 0,
318 });
319 assert!(lim.try_connect("a").is_ok());
320 assert!(lim.try_connect("b").is_ok());
321 assert!(lim.try_connect("c").is_ok());
322 let err = lim.try_connect("d").unwrap_err();
323 assert!(err.contains("global"));
324 }
325
326 #[test]
327 fn global_limit_allows_after_disconnect() {
328 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
329 max_connections_per_peer: 100,
330 max_total_connections: 2,
331 cooldown_ticks: 0,
332 });
333 assert!(lim.try_connect("a").is_ok());
334 assert!(lim.try_connect("b").is_ok());
335 assert!(lim.try_connect("c").is_err());
336 assert!(lim.disconnect("a").is_ok());
337 assert!(lim.try_connect("c").is_ok());
338 }
339
340 #[test]
343 fn cooldown_enforced() {
344 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
345 max_connections_per_peer: 10,
346 max_total_connections: 100,
347 cooldown_ticks: 5,
348 });
349 assert!(lim.try_connect("p").is_ok());
350 let err = lim.try_connect("p").unwrap_err();
352 assert!(err.contains("cooldown"));
353 }
354
355 #[test]
356 fn cooldown_passes_after_enough_ticks() {
357 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
358 max_connections_per_peer: 10,
359 max_total_connections: 100,
360 cooldown_ticks: 3,
361 });
362 assert!(lim.try_connect("p").is_ok());
363 for _ in 0..3 {
364 lim.tick();
365 }
366 assert!(lim.try_connect("p").is_ok());
367 }
368
369 #[test]
370 fn cooldown_still_active_one_tick_short() {
371 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
372 max_connections_per_peer: 10,
373 max_total_connections: 100,
374 cooldown_ticks: 5,
375 });
376 assert!(lim.try_connect("p").is_ok());
377 for _ in 0..4 {
378 lim.tick();
379 }
380 assert!(lim.try_connect("p").is_err());
381 lim.tick();
382 assert!(lim.try_connect("p").is_ok());
383 }
384
385 #[test]
386 fn cooldown_zero_allows_immediate() {
387 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
388 cooldown_ticks: 0,
389 ..LimiterConfig::default()
390 });
391 assert!(lim.try_connect("p").is_ok());
392 assert!(lim.try_connect("p").is_ok());
393 }
394
395 #[test]
398 fn disconnect_decrements_active() {
399 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
400 cooldown_ticks: 0,
401 ..LimiterConfig::default()
402 });
403 assert!(lim.try_connect("p").is_ok());
404 assert!(lim.try_connect("p").is_ok());
405 assert_eq!(lim.active_for_peer("p"), 2);
406 assert!(lim.disconnect("p").is_ok());
407 assert_eq!(lim.active_for_peer("p"), 1);
408 }
409
410 #[test]
411 fn disconnect_unknown_peer_errors() {
412 let mut lim = default_limiter();
413 assert!(lim.disconnect("ghost").is_err());
414 }
415
416 #[test]
417 fn disconnect_zero_active_errors() {
418 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
419 cooldown_ticks: 0,
420 ..LimiterConfig::default()
421 });
422 assert!(lim.try_connect("p").is_ok());
423 assert!(lim.disconnect("p").is_ok());
424 let err = lim.disconnect("p").unwrap_err();
425 assert!(err.contains("no active"));
426 }
427
428 #[test]
429 fn disconnect_updates_total_active() {
430 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
431 cooldown_ticks: 0,
432 ..LimiterConfig::default()
433 });
434 assert!(lim.try_connect("a").is_ok());
435 assert!(lim.try_connect("b").is_ok());
436 assert_eq!(lim.total_active(), 2);
437 assert!(lim.disconnect("a").is_ok());
438 assert_eq!(lim.total_active(), 1);
439 }
440
441 #[test]
444 fn is_allowed_true_for_new_peer() {
445 let lim = default_limiter();
446 assert!(lim.is_allowed("new-peer"));
447 }
448
449 #[test]
450 fn is_allowed_false_when_per_peer_full() {
451 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
452 max_connections_per_peer: 1,
453 cooldown_ticks: 0,
454 ..LimiterConfig::default()
455 });
456 assert!(lim.try_connect("p").is_ok());
457 assert!(!lim.is_allowed("p"));
458 }
459
460 #[test]
461 fn is_allowed_false_when_global_full() {
462 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
463 max_total_connections: 1,
464 cooldown_ticks: 0,
465 ..LimiterConfig::default()
466 });
467 assert!(lim.try_connect("a").is_ok());
468 assert!(!lim.is_allowed("b"));
469 }
470
471 #[test]
472 fn is_allowed_false_during_cooldown() {
473 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
474 cooldown_ticks: 5,
475 ..LimiterConfig::default()
476 });
477 assert!(lim.try_connect("p").is_ok());
478 assert!(!lim.is_allowed("p"));
479 }
480
481 #[test]
482 fn is_allowed_does_not_mutate_state() {
483 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
484 cooldown_ticks: 0,
485 ..LimiterConfig::default()
486 });
487 assert!(lim.try_connect("p").is_ok());
488 let before = lim.stats();
489 let _ = lim.is_allowed("p");
490 let after = lim.stats();
491 assert_eq!(before.total_accepted, after.total_accepted);
492 assert_eq!(before.total_rejected, after.total_rejected);
493 }
494
495 #[test]
498 fn stats_tracking_accepted() {
499 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
500 cooldown_ticks: 0,
501 ..LimiterConfig::default()
502 });
503 assert!(lim.try_connect("a").is_ok());
504 assert!(lim.try_connect("b").is_ok());
505 let s = lim.stats();
506 assert_eq!(s.total_accepted, 2);
507 assert_eq!(s.total_rejected, 0);
508 assert_eq!(s.total_active, 2);
509 assert_eq!(s.tracked_peers, 2);
510 }
511
512 #[test]
513 fn stats_tracking_rejected() {
514 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
515 max_connections_per_peer: 1,
516 cooldown_ticks: 0,
517 ..LimiterConfig::default()
518 });
519 assert!(lim.try_connect("p").is_ok());
520 let _ = lim.try_connect("p"); let s = lim.stats();
522 assert_eq!(s.total_accepted, 1);
523 assert_eq!(s.total_rejected, 1);
524 }
525
526 #[test]
527 fn stats_global_rejection_counted() {
528 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
529 max_total_connections: 1,
530 cooldown_ticks: 0,
531 ..LimiterConfig::default()
532 });
533 assert!(lim.try_connect("a").is_ok());
534 let _ = lim.try_connect("b");
535 let s = lim.stats();
536 assert_eq!(s.total_rejected, 1);
537 }
538
539 #[test]
542 fn reset_peer_removes_tracking() {
543 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
544 cooldown_ticks: 0,
545 ..LimiterConfig::default()
546 });
547 assert!(lim.try_connect("p").is_ok());
548 assert!(lim.try_connect("p").is_ok());
549 lim.reset_peer("p");
550 assert_eq!(lim.active_for_peer("p"), 0);
551 assert_eq!(lim.total_active(), 0);
552 assert!(lim.peer_info("p").is_none());
553 }
554
555 #[test]
556 fn reset_peer_unknown_is_noop() {
557 let mut lim = default_limiter();
558 lim.reset_peer("ghost"); assert_eq!(lim.total_active(), 0);
560 }
561
562 #[test]
565 fn tick_advances_clock() {
566 let mut lim = default_limiter();
567 assert_eq!(lim.current_tick(), 0);
568 lim.tick();
569 assert_eq!(lim.current_tick(), 1);
570 for _ in 0..9 {
571 lim.tick();
572 }
573 assert_eq!(lim.current_tick(), 10);
574 }
575
576 #[test]
579 fn multiple_peers_independent_limits() {
580 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
581 max_connections_per_peer: 2,
582 cooldown_ticks: 0,
583 ..LimiterConfig::default()
584 });
585 assert!(lim.try_connect("a").is_ok());
586 assert!(lim.try_connect("a").is_ok());
587 assert!(lim.try_connect("b").is_ok());
588 assert!(lim.try_connect("b").is_ok());
589 assert!(lim.try_connect("a").is_err());
590 assert!(lim.try_connect("b").is_err());
591 assert_eq!(lim.total_active(), 4);
592 }
593
594 #[test]
595 fn multiple_peers_share_global_limit() {
596 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
597 max_connections_per_peer: 10,
598 max_total_connections: 3,
599 cooldown_ticks: 0,
600 });
601 assert!(lim.try_connect("a").is_ok());
602 assert!(lim.try_connect("b").is_ok());
603 assert!(lim.try_connect("c").is_ok());
604 assert!(lim.try_connect("d").is_err());
605 }
606
607 #[test]
610 fn zero_max_per_peer_always_rejects() {
611 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
612 max_connections_per_peer: 0,
613 cooldown_ticks: 0,
614 ..LimiterConfig::default()
615 });
616 assert!(lim.try_connect("p").is_err());
617 }
618
619 #[test]
620 fn zero_max_global_always_rejects() {
621 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
622 max_total_connections: 0,
623 cooldown_ticks: 0,
624 ..LimiterConfig::default()
625 });
626 assert!(lim.try_connect("p").is_err());
627 }
628
629 #[test]
630 fn default_config_values() {
631 let cfg = LimiterConfig::default();
632 assert_eq!(cfg.max_connections_per_peer, 5);
633 assert_eq!(cfg.max_total_connections, 200);
634 assert_eq!(cfg.cooldown_ticks, 10);
635 }
636
637 #[test]
638 fn peer_info_returns_none_for_unknown() {
639 let lim = default_limiter();
640 assert!(lim.peer_info("unknown").is_none());
641 }
642
643 #[test]
644 fn peer_info_returns_correct_data() {
645 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
646 cooldown_ticks: 0,
647 ..LimiterConfig::default()
648 });
649 assert!(lim.try_connect("p").is_ok());
650 let info = lim.peer_info("p").expect("peer should be tracked");
651 assert_eq!(info.active_connections, 1);
652 assert_eq!(info.total_connections, 1);
653 assert_eq!(info.total_rejections, 0);
654 }
655
656 #[test]
657 fn mixed_accept_reject_stats() {
658 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
659 max_connections_per_peer: 1,
660 max_total_connections: 10,
661 cooldown_ticks: 0,
662 });
663 assert!(lim.try_connect("a").is_ok());
665 assert!(lim.try_connect("b").is_ok());
666 assert!(lim.try_connect("c").is_ok());
667 let _ = lim.try_connect("a");
669 let _ = lim.try_connect("b");
670 let s = lim.stats();
671 assert_eq!(s.total_accepted, 3);
672 assert_eq!(s.total_rejected, 2);
673 assert_eq!(s.total_active, 3);
674 assert_eq!(s.tracked_peers, 3);
675 }
676
677 #[test]
678 fn cooldown_independent_per_peer() {
679 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
680 max_connections_per_peer: 10,
681 max_total_connections: 100,
682 cooldown_ticks: 3,
683 });
684 assert!(lim.try_connect("a").is_ok());
685 for _ in 0..3 {
687 lim.tick();
688 }
689 assert!(lim.try_connect("a").is_ok());
691 assert!(lim.try_connect("b").is_ok());
692 assert!(lim.try_connect("a").is_err());
694 assert!(lim.try_connect("b").is_err());
695 }
696
697 #[test]
698 fn reset_then_reconnect() {
699 let mut lim = PeerConnectionLimiter::new(LimiterConfig {
700 cooldown_ticks: 100,
701 ..LimiterConfig::default()
702 });
703 assert!(lim.try_connect("p").is_ok());
704 assert!(lim.try_connect("p").is_err());
706 lim.reset_peer("p");
708 assert!(lim.try_connect("p").is_ok());
709 }
710}