Skip to main content

rs_dapi_client/
address_list.rs

1//! Subsystem to manage DAPI nodes.
2
3use crate::address_ban_info::AddressBanInfo;
4use crate::Uri;
5use chrono::Utc;
6use rand::{rngs::SmallRng, seq::IteratorRandom, SeedableRng};
7use std::collections::hash_map::Entry;
8use std::collections::HashMap;
9use std::hash::{Hash, Hasher};
10use std::mem;
11use std::str::FromStr;
12use std::sync::{Arc, RwLock};
13use std::time::Duration;
14
15const DEFAULT_BASE_BAN_PERIOD: Duration = Duration::from_secs(60);
16
17/// DAPI address.
18#[derive(Debug, Clone, Eq)]
19#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
20pub struct Address(#[cfg_attr(feature = "mocks", serde(with = "http_serde::uri"))] Uri);
21
22impl FromStr for Address {
23    type Err = AddressListError;
24
25    fn from_str(s: &str) -> Result<Self, Self::Err> {
26        Uri::from_str(s)
27            .map_err(|e| AddressListError::InvalidAddressUri(e.to_string()))
28            .map(Address::try_from)?
29    }
30}
31
32impl PartialEq<Self> for Address {
33    fn eq(&self, other: &Self) -> bool {
34        self.0 == other.0
35    }
36}
37
38impl PartialEq<Uri> for Address {
39    fn eq(&self, other: &Uri) -> bool {
40        self.0 == *other
41    }
42}
43
44impl Hash for Address {
45    fn hash<H: Hasher>(&self, state: &mut H) {
46        self.0.hash(state);
47    }
48}
49
50impl TryFrom<Uri> for Address {
51    type Error = AddressListError;
52
53    fn try_from(value: Uri) -> Result<Self, Self::Error> {
54        if value.host().is_none() {
55            return Err(AddressListError::InvalidAddressUri(
56                "uri must contain host".to_string(),
57            ));
58        }
59
60        Ok(Address(value))
61    }
62}
63
64impl Address {
65    /// Get [Uri] of a node.
66    pub fn uri(&self) -> &Uri {
67        &self.0
68    }
69}
70
71/// Address status
72/// Contains information about the number of bans and the time until the next ban is lifted.
73#[derive(Debug, Default, Clone)]
74pub struct AddressStatus {
75    ban_count: usize,
76    banned_until: Option<chrono::DateTime<Utc>>,
77    /// Human-readable reason for the most recent ban, if any. Cleared
78    /// on [`AddressStatus::unban`]. Sourced from the error that caused
79    /// the ban (see `update_address_ban_status`).
80    ban_reason: Option<String>,
81}
82
83impl AddressStatus {
84    /// Ban the [Address] so it won't be available through [AddressList::get_live_address] for some time.
85    ///
86    /// Back-compat shim for [`AddressStatus::ban_with_reason`] with no reason.
87    pub fn ban(&mut self, base_ban_period: &Duration) {
88        self.ban_with_reason(base_ban_period, None);
89    }
90
91    /// Ban the [Address] and record the `reason` for the ban.
92    ///
93    /// Applies exponential backoff: the ban window is `base × e^ban_count`
94    /// (where `ban_count` is the value *before* this call), and `banned_until`
95    /// is always re-based to `now + window` unconditionally, regardless of any
96    /// existing active ban.  Concretely, a health failure on a node that already
97    /// holds a longer rate-limit window (set via [`AddressStatus::ban_for`]) will
98    /// re-base `banned_until` to the exponential value, which may be shorter.
99    /// This is intentional: the exponential health-ban ladder owns the window for
100    /// genuinely-unhealthy nodes; the no-shorten guarantee is deliberately scoped
101    /// to `ban_for → ban_for` sequences only.
102    ///
103    /// `ban_count` is incremented and `ban_reason` is updated unconditionally.
104    /// The counter resets to 0 on [`AddressStatus::unban`].
105    pub fn ban_with_reason(&mut self, base_ban_period: &Duration, reason: Option<String>) {
106        let coefficient = (self.ban_count as f64).exp();
107        let ban_period = Duration::from_secs_f64(base_ban_period.as_secs_f64() * coefficient);
108
109        self.banned_until = Some(chrono::Utc::now() + ban_period);
110        self.ban_count += 1;
111        self.ban_reason = reason;
112    }
113
114    /// Ban the address for an exact `period` (server-advertised), bypassing the
115    /// exponential ladder used by [`AddressStatus::ban_with_reason`].
116    ///
117    /// The ban window is flat (not exponential).  `banned_until` is advanced to
118    /// `now + period` only when that timestamp is **later** than the current
119    /// `banned_until`, so a short-reset call never shortens a longer active ban
120    /// (health ban or a prior longer rate-limit ban).  `ban_reason` is updated
121    /// only when the window is extended.  `ban_count` is raised to
122    /// `max(ban_count, 1)` unconditionally so that `is_banned()` and
123    /// `ban_info()` correctly report the node as banned.  Side-effect: a
124    /// previously-clean node (ban_count 0) enters the ladder at floor 1,
125    /// meaning its *next* genuine health failure via
126    /// [`AddressStatus::ban_with_reason`] uses `60 s × e¹ ≈ 163 s` rather
127    /// than the first-rung `60 s × e⁰ = 60 s`.  The counter resets to 0 on
128    /// [`AddressStatus::unban`].
129    ///
130    /// Note: the no-shorten guard applies only to `ban_for → ban_for` call
131    /// sequences.  [`AddressStatus::ban_with_reason`] re-bases `banned_until`
132    /// unconditionally — see its docs for the intentional cross-method semantics.
133    pub fn ban_for(&mut self, period: Duration, reason: Option<String>) {
134        let advertised_until = chrono::Utc::now() + period;
135        if self
136            .banned_until
137            .map(|current| current < advertised_until)
138            .unwrap_or(true)
139        {
140            self.banned_until = Some(advertised_until);
141            self.ban_reason = reason;
142        }
143        self.ban_count = self.ban_count.max(1);
144    }
145
146    /// Check if [Address] is banned.
147    pub fn is_banned(&self) -> bool {
148        self.ban_count > 0
149    }
150
151    /// Clears ban record.
152    pub fn unban(&mut self) {
153        self.ban_count = 0;
154        self.banned_until = None;
155        self.ban_reason = None;
156    }
157}
158
159/// [AddressList] errors
160#[derive(Debug, thiserror::Error, Clone)]
161#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
162pub enum AddressListError {
163    /// A valid uri is required to create an Address
164    #[error("unable parse address: {0}")]
165    #[cfg_attr(feature = "mocks", serde(skip))]
166    InvalidAddressUri(String),
167}
168
169/// A structure to manage DAPI addresses to select from
170/// for [DapiRequest](crate::DapiRequest) execution.
171#[derive(Debug, Clone)]
172pub struct AddressList {
173    addresses: Arc<RwLock<HashMap<Address, AddressStatus>>>,
174    base_ban_period: Duration,
175}
176
177impl Default for AddressList {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183impl std::fmt::Display for Address {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        self.0.fmt(f)
186    }
187}
188
189impl AddressList {
190    /// Creates an empty [AddressList] with default base ban time.
191    pub fn new() -> Self {
192        AddressList::with_settings(DEFAULT_BASE_BAN_PERIOD)
193    }
194
195    /// Creates an empty [AddressList] with adjustable base ban time.
196    pub fn with_settings(base_ban_period: Duration) -> Self {
197        AddressList {
198            addresses: Arc::new(RwLock::new(HashMap::new())),
199            base_ban_period,
200        }
201    }
202
203    /// Bans address
204    /// Returns false if the address is not in the list.
205    ///
206    /// Back-compat shim for [`AddressList::ban_with_reason`] with no reason.
207    pub fn ban(&self, address: &Address) -> bool {
208        self.ban_with_reason(address, None)
209    }
210
211    /// Bans address, recording the `reason` for the ban.
212    /// Returns false if the address is not in the list.
213    pub fn ban_with_reason(&self, address: &Address, reason: Option<String>) -> bool {
214        let mut guard = self.addresses.write().unwrap();
215
216        let Some(status) = guard.get_mut(address) else {
217            return false;
218        };
219
220        status.ban_with_reason(&self.base_ban_period, reason);
221
222        true
223    }
224
225    /// Ban the address for an exact `period` (server-advertised); delegates to
226    /// [`AddressStatus::ban_for`] — see that method for the full contract
227    /// including the `ban_count` floor and ladder side-effect.
228    ///
229    /// Returns `false` if the address is not in the list.
230    pub fn ban_for(&self, address: &Address, period: Duration, reason: Option<String>) -> bool {
231        let mut guard = self.addresses.write().unwrap();
232
233        let Some(status) = guard.get_mut(address) else {
234            return false;
235        };
236
237        status.ban_for(period, reason);
238
239        true
240    }
241
242    /// Clears address' ban record
243    /// Returns false if the address is not in the list.
244    pub fn unban(&self, address: &Address) -> bool {
245        let mut guard = self.addresses.write().unwrap();
246
247        let Some(status) = guard.get_mut(address) else {
248            return false;
249        };
250
251        status.unban();
252
253        true
254    }
255
256    /// Check if the address is banned.
257    pub fn is_banned(&self, address: &Address) -> bool {
258        let guard = self.addresses.read().unwrap();
259
260        guard
261            .get(address)
262            .map(|status| status.is_banned())
263            .unwrap_or(false)
264    }
265
266    /// Adds a node [Address] to [AddressList]
267    /// Returns false if the address is already in the list.
268    pub fn add(&mut self, address: Address) -> bool {
269        let mut guard = self.addresses.write().unwrap();
270
271        match guard.entry(address) {
272            Entry::Occupied(_) => false,
273            Entry::Vacant(e) => {
274                e.insert(AddressStatus::default());
275
276                true
277            }
278        }
279    }
280
281    /// Remove address from the list
282    /// Returns [AddressStatus] if the address was in the list.
283    pub fn remove(&mut self, address: &Address) -> Option<AddressStatus> {
284        let mut guard = self.addresses.write().unwrap();
285
286        guard.remove(address)
287    }
288
289    #[deprecated]
290    // TODO: Remove in favor of add
291    /// Add a node [Address] to [AddressList] by [Uri].
292    /// Returns false if the address is already in the list.
293    pub fn add_uri(&mut self, uri: Uri) -> bool {
294        self.add(Address::try_from(uri).expect("valid uri"))
295    }
296
297    /// Randomly select a not-banned address.
298    ///
299    /// An address is considered live when it has never been banned or when its
300    /// ban period has already expired.
301    pub fn get_live_address(&self) -> Option<Address> {
302        // TODO(low): module-wide `.read()/.write().unwrap()` panics on a
303        // poisoned lock; adopt poison-tolerant locking consistently (SEC-003).
304        let guard = self.addresses.read().unwrap();
305
306        let mut rng = SmallRng::from_entropy();
307        let now = chrono::Utc::now();
308
309        guard
310            .iter()
311            .filter(|(_, status)| {
312                status
313                    .banned_until
314                    .map(|banned_until| banned_until < now)
315                    .unwrap_or(true)
316            })
317            .choose(&mut rng)
318            .map(|(addr, _)| addr.clone())
319    }
320
321    /// Get all not banned addresses.
322    ///
323    /// Returns a vector of addresses that are not currently banned or whose ban period has expired.
324    /// The returned addresses use the same filtering logic as [`Self::get_live_address`], checking if the
325    /// ban period has expired based on the current time.
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use rs_dapi_client::{AddressList, Address};
331    ///
332    /// let mut list = AddressList::new();
333    /// list.add("http://127.0.0.1:3000".parse().unwrap());
334    /// list.add("http://127.0.0.1:3001".parse().unwrap());
335    ///
336    /// // Get all non-banned addresses
337    /// let live_addresses = list.get_live_addresses();
338    /// assert_eq!(live_addresses.len(), 2);
339    /// ```
340    pub fn get_live_addresses(&self) -> Vec<Address> {
341        let guard = self.addresses.read().unwrap();
342
343        let now = chrono::Utc::now();
344
345        guard
346            .iter()
347            .filter(|(_, status)| {
348                status
349                    .banned_until
350                    .map(|banned_until| banned_until < now)
351                    .unwrap_or(true)
352            })
353            .map(|(addr, _)| addr.clone())
354            .collect()
355    }
356
357    /// Get an owned snapshot of every address' ban state.
358    ///
359    /// Clones the current state into an owned `Vec<AddressBanInfo>` so
360    /// it can be inspected without holding the internal lock. The
361    /// `banned` flag reflects the *currently effectively banned*
362    /// semantics used by [`AddressList::get_live_address`]: the address
363    /// has been banned at least once (`ban_count > 0`) and its ban
364    /// period has not yet expired (`banned_until` is in the future).
365    pub fn ban_info(&self) -> Vec<AddressBanInfo> {
366        let guard = self.addresses.read().unwrap();
367
368        let now = chrono::Utc::now();
369
370        guard
371            .iter()
372            .map(|(addr, status)| {
373                let banned = status.ban_count > 0
374                    && status
375                        .banned_until
376                        .map(|banned_until| banned_until >= now)
377                        .unwrap_or(false);
378                AddressBanInfo {
379                    uri: addr.to_string(),
380                    banned,
381                    ban_count: status.ban_count,
382                    banned_until: status.banned_until,
383                    reason: status.ban_reason.clone(),
384                }
385            })
386            .collect()
387    }
388
389    /// Get number of all addresses, both banned and not banned.
390    pub fn len(&self) -> usize {
391        self.addresses.read().unwrap().len()
392    }
393
394    /// Check if the list is empty.
395    /// Returns true if there are no addresses in the list.
396    /// Returns false if there is at least one address in the list.
397    /// Banned addresses are also counted.
398    pub fn is_empty(&self) -> bool {
399        self.addresses.read().unwrap().is_empty()
400    }
401}
402
403impl IntoIterator for AddressList {
404    type Item = (Address, AddressStatus);
405    type IntoIter = std::collections::hash_map::IntoIter<Address, AddressStatus>;
406
407    fn into_iter(self) -> Self::IntoIter {
408        let mut guard = self.addresses.write().unwrap();
409
410        let addresses_map = mem::take(&mut *guard);
411
412        addresses_map.into_iter()
413    }
414}
415
416impl FromStr for AddressList {
417    type Err = AddressListError;
418
419    fn from_str(s: &str) -> Result<Self, Self::Err> {
420        let uri_list: Vec<Address> = s
421            .split(',')
422            .map(Address::from_str)
423            .collect::<Result<_, _>>()?;
424
425        Ok(Self::from_iter(uri_list))
426    }
427}
428
429impl FromIterator<Address> for AddressList {
430    fn from_iter<T: IntoIterator<Item = Address>>(iter: T) -> Self {
431        let mut address_list = Self::new();
432        for uri in iter {
433            address_list.add(uri);
434        }
435
436        address_list
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_get_live_addresses_empty_list() {
446        let list = AddressList::new();
447        let live_addresses = list.get_live_addresses();
448        assert_eq!(live_addresses.len(), 0);
449    }
450
451    #[test]
452    fn test_get_live_addresses_all_unbanned() {
453        let mut list = AddressList::new();
454        list.add("http://127.0.0.1:3000".parse().unwrap());
455        list.add("http://127.0.0.1:3001".parse().unwrap());
456        list.add("http://127.0.0.1:3002".parse().unwrap());
457
458        let live_addresses = list.get_live_addresses();
459        assert_eq!(live_addresses.len(), 3);
460    }
461
462    #[test]
463    fn test_get_live_addresses_some_banned() {
464        let mut list = AddressList::new();
465        let addr1: Address = "http://127.0.0.1:3000".parse().unwrap();
466        let addr2: Address = "http://127.0.0.1:3001".parse().unwrap();
467        let addr3: Address = "http://127.0.0.1:3002".parse().unwrap();
468
469        list.add(addr1.clone());
470        list.add(addr2.clone());
471        list.add(addr3.clone());
472
473        // Ban addr2
474        list.ban(&addr2);
475
476        let live_addresses = list.get_live_addresses();
477        assert_eq!(live_addresses.len(), 2);
478        assert!(live_addresses.contains(&addr1));
479        assert!(live_addresses.contains(&addr3));
480        assert!(!live_addresses.contains(&addr2));
481    }
482
483    #[test]
484    fn test_get_live_addresses_all_banned() {
485        let mut list = AddressList::new();
486        let addr1: Address = "http://127.0.0.1:3000".parse().unwrap();
487        let addr2: Address = "http://127.0.0.1:3001".parse().unwrap();
488
489        list.add(addr1.clone());
490        list.add(addr2.clone());
491
492        // Ban all addresses
493        list.ban(&addr1);
494        list.ban(&addr2);
495
496        let live_addresses = list.get_live_addresses();
497        assert_eq!(live_addresses.len(), 0);
498    }
499
500    #[test]
501    fn test_get_live_addresses_unbanned_after_ban() {
502        let mut list = AddressList::new();
503        let addr1: Address = "http://127.0.0.1:3000".parse().unwrap();
504
505        list.add(addr1.clone());
506
507        // Ban and then unban
508        list.ban(&addr1);
509        list.unban(&addr1);
510
511        let live_addresses = list.get_live_addresses();
512        assert_eq!(live_addresses.len(), 1);
513        assert!(live_addresses.contains(&addr1));
514    }
515
516    #[test]
517    fn test_address_try_from_uri_without_host() {
518        let uri: Uri = Uri::from_str("/path/only").unwrap();
519        let result = Address::try_from(uri);
520        assert!(result.is_err());
521        let err = result.unwrap_err();
522        assert!(matches!(err, AddressListError::InvalidAddressUri(_)));
523    }
524
525    #[test]
526    fn test_address_from_str_invalid_uri() {
527        // Use a string with invalid URI characters that http::Uri rejects
528        let result = Address::from_str("not a valid uri\x00");
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn test_address_uri_accessor() {
534        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
535        let uri = addr.uri();
536        assert_eq!(uri.host(), Some("127.0.0.1"));
537    }
538
539    #[test]
540    fn test_address_partial_eq_with_uri() {
541        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
542        let uri = Uri::from_str("http://127.0.0.1:3000").unwrap();
543        assert!(addr == uri);
544
545        let other_uri = Uri::from_str("http://127.0.0.1:4000").unwrap();
546        assert!(addr != other_uri);
547    }
548
549    #[test]
550    fn test_address_display() {
551        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
552        let display = format!("{}", addr);
553        assert!(display.contains("127.0.0.1"));
554    }
555
556    #[test]
557    fn test_address_status_is_banned() {
558        let mut status = AddressStatus::default();
559        assert!(!status.is_banned());
560
561        status.ban(&Duration::from_secs(60));
562        assert!(status.is_banned());
563
564        status.unban();
565        assert!(!status.is_banned());
566    }
567
568    #[test]
569    fn test_address_status_exponential_ban() {
570        let mut status = AddressStatus::default();
571        let base_period = Duration::from_secs(1);
572
573        // First ban: coefficient = exp(0) = 1, period = 1s
574        status.ban(&base_period);
575        assert_eq!(status.ban_count, 1);
576        assert!(status.banned_until.is_some());
577
578        // Second ban: coefficient = exp(1) ~= 2.718, period ~= 2.718s
579        status.ban(&base_period);
580        assert_eq!(status.ban_count, 2);
581    }
582
583    #[test]
584    fn test_address_list_is_empty() {
585        let list = AddressList::new();
586        assert!(list.is_empty());
587
588        let mut list = AddressList::new();
589        list.add("http://127.0.0.1:3000".parse().unwrap());
590        assert!(!list.is_empty());
591    }
592
593    #[test]
594    fn test_address_list_len() {
595        let mut list = AddressList::new();
596        assert_eq!(list.len(), 0);
597
598        list.add("http://127.0.0.1:3000".parse().unwrap());
599        assert_eq!(list.len(), 1);
600
601        list.add("http://127.0.0.1:3001".parse().unwrap());
602        assert_eq!(list.len(), 2);
603    }
604
605    #[test]
606    fn test_address_list_add_duplicate() {
607        let mut list = AddressList::new();
608        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
609
610        assert!(list.add(addr.clone()));
611        assert!(!list.add(addr)); // duplicate returns false
612        assert_eq!(list.len(), 1);
613    }
614
615    #[test]
616    fn test_address_list_remove() {
617        let mut list = AddressList::new();
618        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
619
620        list.add(addr.clone());
621        assert_eq!(list.len(), 1);
622
623        let removed = list.remove(&addr);
624        assert!(removed.is_some());
625        assert_eq!(list.len(), 0);
626
627        // Removing non-existent address returns None
628        let removed = list.remove(&addr);
629        assert!(removed.is_none());
630    }
631
632    #[test]
633    fn test_address_list_ban_nonexistent() {
634        let list = AddressList::new();
635        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
636        assert!(!list.ban(&addr));
637    }
638
639    #[test]
640    fn test_address_list_unban_nonexistent() {
641        let list = AddressList::new();
642        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
643        assert!(!list.unban(&addr));
644    }
645
646    #[test]
647    fn test_address_list_is_banned() {
648        let mut list = AddressList::new();
649        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
650        let unknown: Address = "http://127.0.0.1:9999".parse().unwrap();
651
652        list.add(addr.clone());
653
654        assert!(!list.is_banned(&addr));
655        assert!(!list.is_banned(&unknown)); // unknown returns false
656
657        list.ban(&addr);
658        assert!(list.is_banned(&addr));
659    }
660
661    #[test]
662    fn test_address_list_from_str() {
663        let list: AddressList = "http://127.0.0.1:3000,http://127.0.0.1:3001"
664            .parse()
665            .unwrap();
666        assert_eq!(list.len(), 2);
667    }
668
669    #[test]
670    fn test_address_list_from_str_single() {
671        let list: AddressList = "http://127.0.0.1:3000".parse().unwrap();
672        assert_eq!(list.len(), 1);
673    }
674
675    #[test]
676    fn test_address_list_from_str_invalid() {
677        let result: Result<AddressList, _> = "not a valid uri\x00".parse();
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn test_address_list_get_live_address_returns_none_when_empty() {
683        let list = AddressList::new();
684        assert!(list.get_live_address().is_none());
685    }
686
687    #[test]
688    fn test_address_list_get_live_address_returns_some_when_available() {
689        let mut list = AddressList::new();
690        list.add("http://127.0.0.1:3000".parse().unwrap());
691        assert!(list.get_live_address().is_some());
692    }
693
694    #[test]
695    fn test_address_list_into_iter() {
696        let mut list = AddressList::new();
697        list.add("http://127.0.0.1:3000".parse().unwrap());
698        list.add("http://127.0.0.1:3001".parse().unwrap());
699
700        let items: Vec<_> = list.into_iter().collect();
701        assert_eq!(items.len(), 2);
702    }
703
704    #[test]
705    fn test_address_list_with_settings() {
706        let list = AddressList::with_settings(Duration::from_secs(120));
707        assert!(list.is_empty());
708    }
709
710    #[test]
711    fn test_address_list_default() {
712        let list = AddressList::default();
713        assert!(list.is_empty());
714    }
715
716    #[test]
717    fn test_address_status_ban_with_reason_stores_reason() {
718        let mut status = AddressStatus::default();
719        assert!(status.ban_reason.is_none());
720
721        status.ban_with_reason(
722            &Duration::from_secs(60),
723            Some("transport error".to_string()),
724        );
725        assert_eq!(status.ban_reason.as_deref(), Some("transport error"));
726        assert!(status.is_banned());
727    }
728
729    #[test]
730    fn test_address_status_ban_without_reason_is_none() {
731        let mut status = AddressStatus::default();
732        status.ban(&Duration::from_secs(60));
733        assert!(status.ban_reason.is_none());
734        assert!(status.is_banned());
735    }
736
737    #[test]
738    fn test_address_status_unban_clears_reason() {
739        let mut status = AddressStatus::default();
740        status.ban_with_reason(&Duration::from_secs(60), Some("boom".to_string()));
741        assert_eq!(status.ban_reason.as_deref(), Some("boom"));
742
743        status.unban();
744        assert!(status.ban_reason.is_none());
745        assert!(!status.is_banned());
746    }
747
748    #[test]
749    fn test_address_list_ban_with_reason_records_reason() {
750        let mut list = AddressList::new();
751        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
752        list.add(addr.clone());
753
754        assert!(list.ban_with_reason(&addr, Some("node down".to_string())));
755
756        let info = list.ban_info();
757        assert_eq!(info.len(), 1);
758        let entry = &info[0];
759        assert_eq!(entry.reason.as_deref(), Some("node down"));
760        assert!(entry.banned);
761        assert_eq!(entry.ban_count, 1);
762        assert!(entry.banned_until.is_some());
763    }
764
765    #[test]
766    fn test_address_list_ban_without_reason_records_none() {
767        let mut list = AddressList::new();
768        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
769        list.add(addr.clone());
770
771        assert!(list.ban(&addr));
772
773        let info = list.ban_info();
774        assert_eq!(info.len(), 1);
775        assert!(info[0].reason.is_none());
776        assert!(info[0].banned);
777    }
778
779    #[test]
780    fn test_address_list_unban_clears_reason_in_ban_info() {
781        let mut list = AddressList::new();
782        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
783        list.add(addr.clone());
784
785        list.ban_with_reason(&addr, Some("oops".to_string()));
786        assert!(list.unban(&addr));
787
788        let info = list.ban_info();
789        assert_eq!(info.len(), 1);
790        let entry = &info[0];
791        assert!(entry.reason.is_none());
792        assert!(!entry.banned);
793        assert_eq!(entry.ban_count, 0);
794        assert!(entry.banned_until.is_none());
795    }
796
797    #[test]
798    fn test_ban_info_reflects_unbanned_address() {
799        let mut list = AddressList::new();
800        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
801        list.add(addr.clone());
802
803        // Never banned: banned == false, no reason, ban_count == 0.
804        let info = list.ban_info();
805        assert_eq!(info.len(), 1);
806        let entry = &info[0];
807        assert!(!entry.banned);
808        assert_eq!(entry.ban_count, 0);
809        assert!(entry.banned_until.is_none());
810        assert!(entry.reason.is_none());
811        assert!(entry.uri.contains("127.0.0.1"));
812    }
813
814    #[test]
815    fn test_ban_info_empty_list() {
816        let list = AddressList::new();
817        assert!(list.ban_info().is_empty());
818    }
819
820    #[test]
821    fn test_address_status_ban_for_sets_exact_window_and_min_ban_count() {
822        let mut status = AddressStatus::default();
823        assert_eq!(status.ban_count, 0);
824        assert!(status.banned_until.is_none());
825
826        let before = chrono::Utc::now();
827        status.ban_for(Duration::from_secs(45), Some("rate limited".into()));
828        let after = chrono::Utc::now();
829
830        // ban_count must be at least 1 so is_banned() / ban_info().banned are consistent.
831        assert_eq!(status.ban_count, 1, "ban_for sets ban_count to max(0,1)=1");
832
833        // banned_until should be roughly now + 45 s.
834        let until = status.banned_until.expect("banned_until must be set");
835        let lower = (until - before).num_milliseconds() as f64 / 1000.0;
836        let upper = (until - after).num_milliseconds() as f64 / 1000.0;
837        assert!(
838            lower >= 44.9,
839            "banned_until lower bound too short: {lower}s"
840        );
841        assert!(upper <= 45.1, "banned_until upper bound too long: {upper}s");
842        assert_eq!(status.ban_reason.as_deref(), Some("rate limited"));
843    }
844
845    /// `ban_for` on a fresh node (ban_count = 0) raises ban_count to 1 (the
846    /// ladder floor).  That means the *next* genuine health ban will escalate
847    /// from position 1 (~163 s) instead of position 0 (~60 s).  This pins the
848    /// documented side-effect so regressions are caught.
849    #[test]
850    fn test_ban_for_raises_fresh_node_to_ladder_floor() {
851        let mut status = AddressStatus::default();
852        assert_eq!(status.ban_count, 0, "starts clean");
853
854        // Rate-limit ban on a never-before-banned node.
855        status.ban_for(Duration::from_secs(10), Some("rl".into()));
856        assert_eq!(
857            status.ban_count, 1,
858            "ban_for must raise ban_count 0 → 1 (ladder floor)"
859        );
860
861        // Subsequent genuine health failure must escalate from the floor (1),
862        // yielding ~60 s × e^1 ≈ 163 s, NOT the first-rung ~60 s × e^0 = 60 s.
863        let base = Duration::from_secs(60);
864        let before = chrono::Utc::now();
865        status.ban_with_reason(&base, None); // ban_count 1 → 2; window = 60s × e^1
866        let after = chrono::Utc::now();
867        assert_eq!(status.ban_count, 2);
868
869        let until = status.banned_until.expect("banned_until set");
870        let lo = (until - before).num_milliseconds() as f64 / 1000.0;
871        let hi = (until - after).num_milliseconds() as f64 / 1000.0;
872        let expected = 60.0_f64 * std::f64::consts::E; // ≈ 163 s
873        assert!(
874            lo >= expected - 0.5,
875            "window lower {lo:.1}s < expected {expected:.1}s (should escalate from floor 1)"
876        );
877        assert!(
878            hi <= expected + 0.5,
879            "window upper {hi:.1}s > expected {expected:.1}s"
880        );
881    }
882
883    #[test]
884    fn test_address_status_ban_for_does_not_inflate_existing_ban_count() {
885        // A node already health-banned (ban_count = 3) gets rate-limited.
886        // ban_count must stay at 3, not grow to 4.
887        let mut status = AddressStatus::default();
888        let base = Duration::from_secs(60);
889        status.ban_with_reason(&base, None); // → 1
890        status.ban_with_reason(&base, None); // → 2
891        status.ban_with_reason(&base, None); // → 3
892        status.ban_for(Duration::from_secs(30), Some("rl".into()));
893        assert_eq!(
894            status.ban_count, 3,
895            "ban_for must not inflate ban_count above its existing value"
896        );
897    }
898
899    #[test]
900    fn test_address_list_ban_for_returns_false_for_unknown() {
901        let list = AddressList::new();
902        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
903        assert!(!list.ban_for(&addr, Duration::from_secs(5), None));
904    }
905
906    #[test]
907    fn test_address_list_ban_for_bans_known_address() {
908        let mut list = AddressList::new();
909        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
910        list.add(addr.clone());
911
912        assert!(list.ban_for(&addr, Duration::from_secs(60), Some("rl".into())));
913        // The address must now be hidden from get_live_address.
914        assert!(list.get_live_address().is_none());
915        // ban_count is 1 (ban_for sets max(0,1)).
916        let info = list.ban_info();
917        assert_eq!(info.len(), 1);
918        assert!(info[0].banned);
919        assert_eq!(info[0].ban_count, 1);
920    }
921
922    /// After `ban_for`'s window expires the address re-enters rotation via
923    /// `get_live_address`.  We verify both directions: the node is hidden during
924    /// an active window, and becomes live once the window has passed.
925    ///
926    /// Window-expiry reinstatement is orthogonal to `unban()`: `get_live_address`
927    /// reinstates a node purely on `banned_until < now` regardless of `ban_count`,
928    /// so after expiry the node is live again while `is_banned()` (ban_count > 0)
929    /// is still true.  This is a different path from `unban()`, which also zeroes
930    /// `ban_count`.
931    #[test]
932    fn test_ban_for_address_re_enters_rotation_after_window_expires() {
933        let mut list = AddressList::new();
934        let addr: Address = "http://127.0.0.1:3000".parse().unwrap();
935        list.add(addr.clone());
936
937        // Active 300-second window → node hidden.
938        assert!(list.ban_for(&addr, Duration::from_secs(300), Some("rl".into())));
939        assert!(
940            list.get_live_address().is_none(),
941            "node must be hidden during active ban window"
942        );
943
944        // Simulate window expiry by back-dating banned_until — do NOT touch ban_count.
945        {
946            let mut guard = list.addresses.write().unwrap();
947            let status = guard.get_mut(&addr).expect("addr must be in list");
948            status.banned_until = Some(chrono::Utc::now() - Duration::from_secs(1));
949        }
950
951        // After window expiry the node must re-enter rotation …
952        assert!(
953            list.get_live_address().is_some(),
954            "address must re-enter rotation after ban window expires"
955        );
956        // … but ban_count is still > 0, so is_banned() remains true.
957        // This distinguishes window-expiry from an explicit unban().
958        assert!(
959            list.is_banned(&addr),
960            "is_banned() must still be true after window expiry (ban_count not reset)"
961        );
962    }
963
964    /// Invariant 1 at the ladder source: the exponential ban window is
965    /// `base × e^ban_count`, `ban_count` incrementing on each ban. This pins the
966    /// exact formula independently of the `update_address_ban_status` entrypoint.
967    #[test]
968    fn test_ban_ladder_windows_match_exponential_formula() {
969        let mut status = AddressStatus::default();
970        let base_secs = 60.0_f64;
971        let base = Duration::from_secs(60);
972
973        for n in 0..3usize {
974            // coefficient uses ban_count BEFORE this ban (== n here).
975            let before = chrono::Utc::now();
976            status.ban(&base);
977            let after = chrono::Utc::now();
978
979            assert_eq!(status.ban_count, n + 1, "ban_count must increment");
980            let period = base_secs * (n as f64).exp();
981            let banned_until = status.banned_until.expect("banned_until is set");
982            let lower = (banned_until - before).num_milliseconds() as f64 / 1000.0;
983            let upper = (banned_until - after).num_milliseconds() as f64 / 1000.0;
984            assert!(
985                lower >= period - 0.05,
986                "ban #{} window lower bound {lower}s < expected {period}s",
987                n + 1
988            );
989            assert!(
990                upper <= period + 0.05,
991                "ban #{} window upper bound {upper}s > expected {period}s",
992                n + 1
993            );
994        }
995    }
996}