Skip to main content

stygian_proxy/
stickiness.rs

1//! Per-vendor session stickiness policy.
2//!
3//! The 2026 scraping guide (see
4//! `docs/dev/project/scraping-guide-2026-llm-context.md` §"PROXY PROVIDERS
5//! AND TYPES") describes an anti-bot-specific stickiness matrix:
6//!
7//! | Vendor          | Recommended stickiness                                            |
8//! | --------------- | ----------------------------------------------------------------- |
9//! | `Akamai`        | Static / ISP IP, sticky for the lifetime of a long session.       |
10//! | `PerimeterX`    | Fresh session per domain (Camoufox + residential flow).          |
11//! | `DataDome`      | No stickiness — fresh proxy per request; mobile carrier wins.    |
12//! | `Kasada`        | Fresh session per domain.                                         |
13//! | `Cloudflare`    | Short (≈5 min) sticky window — `cf_clearance` re-issue cadence.  |
14//! | `Imperva`       | Medium (≈15 min) sticky window.                                   |
15//! | everything else | Fresh proxy per request (safest default for unknown vendors).     |
16//!
17//! [`VendorStickinessMap`] encodes that matrix as a typed
18//! `BTreeMap<VendorId, StickinessPolicy>` so the [`SessionMap`](crate::session::SessionMap)
19//! and [`ProxyManager`](crate::manager::ProxyManager) can pick the
20//! correct sticky slot automatically when a session is requested for
21//! `(domain, vendor)`.
22//!
23//! ## Feature flag
24//!
25//! The data types in this module are always compiled (so external
26//! adapters can build a [`VendorStickinessMap`] without enabling the
27//! feature). The wiring into
28//! [`ProxyManager::acquire_for_domain_with_vendor`](crate::manager::ProxyManager::acquire_for_domain_with_vendor)
29//! is gated behind the `vendor-stickiness` cargo feature (off by
30//! default; wired into the `full` aggregator).
31//!
32//! ## Example
33//!
34//! ```rust
35//! use std::time::Duration;
36//! use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
37//! use stygian_proxy::types::VendorId;
38//!
39//! // Built-in defaults from the 2026 guide.
40//! let defaults = VendorStickinessMap::with_builtin_defaults();
41//! assert_eq!(
42//!     defaults.for_vendor(VendorId::Akamai),
43//!     StickinessPolicy::StickyForTtl { ttl: Duration::from_mins(30) }
44//! );
45//! assert_eq!(
46//!     defaults.for_vendor(VendorId::DataDome),
47//!     StickinessPolicy::FreshPerRequest
48//! );
49//! assert_eq!(
50//!     defaults.for_vendor(VendorId::Unknown),
51//!     StickinessPolicy::FreshPerRequest
52//!
53//! ); // unknown vendors fall back to the safest default
54//!
55//! // Operators can override individual entries before applying built-ins.
56//! let custom = VendorStickinessMap::with_builtin_defaults()
57//!     .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
58//! assert_eq!(custom.for_vendor(VendorId::Akamai), StickinessPolicy::StickyForever);
59//! ```
60
61use std::collections::BTreeMap;
62use std::time::Duration;
63
64use serde::{Deserialize, Serialize};
65
66use crate::types::VendorId;
67
68/// Built-in TTL for `Akamai` sticky sessions (2026 guide L2734).
69const AKAMAI_STICKY_TTL: Duration = Duration::from_mins(30);
70/// Built-in TTL for `Cloudflare` sticky sessions (5 min — `cf_clearance`
71/// re-issue cadence per the 2026 guide).
72const CLOUDFLARE_STICKY_TTL: Duration = Duration::from_mins(5);
73/// Built-in TTL for `Imperva` sticky sessions.
74const IMPERVA_STICKY_TTL: Duration = Duration::from_mins(15);
75
76/// Session stickiness policy keyed by anti-bot [`VendorId`].
77///
78/// Different vendors reward different proxy-rotation cadences:
79///
80/// - `Akamai` accumulates trust on a consistent ISP IP, so the session
81///   should be **sticky** for the whole scrape lifetime.
82/// - `Cloudflare` re-issues `cf_clearance` cookies on a short cadence,
83///   so a 5 min sticky window matches the re-issue cadence and avoids
84///   burning the cookie on a single use.
85/// - `PerimeterX` / `Kasada` flag frequent proxy changes on the same
86///   domain, so each domain request should pick a **fresh** proxy.
87/// - `DataDome` doesn't care about stickiness — it scores mobile
88///   carriers above all else — so the safe default is **fresh per
89///   request**.
90/// - Everything else falls back to `FreshPerRequest` so an unknown
91///   vendor can never inherit a permissive sticky policy by accident.
92///
93/// `Copy + Eq + Hash + Display + Debug` for ergonomic logging and use as
94/// a value type.
95///
96/// # Example
97/// ```
98/// use std::time::Duration;
99/// use stygian_proxy::stickiness::StickinessPolicy;
100/// assert_eq!(
101///     StickinessPolicy::StickyForTtl { ttl: Duration::from_mins(30) },
102///     StickinessPolicy::StickyForTtl { ttl: Duration::from_mins(30) }
103/// );
104/// assert_eq!(format!("{}", StickinessPolicy::FreshPerRequest), "fresh_per_request");
105/// ```
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case", tag = "mode")]
108pub enum StickinessPolicy {
109    /// Pin the same proxy for the entire process lifetime (or until the
110    /// bound proxy fails its circuit breaker).
111    StickyForever,
112    /// Pin the same proxy for a fixed TTL; pick fresh after expiry.
113    StickyForTtl {
114        /// How long the domain→proxy binding remains valid.
115        #[serde(with = "serde_duration_secs")]
116        ttl: Duration,
117    },
118    /// Pin the same proxy for at most `max_requests` requests; reset on
119    /// each request. Not currently enforced at the
120    /// [`SessionMap`](crate::session::SessionMap) layer — the policy is
121    /// treated as `FreshPerRequest` when consulted by
122    /// [`acquire_session`](crate::session::SessionMap::acquire_session).
123    /// Kept in the enum for future per-request counters.
124    StickyForRequestCount {
125        /// Maximum number of requests per binding.
126        max_requests: u32,
127    },
128    /// Pick a fresh proxy for every request. No domain→proxy binding is
129    /// created or retained.
130    FreshPerRequest,
131    /// Pick a fresh proxy per domain request, evicting any prior binding.
132    ///
133    /// The "per domain" qualifier means a request to a **different**
134    /// domain for the same vendor reuses its own binding; a request to
135    /// the same domain forces fresh.
136    FreshPerDomain,
137}
138
139impl std::fmt::Display for StickinessPolicy {
140    /// Stable, lower-case wire label for log output.
141    ///
142    /// # Example
143    /// ```
144    /// use std::time::Duration;
145    /// use stygian_proxy::stickiness::StickinessPolicy;
146    /// assert_eq!(format!("{}", StickinessPolicy::FreshPerRequest), "fresh_per_request");
147    /// assert_eq!(format!("{}", StickinessPolicy::StickyForever), "sticky_forever");
148    /// assert_eq!(
149    ///     format!("{}", StickinessPolicy::StickyForTtl { ttl: Duration::from_secs(60) }),
150    ///     "sticky_for_ttl(60s)"
151    /// );
152    /// ```
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::StickyForever => f.write_str("sticky_forever"),
156            Self::StickyForTtl { ttl } => write!(f, "sticky_for_ttl({}s)", ttl.as_secs()),
157            Self::StickyForRequestCount { max_requests } => {
158                write!(f, "sticky_for_request_count({max_requests})")
159            }
160            Self::FreshPerDomain => f.write_str("fresh_per_domain"),
161            Self::FreshPerRequest => f.write_str("fresh_per_request"),
162        }
163    }
164}
165
166/// `BTreeMap`-backed stickiness policy keyed by anti-bot [`VendorId`].
167///
168/// Use [`with_builtin_defaults`](Self::with_builtin_defaults) to seed the
169/// map with the 2026 guide defaults, then chain
170/// [`with_override`](Self::with_override) to customise individual vendors
171/// before installing the result on a
172/// [`ProxyManager`](crate::manager::ProxyManager).
173///
174/// # Example
175/// ```
176/// use std::time::Duration;
177/// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
178/// use stygian_proxy::types::VendorId;
179///
180/// let map = VendorStickinessMap::with_builtin_defaults();
181/// assert_eq!(
182///     map.for_vendor(VendorId::Akamai),
183///     StickinessPolicy::StickyForTtl { ttl: Duration::from_mins(30) }
184/// );
185/// ```
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(transparent)]
188pub struct VendorStickinessMap(BTreeMap<VendorId, StickinessPolicy>);
189
190impl VendorStickinessMap {
191    /// Empty map — every vendor falls back to
192    /// [`StickinessPolicy::FreshPerRequest`] at lookup time.
193    ///
194    /// # Example
195    /// ```
196    /// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
197    /// use stygian_proxy::types::VendorId;
198    /// let map = VendorStickinessMap::new();
199    /// assert!(map.is_empty());
200    /// assert_eq!(map.for_vendor(VendorId::Akamai), StickinessPolicy::FreshPerRequest);
201    /// ```
202    #[must_use]
203    pub const fn new() -> Self {
204        Self(BTreeMap::new())
205    }
206
207    /// Built-in defaults from the 2026 scraping guide:
208    ///
209    /// - `Akamai` → [`StickyForTtl`](StickinessPolicy::StickyForTtl) 30 min
210    /// - `Cloudflare` → [`StickyForTtl`](StickinessPolicy::StickyForTtl) 5 min
211    /// - `Imperva` → [`StickyForTtl`](StickinessPolicy::StickyForTtl) 15 min
212    /// - `PerimeterX` → [`FreshPerDomain`](StickinessPolicy::FreshPerDomain)
213    /// - `Kasada` → [`FreshPerDomain`](StickinessPolicy::FreshPerDomain)
214    /// - `DataDome` → [`FreshPerRequest`](StickinessPolicy::FreshPerRequest)
215    /// - everything else → [`FreshPerRequest`](StickinessPolicy::FreshPerRequest)
216    ///   (safest default for unknown vendors)
217    ///
218    /// # Example
219    /// ```
220    /// use std::time::Duration;
221    /// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
222    /// use stygian_proxy::types::VendorId;
223    ///
224    /// let map = VendorStickinessMap::with_builtin_defaults();
225    /// assert_eq!(
226    ///     map.for_vendor(VendorId::Akamai),
227    ///     StickinessPolicy::StickyForTtl { ttl: Duration::from_mins(30) }
228    /// );
229    /// assert_eq!(map.for_vendor(VendorId::DataDome), StickinessPolicy::FreshPerRequest);
230    /// assert_eq!(
231    ///     map.for_vendor(VendorId::PerimeterX),
232    ///     StickinessPolicy::FreshPerDomain
233    /// );
234    /// ```
235    #[must_use]
236    pub fn with_builtin_defaults() -> Self {
237        let mut entries = BTreeMap::new();
238        entries.insert(
239            VendorId::Akamai,
240            StickinessPolicy::StickyForTtl {
241                ttl: AKAMAI_STICKY_TTL,
242            },
243        );
244        entries.insert(
245            VendorId::Cloudflare,
246            StickinessPolicy::StickyForTtl {
247                ttl: CLOUDFLARE_STICKY_TTL,
248            },
249        );
250        entries.insert(VendorId::DataDome, StickinessPolicy::FreshPerRequest);
251        entries.insert(
252            VendorId::Imperva,
253            StickinessPolicy::StickyForTtl {
254                ttl: IMPERVA_STICKY_TTL,
255            },
256        );
257        entries.insert(VendorId::Kasada, StickinessPolicy::FreshPerDomain);
258        entries.insert(VendorId::PerimeterX, StickinessPolicy::FreshPerDomain);
259        Self(entries)
260    }
261
262    /// Look up the policy for `vendor`.
263    ///
264    /// Unknown vendors (including [`VendorId::Unknown`]) fall back to
265    /// [`StickinessPolicy::FreshPerRequest`] — the safest default.
266    ///
267    /// # Example
268    /// ```
269    /// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
270    /// use stygian_proxy::types::VendorId;
271    /// let map = VendorStickinessMap::with_builtin_defaults();
272    /// assert_eq!(map.for_vendor(VendorId::DataDome), StickinessPolicy::FreshPerRequest);
273    /// assert_eq!(map.for_vendor(VendorId::Unknown), StickinessPolicy::FreshPerRequest);
274    /// ```
275    #[must_use]
276    pub fn for_vendor(&self, vendor: VendorId) -> StickinessPolicy {
277        self.0
278            .get(&vendor)
279            .copied()
280            .unwrap_or(StickinessPolicy::FreshPerRequest)
281    }
282
283    /// Insert or replace the policy for `vendor`. Builder-style: takes
284    /// `self` by value and returns the updated map so calls can be
285    /// chained.
286    ///
287    /// # Example
288    /// ```
289    /// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
290    /// use stygian_proxy::types::VendorId;
291    ///
292    /// let map = VendorStickinessMap::with_builtin_defaults()
293    ///     .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
294    /// assert_eq!(map.for_vendor(VendorId::Akamai), StickinessPolicy::StickyForever);
295    /// ```
296    #[must_use]
297    pub fn with_override(mut self, vendor: VendorId, policy: StickinessPolicy) -> Self {
298        self.0.insert(vendor, policy);
299        self
300    }
301
302    /// Returns `true` when no vendor policies have been registered.
303    ///
304    /// # Example
305    /// ```
306    /// use stygian_proxy::stickiness::VendorStickinessMap;
307    /// assert!(VendorStickinessMap::new().is_empty());
308    /// assert!(!VendorStickinessMap::with_builtin_defaults().is_empty());
309    /// ```
310    #[must_use]
311    pub fn is_empty(&self) -> bool {
312        self.0.is_empty()
313    }
314
315    /// Returns the number of registered vendor policies.
316    ///
317    /// # Example
318    /// ```
319    /// use stygian_proxy::stickiness::VendorStickinessMap;
320    /// use stygian_proxy::types::VendorId;
321    /// let map = VendorStickinessMap::with_builtin_defaults();
322    /// assert_eq!(map.len(), 6);
323    /// let map = VendorStickinessMap::new()
324    ///     .with_override(VendorId::Akamai, stygian_proxy::stickiness::StickinessPolicy::StickyForever);
325    /// assert_eq!(map.len(), 1);
326    /// ```
327    #[must_use]
328    pub fn len(&self) -> usize {
329        self.0.len()
330    }
331
332    /// Iterate `(vendor, policy)` pairs in deterministic (sorted) order.
333    ///
334    /// # Example
335    /// ```
336    /// use stygian_proxy::stickiness::{StickinessPolicy, VendorStickinessMap};
337    /// use stygian_proxy::types::VendorId;
338    ///
339    /// let map = VendorStickinessMap::with_builtin_defaults();
340    /// let entries: Vec<_> = map.iter().collect();
341    /// // Sorted by VendorId discriminant order — `Akamai` < `Cloudflare`.
342    /// assert_eq!(entries.first().map(|(v, _)| *v), Some(VendorId::Akamai));
343    /// ```
344    pub fn iter(&self) -> impl Iterator<Item = (VendorId, StickinessPolicy)> + '_ {
345        self.0.iter().map(|(v, p)| (*v, *p))
346    }
347}
348
349mod serde_duration_secs {
350    use serde::{Deserialize, Deserializer, Serialize, Serializer};
351    use std::time::Duration;
352
353    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
354        d.as_secs().serialize(s)
355    }
356
357    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
358        Ok(Duration::from_secs(u64::deserialize(d)?))
359    }
360}
361
362// ─────────────────────────────────────────────────────────────────────────────
363// Tests
364// ─────────────────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367#[allow(
368    clippy::unwrap_used,
369    clippy::expect_used,
370    clippy::panic,
371    clippy::indexing_slicing
372)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn new_is_empty() {
378        let map = VendorStickinessMap::new();
379        assert!(map.is_empty());
380        assert_eq!(map.len(), 0);
381    }
382
383    #[test]
384    fn default_is_empty() {
385        let map = VendorStickinessMap::default();
386        assert!(map.is_empty());
387    }
388
389    #[test]
390    fn for_vendor_unknown_returns_fresh_per_request() {
391        let map = VendorStickinessMap::new();
392        assert_eq!(
393            map.for_vendor(VendorId::Unknown),
394            StickinessPolicy::FreshPerRequest
395        );
396        assert_eq!(
397            map.for_vendor(VendorId::Akamai),
398            StickinessPolicy::FreshPerRequest
399        );
400    }
401
402    #[test]
403    fn with_override_inserts_entry() {
404        let map = VendorStickinessMap::new()
405            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
406        assert_eq!(map.len(), 1);
407        assert_eq!(
408            map.for_vendor(VendorId::Akamai),
409            StickinessPolicy::StickyForever
410        );
411    }
412
413    #[test]
414    fn with_override_replaces_existing_entry() {
415        let map = VendorStickinessMap::new()
416            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever)
417            .with_override(VendorId::Akamai, StickinessPolicy::FreshPerDomain);
418        assert_eq!(map.len(), 1);
419        assert_eq!(
420            map.for_vendor(VendorId::Akamai),
421            StickinessPolicy::FreshPerDomain
422        );
423    }
424
425    #[test]
426    fn built_in_defaults_akamai_is_30min_sticky() {
427        let map = VendorStickinessMap::with_builtin_defaults();
428        assert_eq!(
429            map.for_vendor(VendorId::Akamai),
430            StickinessPolicy::StickyForTtl {
431                ttl: Duration::from_mins(30)
432            }
433        );
434    }
435
436    #[test]
437    fn built_in_defaults_cloudflare_is_5min_sticky() {
438        let map = VendorStickinessMap::with_builtin_defaults();
439        assert_eq!(
440            map.for_vendor(VendorId::Cloudflare),
441            StickinessPolicy::StickyForTtl {
442                ttl: Duration::from_mins(5)
443            }
444        );
445    }
446
447    #[test]
448    fn built_in_defaults_imperva_is_15min_sticky() {
449        let map = VendorStickinessMap::with_builtin_defaults();
450        assert_eq!(
451            map.for_vendor(VendorId::Imperva),
452            StickinessPolicy::StickyForTtl {
453                ttl: Duration::from_mins(15)
454            }
455        );
456    }
457
458    #[test]
459    fn built_in_defaults_perimeter_x_is_fresh_per_domain() {
460        let map = VendorStickinessMap::with_builtin_defaults();
461        assert_eq!(
462            map.for_vendor(VendorId::PerimeterX),
463            StickinessPolicy::FreshPerDomain
464        );
465    }
466
467    #[test]
468    fn built_in_defaults_kasada_is_fresh_per_domain() {
469        let map = VendorStickinessMap::with_builtin_defaults();
470        assert_eq!(
471            map.for_vendor(VendorId::Kasada),
472            StickinessPolicy::FreshPerDomain
473        );
474    }
475
476    #[test]
477    fn built_in_defaults_data_dome_is_fresh_per_request() {
478        let map = VendorStickinessMap::with_builtin_defaults();
479        assert_eq!(
480            map.for_vendor(VendorId::DataDome),
481            StickinessPolicy::FreshPerRequest
482        );
483    }
484
485    #[test]
486    fn built_in_defaults_unknown_vendor_falls_back_to_fresh_per_request() {
487        let map = VendorStickinessMap::with_builtin_defaults();
488        assert_eq!(
489            map.for_vendor(VendorId::Unknown),
490            StickinessPolicy::FreshPerRequest
491        );
492        assert_eq!(
493            map.for_vendor(VendorId::Hcaptcha),
494            StickinessPolicy::FreshPerRequest
495        );
496        assert_eq!(
497            map.for_vendor(VendorId::ShapeSecurity),
498            StickinessPolicy::FreshPerRequest
499        );
500    }
501
502    #[test]
503    fn built_in_defaults_has_six_entries() {
504        let map = VendorStickinessMap::with_builtin_defaults();
505        assert_eq!(map.len(), 6);
506    }
507
508    #[test]
509    fn built_in_defaults_iterates_in_sorted_order() {
510        let map = VendorStickinessMap::with_builtin_defaults();
511        let entries: Vec<_> = map.iter().map(|(v, _)| v).collect();
512        // BTreeMap orders by VendorId discriminant — PerimeterX (3)
513        // precedes Kasada (6) precedes Imperva (9).
514        assert_eq!(
515            entries,
516            vec![
517                VendorId::Akamai,
518                VendorId::Cloudflare,
519                VendorId::DataDome,
520                VendorId::PerimeterX,
521                VendorId::Kasada,
522                VendorId::Imperva,
523            ]
524        );
525    }
526
527    #[test]
528    fn override_chained_before_builtins_replaces_entry() {
529        // Per the spec, operators chain `with_override` BEFORE
530        // `with_builtin_defaults` so the override takes precedence.
531        // We allow chaining in either order via the builder-style API;
532        // here we verify the documented usage shape.
533        let map = VendorStickinessMap::with_builtin_defaults()
534            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
535        assert_eq!(
536            map.for_vendor(VendorId::Akamai),
537            StickinessPolicy::StickyForever
538        );
539        // Other entries remain at their built-in defaults.
540        assert_eq!(
541            map.for_vendor(VendorId::Cloudflare),
542            StickinessPolicy::StickyForTtl {
543                ttl: Duration::from_mins(5)
544            }
545        );
546    }
547
548    #[test]
549    fn stickiness_policy_is_copy() {
550        let policy = StickinessPolicy::StickyForTtl {
551            ttl: Duration::from_mins(30),
552        };
553        let copy = policy;
554        assert_eq!(policy, copy);
555    }
556
557    #[test]
558    fn stickiness_policy_is_hash_eq() {
559        use std::collections::HashSet;
560        let mut set = HashSet::new();
561        set.insert(StickinessPolicy::StickyForever);
562        set.insert(StickinessPolicy::StickyForTtl {
563            ttl: Duration::from_mins(30),
564        });
565        set.insert(StickinessPolicy::FreshPerRequest);
566        assert_eq!(set.len(), 3);
567        assert!(set.contains(&StickinessPolicy::StickyForever));
568    }
569
570    #[test]
571    fn stickiness_policy_display_matches_snake_case_label() {
572        assert_eq!(
573            format!("{}", StickinessPolicy::StickyForever),
574            "sticky_forever"
575        );
576        assert_eq!(
577            format!(
578                "{}",
579                StickinessPolicy::StickyForTtl {
580                    ttl: Duration::from_mins(1)
581                }
582            ),
583            "sticky_for_ttl(60s)"
584        );
585        assert_eq!(
586            format!(
587                "{}",
588                StickinessPolicy::StickyForRequestCount { max_requests: 5 }
589            ),
590            "sticky_for_request_count(5)"
591        );
592        assert_eq!(
593            format!("{}", StickinessPolicy::FreshPerDomain),
594            "fresh_per_domain"
595        );
596        assert_eq!(
597            format!("{}", StickinessPolicy::FreshPerRequest),
598            "fresh_per_request"
599        );
600    }
601
602    #[test]
603    fn stickiness_policy_round_trips_through_json() {
604        let policies = [
605            StickinessPolicy::StickyForever,
606            StickinessPolicy::StickyForTtl {
607                ttl: Duration::from_mins(30),
608            },
609            StickinessPolicy::StickyForRequestCount { max_requests: 7 },
610            StickinessPolicy::FreshPerDomain,
611            StickinessPolicy::FreshPerRequest,
612        ];
613        for policy in policies {
614            let json = serde_json::to_string(&policy).expect("serialize");
615            let parsed: StickinessPolicy = serde_json::from_str(&json).expect("deserialize");
616            assert_eq!(parsed, policy, "round-trip for {policy:?}");
617        }
618    }
619
620    #[test]
621    fn stickiness_policy_round_trips_through_toml() {
622        let policies = [
623            StickinessPolicy::StickyForever,
624            StickinessPolicy::StickyForTtl {
625                ttl: Duration::from_mins(30),
626            },
627            StickinessPolicy::StickyForRequestCount { max_requests: 7 },
628            StickinessPolicy::FreshPerDomain,
629            StickinessPolicy::FreshPerRequest,
630        ];
631        for policy in policies {
632            let toml_str = toml::to_string(&policy).expect("serialize toml");
633            let parsed: StickinessPolicy = toml::from_str(&toml_str).expect("deserialize toml");
634            assert_eq!(parsed, policy, "round-trip for {policy:?}");
635        }
636    }
637
638    #[test]
639    fn vendor_stickiness_map_round_trips_through_json() {
640        let map = VendorStickinessMap::with_builtin_defaults()
641            .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
642        let json = serde_json::to_string(&map).expect("serialize");
643        let parsed: VendorStickinessMap = serde_json::from_str(&json).expect("deserialize");
644        assert_eq!(parsed, map);
645    }
646
647    #[test]
648    fn vendor_stickiness_map_round_trips_through_toml() {
649        let map = VendorStickinessMap::with_builtin_defaults()
650            .with_override(VendorId::DataDome, StickinessPolicy::StickyForever);
651        let toml_str = toml::to_string(&map).expect("serialize toml");
652        let parsed: VendorStickinessMap = toml::from_str(&toml_str).expect("deserialize toml");
653        assert_eq!(parsed, map);
654    }
655
656    #[test]
657    fn vendor_stickiness_map_transparent_serde_orders_by_vendor_id() {
658        // `#[serde(transparent)]` on `VendorStickinessMap` means the wire
659        // form is the `BTreeMap` directly. Verify that the BTreeMap
660        // ordering on the wire matches the sorted `VendorId`
661        // discriminant.
662        let map = VendorStickinessMap::with_builtin_defaults();
663        let json = serde_json::to_string(&map).expect("serialize");
664        let akamai_pos = json.find("\"akamai\"").expect("akamai present");
665        let cloudflare_pos = json.find("\"cloudflare\"").expect("cloudflare present");
666        assert!(
667            akamai_pos < cloudflare_pos,
668            "expected sorted order on wire: {json}"
669        );
670    }
671
672    // ── integration with `StickyForRequestCount` (treated as fresh) ─────────
673
674    #[test]
675    fn stickiness_policy_for_request_count_variant_exists() {
676        // Documented fallback: SessionMap treats `StickyForRequestCount`
677        // as fresh. The variant must still round-trip cleanly through
678        // serde so operators can persist policies that include it.
679        let policy = StickinessPolicy::StickyForRequestCount { max_requests: 5 };
680        let json = serde_json::to_string(&policy).expect("serialize");
681        let parsed: StickinessPolicy = serde_json::from_str(&json).expect("deserialize");
682        assert_eq!(parsed, policy);
683    }
684}