Skip to main content

ipfrs_network/
connection_limiter.rs

1//! Peer Connection Limiter
2//!
3//! Rate-based connection limiting per peer and globally. Enforces per-peer
4//! connection caps, a global connection ceiling, and a cooldown period
5//! (measured in ticks) between successive connections from the same peer.
6
7use std::collections::HashMap;
8
9// ── Configuration ────────────────────────────────────────────────────────────
10
11/// Configuration for [`PeerConnectionLimiter`].
12#[derive(Debug, Clone)]
13pub struct LimiterConfig {
14    /// Maximum simultaneous connections allowed from a single peer.
15    pub max_connections_per_peer: usize,
16    /// Maximum total active connections across all peers.
17    pub max_total_connections: usize,
18    /// Minimum number of ticks that must elapse between connections from the
19    /// same peer.
20    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// ── Per-peer tracking ────────────────────────────────────────────────────────
34
35/// Per-peer connection information tracked by the limiter.
36#[derive(Debug, Clone)]
37pub struct PeerConnectionInfo {
38    /// Identifier of the peer.
39    pub peer_id: String,
40    /// Number of currently active connections from this peer.
41    pub active_connections: usize,
42    /// Tick at which the most recent connection was accepted.
43    pub last_connection_tick: u64,
44    /// Lifetime count of accepted connections for this peer.
45    pub total_connections: u64,
46    /// Lifetime count of rejected connection attempts for this peer.
47    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// ── Aggregate statistics ─────────────────────────────────────────────────────
63
64/// Aggregate statistics snapshot returned by [`PeerConnectionLimiter::stats`].
65#[derive(Debug, Clone)]
66pub struct LimiterStats {
67    /// Total active connections across all peers.
68    pub total_active: usize,
69    /// Number of distinct peers being tracked.
70    pub tracked_peers: usize,
71    /// Lifetime count of accepted connections.
72    pub total_accepted: u64,
73    /// Lifetime count of rejected connection attempts.
74    pub total_rejected: u64,
75}
76
77// ── Limiter ──────────────────────────────────────────────────────────────────
78
79/// Rate-based connection limiter that enforces per-peer limits, a global cap,
80/// and a cooldown period between connections from the same peer.
81pub 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    /// Create a new limiter with the given configuration.
92    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    /// Attempt to accept a connection from `peer_id`.
104    ///
105    /// Returns `Ok(())` if the connection is accepted, or `Err` with a
106    /// human-readable reason if rejected.
107    pub fn try_connect(&mut self, peer_id: &str) -> Result<(), String> {
108        // Global limit
109        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        // Per-peer limit
123        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        // Cooldown check — only applies if the peer has had at least one
133        // previous connection (total_connections > 0).
134        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        // Accept
148        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    /// Record a disconnection for `peer_id`.
158    ///
159    /// Returns `Err` if the peer has no active connections or is unknown.
160    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    /// Check whether a connection from `peer_id` would be allowed **without**
176    /// modifying any state.
177    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    /// Return the number of active connections for the given peer, or `0` if
198    /// the peer is not tracked.
199    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    /// Return the total number of active connections across all peers.
204    pub fn total_active(&self) -> usize {
205        self.total_active
206    }
207
208    /// Advance the internal clock by one tick.
209    pub fn tick(&mut self) {
210        self.current_tick += 1;
211    }
212
213    /// Clear all connection info for the specified peer. The peer is removed
214    /// from the internal tracking map and the global active count is adjusted.
215    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    /// Return an aggregate statistics snapshot.
222    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    /// Return a reference to the current configuration.
232    pub fn config(&self) -> &LimiterConfig {
233        &self.config
234    }
235
236    /// Return the current tick value.
237    pub fn current_tick(&self) -> u64 {
238        self.current_tick
239    }
240
241    /// Return a reference to a peer's info, if tracked.
242    pub fn peer_info(&self, peer_id: &str) -> Option<&PeerConnectionInfo> {
243        self.peers.get(peer_id)
244    }
245
246    // ── helpers ──────────────────────────────────────────────────────────
247
248    /// Record a rejection for global-limit hits (peer may not exist yet).
249    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// ── Tests ────────────────────────────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn default_limiter() -> PeerConnectionLimiter {
265        PeerConnectionLimiter::new(LimiterConfig::default())
266    }
267
268    // --- per-peer limit ---
269
270    #[test]
271    fn per_peer_limit_allows_up_to_max() {
272        let mut lim = default_limiter();
273        // Advance past cooldown for each connection
274        for _ in 0..5 {
275            lim.tick(); // ensure cooldown passes between each
276            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    // --- global limit ---
311
312    #[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    // --- cooldown ---
341
342    #[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        // Immediately try again — should be rejected due to cooldown
351        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    // --- disconnect ---
396
397    #[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    // --- is_allowed ---
442
443    #[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    // --- stats ---
496
497    #[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"); // rejected
521        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    // --- reset_peer ---
540
541    #[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"); // should not panic
559        assert_eq!(lim.total_active(), 0);
560    }
561
562    // --- tick ---
563
564    #[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    // --- multiple peers ---
577
578    #[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    // --- edge cases ---
608
609    #[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        // accept 3
664        assert!(lim.try_connect("a").is_ok());
665        assert!(lim.try_connect("b").is_ok());
666        assert!(lim.try_connect("c").is_ok());
667        // reject 2
668        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        // Advance 3 ticks
686        for _ in 0..3 {
687            lim.tick();
688        }
689        // "a" cooldown is over, "b" is fresh
690        assert!(lim.try_connect("a").is_ok());
691        assert!(lim.try_connect("b").is_ok());
692        // "b" is now on cooldown, "a" is too
693        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        // Cooldown would block reconnection
705        assert!(lim.try_connect("p").is_err());
706        // Reset clears everything — including cooldown history
707        lim.reset_peer("p");
708        assert!(lim.try_connect("p").is_ok());
709    }
710}