Skip to main content

stygian_proxy/
session.rs

1//! Domain-scoped proxy session stickiness.
2//!
3//! A *sticky session* binds a target domain to a specific proxy for a
4//! configurable TTL. Requests to the same domain reuse the same proxy,
5//! preserving IP consistency for anti-bot fingerprint checks while still
6//! rotating across different domains.
7//!
8//! # Example
9//!
10//! ```
11//! use stygian_proxy::session::{SessionMap, StickyPolicy};
12//! use std::time::Duration;
13//! use uuid::Uuid;
14//!
15//! let map = SessionMap::new();
16//! let ttl = Duration::from_secs(300);
17//! let proxy_id = Uuid::new_v4();
18//!
19//! map.bind("example.com", proxy_id, ttl);
20//! assert_eq!(map.lookup("example.com"), Some(proxy_id));
21//! ```
22
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28use tokio::sync::RwLock;
29use uuid::Uuid;
30
31use crate::stickiness::{StickinessPolicy, VendorStickinessMap};
32use crate::types::VendorId;
33
34/// Default session TTL: 5 minutes.
35const DEFAULT_TTL_SECS: u64 = 300;
36
37// ── StickyPolicy ─────────────────────────────────────────────────────────────
38
39/// Policy controlling when and how proxy sessions are pinned to a key.
40///
41/// # Example
42///
43/// ```
44/// use stygian_proxy::session::StickyPolicy;
45/// use std::time::Duration;
46///
47/// let policy = StickyPolicy::domain(Duration::from_secs(600));
48/// assert!(!policy.is_disabled());
49/// ```
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", tag = "mode")]
52#[non_exhaustive]
53pub enum StickyPolicy {
54    /// No session stickiness — every request may use a different proxy.
55    #[default]
56    Disabled,
57    /// Pin by domain with a fixed TTL per binding.
58    Domain {
59        /// How long a domain→proxy binding remains valid.
60        #[serde(with = "serde_duration_secs")]
61        ttl: Duration,
62    },
63}
64
65impl StickyPolicy {
66    /// Create a domain-scoped policy with the given TTL.
67    #[must_use]
68    pub const fn domain(ttl: Duration) -> Self {
69        Self::Domain { ttl }
70    }
71
72    /// Create a domain-scoped policy with the default TTL (5 minutes).
73    #[must_use]
74    pub const fn domain_default() -> Self {
75        Self::Domain {
76            ttl: Duration::from_secs(DEFAULT_TTL_SECS),
77        }
78    }
79
80    /// Returns `true` when session stickiness is turned off.
81    #[must_use]
82    pub const fn is_disabled(&self) -> bool {
83        matches!(self, Self::Disabled)
84    }
85}
86
87// ── ProxySession ─────────────────────────────────────────────────────────────
88
89/// A single domain→proxy binding with an expiration deadline.
90#[derive(Debug, Clone)]
91struct ProxySession {
92    /// The proxy this session is bound to.
93    proxy_id: Uuid,
94    /// When this session was created.
95    bound_at: Instant,
96    /// How long the binding is valid.
97    ttl: Duration,
98}
99
100impl ProxySession {
101    /// Returns `true` when `bound_at + ttl` has elapsed.
102    fn is_expired(&self) -> bool {
103        self.bound_at.elapsed() >= self.ttl
104    }
105}
106
107// ── SessionMap ───────────────────────────────────────────────────────────────
108
109/// Thread-safe map of session keys (typically domains) to proxy bindings.
110///
111/// All operations acquire short-lived locks to minimise contention.
112///
113/// # Example
114///
115/// ```
116/// use stygian_proxy::session::SessionMap;
117/// use std::time::Duration;
118/// use uuid::Uuid;
119///
120/// let map = SessionMap::new();
121/// let id = Uuid::new_v4();
122/// map.bind("example.com", id, Duration::from_secs(60));
123/// assert_eq!(map.lookup("example.com"), Some(id));
124/// ```
125#[derive(Debug, Clone)]
126pub struct SessionMap {
127    inner: Arc<RwLock<HashMap<String, ProxySession>>>,
128}
129
130impl Default for SessionMap {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl SessionMap {
137    /// Create an empty session map.
138    #[must_use]
139    pub fn new() -> Self {
140        Self {
141            inner: Arc::new(RwLock::new(HashMap::new())),
142        }
143    }
144
145    /// Look up the proxy bound to `key`, returning `None` when no session
146    /// exists or the existing session has expired.
147    ///
148    /// Expired entries are lazily removed on the next [`bind`](Self::bind)
149    /// or [`purge_expired`](Self::purge_expired) call.
150    #[must_use]
151    pub fn lookup(&self, key: &str) -> Option<Uuid> {
152        // try_read avoids blocking if a write is in progress.
153        let guard = self.inner.try_read().ok()?;
154        guard
155            .get(key)
156            .filter(|s| !s.is_expired())
157            .map(|s| s.proxy_id)
158    }
159
160    /// Bind `key` to `proxy_id` with the given TTL. Overwrites any existing
161    /// session for the same key.
162    pub fn bind(&self, key: &str, proxy_id: Uuid, ttl: Duration) {
163        let session = ProxySession {
164            proxy_id,
165            bound_at: Instant::now(),
166            ttl,
167        };
168        if let Ok(mut guard) = self.inner.try_write() {
169            guard.insert(key.to_string(), session);
170        }
171    }
172
173    /// Remove all expired sessions, returning the number removed.
174    #[must_use]
175    pub fn purge_expired(&self) -> usize {
176        let Ok(mut guard) = self.inner.try_write() else {
177            return 0;
178        };
179        let before = guard.len();
180        guard.retain(|_, s| !s.is_expired());
181        before - guard.len()
182    }
183
184    /// Remove a specific session by key.
185    pub fn unbind(&self, key: &str) {
186        if let Ok(mut guard) = self.inner.try_write() {
187            guard.remove(key);
188        }
189    }
190
191    /// Returns the number of active (non-expired) sessions.
192    #[must_use]
193    pub fn active_count(&self) -> usize {
194        let Ok(guard) = self.inner.try_read() else {
195            return 0;
196        };
197        guard.values().filter(|s| !s.is_expired()).count()
198    }
199
200    /// Acquire (or refresh) a sticky session for `(domain, vendor)`
201    /// according to `policy_map`.
202    ///
203    /// Translates the 2026 guide's per-vendor stickiness matrix
204    /// ([`VendorStickinessMap::with_builtin_defaults`]) into a typed
205    /// [`SessionDecision`]:
206    ///
207    /// - [`StickinessPolicy::StickyForever`] and
208    ///   [`StickinessPolicy::StickyForTtl`] → reuse an existing binding
209    ///   when present, otherwise emit
210    ///   [`SessionDecision::AcquireAndBind`] with the policy TTL (or
211    ///   [`Duration::MAX`](Duration) for `StickyForever`).
212    /// - [`StickinessPolicy::FreshPerDomain`] → evict any existing
213    ///   binding for `domain` and emit
214    ///   [`SessionDecision::AcquireFresh`].
215    /// - [`StickinessPolicy::FreshPerRequest`] → emit
216    ///   [`SessionDecision::AcquireFresh`] (no binding to evict).
217    /// - [`StickinessPolicy::StickyForRequestCount`] → emitted as
218    ///   [`SessionDecision::AcquireFresh`]. The request-count stickiness
219    ///   is documented as a no-op at this layer because the [`SessionMap`]
220    ///   does not count requests per session; operators that need
221    ///   per-request counting should switch to `StickyForTtl` instead.
222    ///
223    /// Unknown vendors (those without an explicit entry in `policy_map`)
224    /// fall back to [`StickinessPolicy::FreshPerRequest`] (the safest
225    /// default) and so always emit [`SessionDecision::AcquireFresh`].
226    ///
227    /// This method is pure — it never blocks on async I/O and never
228    /// touches the rotation strategy — so it is safe to call from the
229    /// hot acquisition path.
230    ///
231    /// # Example
232    ///
233    /// ```
234    /// use stygian_proxy::session::{SessionDecision, SessionMap};
235    /// use stygian_proxy::stickiness::VendorStickinessMap;
236    /// use stygian_proxy::types::VendorId;
237    /// use std::time::Duration;
238    /// use uuid::Uuid;
239    ///
240    /// let map = SessionMap::new();
241    /// let policy = VendorStickinessMap::with_builtin_defaults();
242    ///
243    /// // Pre-bind as if the manager already acquired a proxy.
244    /// let proxy_id = Uuid::new_v4();
245    /// map.bind("example.com", proxy_id, Duration::from_mins(30));
246    ///
247    /// // Subsequent `acquire_session` for `Akamai` reuses the binding.
248    /// let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
249    /// assert_eq!(decision, SessionDecision::UseSticky(proxy_id));
250    ///
251    /// // `PerimeterX` always evicts the binding and asks for fresh
252    /// // (FreshPerDomain semantics from the 2026 guide).
253    /// let decision = map.acquire_session("example.com", VendorId::PerimeterX, &policy);
254    /// assert_eq!(decision, SessionDecision::AcquireFresh);
255    /// assert_eq!(map.lookup("example.com"), None, "PerimeterX evicts the binding");
256    /// ```
257    #[must_use]
258    pub fn acquire_session(
259        &self,
260        domain: &str,
261        vendor: VendorId,
262        policy_map: &VendorStickinessMap,
263    ) -> SessionDecision {
264        let policy = policy_map.for_vendor(vendor);
265        let ttl = match policy {
266            StickinessPolicy::StickyForever => Some(Duration::MAX),
267            StickinessPolicy::StickyForTtl { ttl } => Some(ttl),
268            StickinessPolicy::StickyForRequestCount { .. }
269            | StickinessPolicy::FreshPerRequest
270            | StickinessPolicy::FreshPerDomain => None,
271        };
272
273        // Sticky path: reuse existing binding, otherwise ask caller to
274        // acquire-and-bind with the policy TTL. Fresh path: evict any
275        // prior binding for `FreshPerDomain` and always ask caller to
276        // acquire fresh — `FreshPerRequest` and
277        // `StickyForRequestCount` leave any existing binding untouched
278        // (no per-domain semantics).
279        let evict_for_fresh_domain = matches!(policy, StickinessPolicy::FreshPerDomain);
280        ttl.map_or_else(
281            || {
282                if evict_for_fresh_domain {
283                    self.unbind(domain);
284                }
285                SessionDecision::AcquireFresh
286            },
287            |ttl| {
288                self.lookup(domain).map_or(
289                    SessionDecision::AcquireAndBind(ttl),
290                    SessionDecision::UseSticky,
291                )
292            },
293        )
294    }
295}
296
297// ── SessionDecision ──────────────────────────────────────────────────────────
298
299/// Outcome of [`SessionMap::acquire_session`].
300///
301/// Describes whether the caller should reuse an existing sticky binding,
302/// acquire a fresh proxy (without binding), or acquire a fresh proxy and
303/// bind it for a TTL. Returned by `acquire_session` so the caller can
304/// drive the rotation-strategy call itself (the
305/// [`SessionMap`](Self) stays pure — no async, no strategy dependency).
306///
307/// # Example
308///
309/// ```
310/// use stygian_proxy::session::SessionDecision;
311/// use std::time::Duration;
312/// use uuid::Uuid;
313///
314/// let id = Uuid::new_v4();
315/// let decision = SessionDecision::UseSticky(id);
316/// assert!(matches!(decision, SessionDecision::UseSticky(_)));
317/// let decision = SessionDecision::AcquireAndBind(Duration::from_mins(30));
318/// assert!(matches!(decision, SessionDecision::AcquireAndBind(_)));
319/// ```
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum SessionDecision {
322    /// Reuse the existing sticky binding for `proxy_id`. No fresh
323    /// acquisition is needed.
324    UseSticky(Uuid),
325    /// Acquire a fresh proxy via the rotation strategy; do **not** bind
326    /// it. Used for `FreshPerRequest` (and as the safe fallback for
327    /// `StickyForRequestCount`).
328    AcquireFresh,
329    /// Acquire a fresh proxy via the rotation strategy and bind it for
330    /// `ttl`. Used for `StickyForTtl` and `StickyForever` when no
331    /// binding currently exists.
332    AcquireAndBind(Duration),
333}
334
335// ── serde helper ─────────────────────────────────────────────────────────────
336
337mod serde_duration_secs {
338    use serde::{Deserialize, Deserializer, Serialize, Serializer};
339    use std::time::Duration;
340
341    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
342        d.as_secs().serialize(s)
343    }
344
345    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
346        Ok(Duration::from_secs(u64::deserialize(d)?))
347    }
348}
349
350// ── tests ────────────────────────────────────────────────────────────────────
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn same_domain_returns_same_proxy() {
359        let map = SessionMap::new();
360        let id = Uuid::new_v4();
361        map.bind("example.com", id, Duration::from_mins(1));
362        assert_eq!(map.lookup("example.com"), Some(id));
363        assert_eq!(map.lookup("example.com"), Some(id));
364    }
365
366    #[test]
367    fn different_domains_independent() {
368        let map = SessionMap::new();
369        let id_a = Uuid::new_v4();
370        let id_b = Uuid::new_v4();
371        map.bind("a.com", id_a, Duration::from_mins(1));
372        map.bind("b.com", id_b, Duration::from_mins(1));
373        assert_eq!(map.lookup("a.com"), Some(id_a));
374        assert_eq!(map.lookup("b.com"), Some(id_b));
375    }
376
377    #[test]
378    fn expired_session_returns_none() {
379        let map = SessionMap::new();
380        let id = Uuid::new_v4();
381        // TTL of 0 means it expires immediately.
382        map.bind("example.com", id, Duration::ZERO);
383        // Spin-wait a tiny bit to ensure the instant has elapsed.
384        std::thread::sleep(Duration::from_millis(1));
385        assert_eq!(map.lookup("example.com"), None);
386    }
387
388    #[test]
389    fn purge_removes_expired() {
390        let map = SessionMap::new();
391        map.bind("expired.com", Uuid::new_v4(), Duration::ZERO);
392        map.bind("active.com", Uuid::new_v4(), Duration::from_mins(5));
393        std::thread::sleep(Duration::from_millis(1));
394
395        let removed = map.purge_expired();
396        assert_eq!(removed, 1);
397        assert_eq!(map.active_count(), 1);
398    }
399
400    #[test]
401    fn unbind_removes_session() {
402        let map = SessionMap::new();
403        map.bind("example.com", Uuid::new_v4(), Duration::from_mins(1));
404        map.unbind("example.com");
405        assert_eq!(map.lookup("example.com"), None);
406    }
407
408    #[test]
409    fn rebind_overwrites_previous() {
410        let map = SessionMap::new();
411        let old_id = Uuid::new_v4();
412        let new_id = Uuid::new_v4();
413        map.bind("example.com", old_id, Duration::from_mins(1));
414        map.bind("example.com", new_id, Duration::from_mins(1));
415        assert_eq!(map.lookup("example.com"), Some(new_id));
416    }
417
418    #[test]
419    fn policy_domain_default_ttl() {
420        let policy = StickyPolicy::domain_default();
421        assert!(matches!(policy, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(5)));
422    }
423
424    #[test]
425    fn policy_disabled_by_default() {
426        let policy = StickyPolicy::default();
427        assert!(policy.is_disabled());
428    }
429
430    #[test]
431    fn policy_serde_roundtrip() -> std::result::Result<(), Box<dyn std::error::Error>> {
432        let policy = StickyPolicy::domain(Duration::from_mins(2));
433        let json = serde_json::to_string(&policy)?;
434        let back: StickyPolicy = serde_json::from_str(&json)?;
435        assert!(matches!(back, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(2)));
436        Ok(())
437    }
438
439    // ── T99: per-vendor acquire_session ─────────────────────────────────────
440
441    use crate::stickiness::{StickinessPolicy, VendorStickinessMap};
442
443    fn vendor_policy_map() -> VendorStickinessMap {
444        VendorStickinessMap::with_builtin_defaults()
445    }
446
447    #[test]
448    fn acquire_session_akamai_no_binding_returns_acquire_and_bind_30min() {
449        let map = SessionMap::new();
450        let policy = vendor_policy_map();
451
452        let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
453        assert_eq!(
454            decision,
455            SessionDecision::AcquireAndBind(Duration::from_mins(30))
456        );
457    }
458
459    #[test]
460    fn acquire_session_akamai_with_existing_binding_returns_sticky() {
461        let map = SessionMap::new();
462        let policy = vendor_policy_map();
463        let proxy_id = Uuid::new_v4();
464
465        // Simulate the manager having bound the proxy on a prior call.
466        map.bind("example.com", proxy_id, Duration::from_mins(30));
467
468        let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
469        assert_eq!(decision, SessionDecision::UseSticky(proxy_id));
470    }
471
472    #[test]
473    fn acquire_session_akamai_100_calls_within_ttl_return_same_proxy() {
474        let map = SessionMap::new();
475        let policy = vendor_policy_map();
476        let proxy_id = Uuid::new_v4();
477        map.bind("example.com", proxy_id, Duration::from_mins(30));
478
479        for _ in 0..100 {
480            assert_eq!(
481                map.acquire_session("example.com", VendorId::Akamai, &policy),
482                SessionDecision::UseSticky(proxy_id)
483            );
484        }
485    }
486
487    #[test]
488    fn acquire_session_akamai_expired_binding_returns_acquire_and_bind() {
489        let map = SessionMap::new();
490        let policy = vendor_policy_map();
491        let stale_id = Uuid::new_v4();
492
493        // TTL of 0 expires immediately.
494        map.bind("example.com", stale_id, Duration::ZERO);
495        std::thread::sleep(Duration::from_millis(1));
496
497        let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
498        assert_eq!(
499            decision,
500            SessionDecision::AcquireAndBind(Duration::from_mins(30))
501        );
502    }
503
504    #[test]
505    fn acquire_session_cloudflare_no_binding_returns_acquire_and_bind_5min() {
506        let map = SessionMap::new();
507        let policy = vendor_policy_map();
508
509        let decision = map.acquire_session("example.com", VendorId::Cloudflare, &policy);
510        assert_eq!(
511            decision,
512            SessionDecision::AcquireAndBind(Duration::from_mins(5))
513        );
514    }
515
516    #[test]
517    fn acquire_session_imperva_no_binding_returns_acquire_and_bind_15min() {
518        let map = SessionMap::new();
519        let policy = vendor_policy_map();
520
521        let decision = map.acquire_session("example.com", VendorId::Imperva, &policy);
522        assert_eq!(
523            decision,
524            SessionDecision::AcquireAndBind(Duration::from_mins(15))
525        );
526    }
527
528    #[test]
529    fn acquire_session_data_dome_always_returns_acquire_fresh() {
530        let map = SessionMap::new();
531        let policy = vendor_policy_map();
532
533        // Even with a binding in place, DataDome says "fresh per request".
534        map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
535        let decision = map.acquire_session("example.com", VendorId::DataDome, &policy);
536        assert_eq!(decision, SessionDecision::AcquireFresh);
537    }
538
539    #[test]
540    fn acquire_session_perimeter_x_evicts_existing_binding() {
541        let map = SessionMap::new();
542        let policy = vendor_policy_map();
543        map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
544
545        let decision = map.acquire_session("example.com", VendorId::PerimeterX, &policy);
546        assert_eq!(decision, SessionDecision::AcquireFresh);
547        // FreshPerDomain must evict the prior binding so the next call
548        // also acquires fresh.
549        assert_eq!(map.lookup("example.com"), None);
550    }
551
552    #[test]
553    fn acquire_session_perimeter_x_no_existing_binding_returns_fresh() {
554        let map = SessionMap::new();
555        let policy = vendor_policy_map();
556        let decision = map.acquire_session("example.com", VendorId::PerimeterX, &policy);
557        assert_eq!(decision, SessionDecision::AcquireFresh);
558    }
559
560    #[test]
561    fn acquire_session_kasada_evicts_existing_binding() {
562        let map = SessionMap::new();
563        let policy = vendor_policy_map();
564        map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
565
566        let decision = map.acquire_session("example.com", VendorId::Kasada, &policy);
567        assert_eq!(decision, SessionDecision::AcquireFresh);
568        assert_eq!(map.lookup("example.com"), None);
569    }
570
571    #[test]
572    fn acquire_session_unknown_vendor_defaults_to_fresh() {
573        let map = SessionMap::new();
574        let policy = vendor_policy_map();
575        map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
576
577        // Unknown vendors inherit the safest default (FreshPerRequest),
578        // which leaves any binding alone but asks the caller to acquire
579        // fresh — different from FreshPerDomain, which would evict the
580        // binding.
581        let decision = map.acquire_session("example.com", VendorId::Unknown, &policy);
582        assert_eq!(decision, SessionDecision::AcquireFresh);
583        // FreshPerRequest does not evict prior bindings.
584        assert!(map.lookup("example.com").is_some());
585    }
586
587    #[test]
588    fn acquire_session_sticky_forever_uses_max_duration() {
589        let map = SessionMap::new();
590        let custom = VendorStickinessMap::new()
591            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
592
593        let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
594        assert_eq!(decision, SessionDecision::AcquireAndBind(Duration::MAX));
595    }
596
597    #[test]
598    fn acquire_session_sticky_for_request_count_treated_as_fresh() {
599        let map = SessionMap::new();
600        let custom = VendorStickinessMap::new().with_override(
601            VendorId::Akamai,
602            StickinessPolicy::StickyForRequestCount { max_requests: 5 },
603        );
604
605        let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
606        assert_eq!(decision, SessionDecision::AcquireFresh);
607    }
608
609    #[test]
610    fn acquire_session_sticky_forever_uses_existing_binding() {
611        let map = SessionMap::new();
612        let custom = VendorStickinessMap::new()
613            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
614        let proxy_id = Uuid::new_v4();
615        map.bind("example.com", proxy_id, Duration::from_hours(1));
616
617        let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
618        assert_eq!(decision, SessionDecision::UseSticky(proxy_id));
619    }
620
621    #[test]
622    fn acquire_session_override_replaces_builtin_akamai_policy() {
623        let map = SessionMap::new();
624        let policy = vendor_policy_map().with_override(
625            VendorId::Akamai,
626            StickinessPolicy::StickyForTtl {
627                ttl: Duration::from_mins(2),
628            },
629        );
630
631        let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
632        assert_eq!(
633            decision,
634            SessionDecision::AcquireAndBind(Duration::from_mins(2))
635        );
636    }
637
638    #[test]
639    fn acquire_session_empty_map_defaults_all_to_fresh() {
640        // A `VendorStickinessMap::new()` (no built-ins) is the operator's
641        // way of saying "fresh for everything". This must be safe even
642        // when no entries are present.
643        let map = SessionMap::new();
644        let empty = VendorStickinessMap::new();
645        map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
646
647        for vendor in [
648            VendorId::Akamai,
649            VendorId::Cloudflare,
650            VendorId::DataDome,
651            VendorId::PerimeterX,
652            VendorId::Kasada,
653            VendorId::Imperva,
654            VendorId::Unknown,
655            VendorId::Hcaptcha,
656        ] {
657            assert_eq!(
658                map.acquire_session("example.com", vendor, &empty),
659                SessionDecision::AcquireFresh,
660                "{vendor:?} should default to fresh when no entry exists"
661            );
662        }
663    }
664}