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}