Skip to main content

stygian_graph/ports/
escalation.rs

1//! Tiered request escalation port.
2//!
3//! Defines the [`EscalationPolicy`](crate::ports::escalation::EscalationPolicy) trait for deciding when and how to
4//! escalate a failed request from a lightweight tier (plain HTTP) to a
5//! heavier one (TLS-profiled HTTP, basic browser, advanced browser).
6//!
7//! This is a pure domain concept — no I/O, no adapter imports. Concrete
8//! policies are implemented in adapter modules (see T19).
9//!
10//! # Tiers
11//!
12//! | Tier | Description |
13//! |---|---|
14//! | [`HttpPlain`](crate::ports::escalation::EscalationTier::HttpPlain) | Standard HTTP client, no stealth |
15//! | [`HttpTlsProfiled`](crate::ports::escalation::EscalationTier::HttpTlsProfiled) | HTTP with TLS fingerprint matching |
16//! | [`BrowserBasic`](crate::ports::escalation::EscalationTier::BrowserBasic) | Headless browser with basic stealth |
17//! | [`BrowserAdvanced`](crate::ports::escalation::EscalationTier::BrowserAdvanced) | Full stealth browser (CDP fixes, JS patches) |
18//!
19//! # Example
20//!
21//! ```
22//! use stygian_graph::ports::escalation::{
23//!     EscalationPolicy, EscalationTier, ResponseContext,
24//! };
25//!
26//! struct AlwaysEscalate;
27//!
28//! impl EscalationPolicy for AlwaysEscalate {
29//!     fn initial_tier(&self) -> EscalationTier {
30//!         EscalationTier::HttpPlain
31//!     }
32//!
33//!     fn should_escalate(
34//!         &self,
35//!         ctx: &ResponseContext,
36//!         current: EscalationTier,
37//!     ) -> Option<EscalationTier> {
38//!         current.next()
39//!     }
40//!
41//!     fn max_tier(&self) -> EscalationTier {
42//!         EscalationTier::BrowserAdvanced
43//!     }
44//! }
45//!
46//! let policy = AlwaysEscalate;
47//! assert_eq!(policy.initial_tier(), EscalationTier::HttpPlain);
48//! ```
49
50use serde::{Deserialize, Serialize};
51
52// ── EscalationTier ───────────────────────────────────────────────────────────
53
54/// A request-handling tier, ordered from cheapest to most expensive.
55///
56/// Each tier adds complexity and resource cost but increases the chance
57/// of bypassing anti-bot protections.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60#[non_exhaustive]
61pub enum EscalationTier {
62    /// Standard HTTP client — no stealth measures, lowest resource cost.
63    HttpPlain = 0,
64    /// HTTP with a TLS fingerprint profile applied via rustls.
65    HttpTlsProfiled = 1,
66    /// Headless browser with basic CDP stealth (automation flag removed).
67    BrowserBasic = 2,
68    /// Full stealth browser: CDP fixes, JS patches, `WebRTC` leak prevention.
69    BrowserAdvanced = 3,
70}
71
72impl EscalationTier {
73    /// Return the next higher tier, or `None` if already at the maximum.
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// use stygian_graph::ports::escalation::EscalationTier;
79    ///
80    /// assert_eq!(
81    ///     EscalationTier::HttpPlain.next(),
82    ///     Some(EscalationTier::HttpTlsProfiled)
83    /// );
84    /// assert_eq!(EscalationTier::BrowserAdvanced.next(), None);
85    /// ```
86    #[must_use]
87    pub const fn next(self) -> Option<Self> {
88        match self {
89            Self::HttpPlain => Some(Self::HttpTlsProfiled),
90            Self::HttpTlsProfiled => Some(Self::BrowserBasic),
91            Self::BrowserBasic => Some(Self::BrowserAdvanced),
92            Self::BrowserAdvanced => None,
93        }
94    }
95}
96
97impl std::fmt::Display for EscalationTier {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::HttpPlain => f.write_str("http_plain"),
101            Self::HttpTlsProfiled => f.write_str("http_tls_profiled"),
102            Self::BrowserBasic => f.write_str("browser_basic"),
103            Self::BrowserAdvanced => f.write_str("browser_advanced"),
104        }
105    }
106}
107
108// ── ResponseContext ──────────────────────────────────────────────────────────
109
110/// Contextual information about an HTTP response used by
111/// [`EscalationPolicy::should_escalate`] to decide whether to move to a
112/// higher tier.
113#[derive(Debug, Clone)]
114pub struct ResponseContext {
115    /// HTTP status code (e.g. 200, 403, 503).
116    pub status: u16,
117    /// Whether the response body is empty.
118    pub body_empty: bool,
119    /// Whether the response body contains a Cloudflare challenge marker
120    /// (e.g. `<title>Just a moment...</title>` or a `cf-ray` header).
121    pub has_cloudflare_challenge: bool,
122    /// Whether a CAPTCHA marker was detected in the response
123    /// (e.g. reCAPTCHA, hCaptcha script tags).
124    pub has_captcha: bool,
125}
126
127// ── EscalationResult ─────────────────────────────────────────────────────────
128
129/// The outcome of a tiered escalation run.
130///
131/// Records which tier ultimately succeeded and the full escalation path
132/// for observability.
133#[derive(Debug, Clone)]
134pub struct EscalationResult<T> {
135    /// The tier that produced the final response.
136    pub final_tier: EscalationTier,
137    /// The successful response payload.
138    pub response: T,
139    /// Ordered list of tiers attempted (including the successful one).
140    pub escalation_path: Vec<EscalationTier>,
141}
142
143// ── EscalationPolicy ─────────────────────────────────────────────────────────
144
145/// Port trait for tiered request escalation.
146///
147/// Implementations decide:
148/// - Where to start ([`initial_tier`](Self::initial_tier))
149/// - When to move up ([`should_escalate`](Self::should_escalate))
150/// - Where to stop ([`max_tier`](Self::max_tier))
151///
152/// The trait is purely synchronous — it contains no I/O. The pipeline
153/// executor (see T20) calls into the policy between tiers.
154pub trait EscalationPolicy: Send + Sync {
155    /// The tier to attempt first.
156    fn initial_tier(&self) -> EscalationTier;
157
158    /// Given a response context and the current tier, return the next tier
159    /// to try, or `None` to accept the current response.
160    ///
161    /// Implementations should respect [`max_tier`](Self::max_tier): if
162    /// `current >= self.max_tier()`, return `None`.
163    fn should_escalate(
164        &self,
165        ctx: &ResponseContext,
166        current: EscalationTier,
167    ) -> Option<EscalationTier>;
168
169    /// The highest tier this policy is allowed to reach.
170    fn max_tier(&self) -> EscalationTier;
171}
172
173// ── tests ────────────────────────────────────────────────────────────────────
174
175#[cfg(test)]
176#[allow(clippy::unwrap_used)]
177mod tests {
178    use super::*;
179
180    /// A simple default policy for testing:
181    /// Start at `HttpPlain`, escalate on 403 / challenge / CAPTCHA.
182    struct DefaultPolicy;
183
184    impl EscalationPolicy for DefaultPolicy {
185        fn initial_tier(&self) -> EscalationTier {
186            EscalationTier::HttpPlain
187        }
188
189        fn should_escalate(
190            &self,
191            ctx: &ResponseContext,
192            current: EscalationTier,
193        ) -> Option<EscalationTier> {
194            if current >= self.max_tier() {
195                return None;
196            }
197
198            let needs_escalation = ctx.status == 403
199                || ctx.has_cloudflare_challenge
200                || ctx.has_captcha
201                || (ctx.body_empty && current >= EscalationTier::HttpTlsProfiled);
202
203            if needs_escalation {
204                current.next()
205            } else {
206                None
207            }
208        }
209
210        fn max_tier(&self) -> EscalationTier {
211            EscalationTier::BrowserAdvanced
212        }
213    }
214
215    #[test]
216    fn starts_at_http_plain() {
217        let policy = DefaultPolicy;
218        assert_eq!(policy.initial_tier(), EscalationTier::HttpPlain);
219    }
220
221    #[test]
222    fn escalates_on_403() {
223        let policy = DefaultPolicy;
224        let ctx = ResponseContext {
225            status: 403,
226            body_empty: false,
227            has_cloudflare_challenge: false,
228            has_captcha: false,
229        };
230        assert_eq!(
231            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
232            Some(EscalationTier::HttpTlsProfiled)
233        );
234    }
235
236    #[test]
237    fn escalates_on_cloudflare_challenge() {
238        let policy = DefaultPolicy;
239        let ctx = ResponseContext {
240            status: 503,
241            body_empty: false,
242            has_cloudflare_challenge: true,
243            has_captcha: false,
244        };
245        assert_eq!(
246            policy.should_escalate(&ctx, EscalationTier::HttpTlsProfiled),
247            Some(EscalationTier::BrowserBasic)
248        );
249    }
250
251    #[test]
252    fn max_tier_prevents_further_escalation() {
253        let policy = DefaultPolicy;
254        let ctx = ResponseContext {
255            status: 403,
256            body_empty: false,
257            has_cloudflare_challenge: false,
258            has_captcha: false,
259        };
260        assert_eq!(
261            policy.should_escalate(&ctx, EscalationTier::BrowserAdvanced),
262            None
263        );
264    }
265
266    #[test]
267    fn no_escalation_on_success() {
268        let policy = DefaultPolicy;
269        let ctx = ResponseContext {
270            status: 200,
271            body_empty: false,
272            has_cloudflare_challenge: false,
273            has_captcha: false,
274        };
275        assert_eq!(
276            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
277            None
278        );
279    }
280
281    #[test]
282    fn no_escalation_on_redirect() {
283        let policy = DefaultPolicy;
284        let ctx = ResponseContext {
285            status: 301,
286            body_empty: false,
287            has_cloudflare_challenge: false,
288            has_captcha: false,
289        };
290        assert_eq!(
291            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
292            None
293        );
294    }
295
296    #[test]
297    fn tier_ordering() {
298        assert!(EscalationTier::HttpPlain < EscalationTier::HttpTlsProfiled);
299        assert!(EscalationTier::HttpTlsProfiled < EscalationTier::BrowserBasic);
300        assert!(EscalationTier::BrowserBasic < EscalationTier::BrowserAdvanced);
301    }
302
303    #[test]
304    fn next_tier_chain() {
305        assert_eq!(
306            EscalationTier::HttpPlain.next(),
307            Some(EscalationTier::HttpTlsProfiled)
308        );
309        assert_eq!(
310            EscalationTier::HttpTlsProfiled.next(),
311            Some(EscalationTier::BrowserBasic)
312        );
313        assert_eq!(
314            EscalationTier::BrowserBasic.next(),
315            Some(EscalationTier::BrowserAdvanced)
316        );
317        assert_eq!(EscalationTier::BrowserAdvanced.next(), None);
318    }
319
320    #[test]
321    fn tier_display() {
322        assert_eq!(EscalationTier::HttpPlain.to_string(), "http_plain");
323        assert_eq!(
324            EscalationTier::BrowserAdvanced.to_string(),
325            "browser_advanced"
326        );
327    }
328
329    #[test]
330    fn tier_serde_roundtrip() {
331        let tier = EscalationTier::BrowserBasic;
332        let json = serde_json::to_string(&tier).unwrap();
333        let back: EscalationTier = serde_json::from_str(&json).unwrap();
334        assert_eq!(tier, back);
335    }
336}