Skip to main content

stygian_graph/ports/
escalation.rs

1//! Tiered request escalation port.
2//!
3//! Defines the [`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`](EscalationTier::HttpPlain) | Standard HTTP client, no stealth |
15//! | [`HttpTlsProfiled`](EscalationTier::HttpTlsProfiled) | HTTP with TLS fingerprint matching |
16//! | [`BrowserBasic`](EscalationTier::BrowserBasic) | Headless browser with basic stealth |
17//! | [`BrowserAdvanced`](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    pub const fn next(self) -> Option<Self> {
87        match self {
88            Self::HttpPlain => Some(Self::HttpTlsProfiled),
89            Self::HttpTlsProfiled => Some(Self::BrowserBasic),
90            Self::BrowserBasic => Some(Self::BrowserAdvanced),
91            Self::BrowserAdvanced => None,
92        }
93    }
94}
95
96impl std::fmt::Display for EscalationTier {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::HttpPlain => f.write_str("http_plain"),
100            Self::HttpTlsProfiled => f.write_str("http_tls_profiled"),
101            Self::BrowserBasic => f.write_str("browser_basic"),
102            Self::BrowserAdvanced => f.write_str("browser_advanced"),
103        }
104    }
105}
106
107// ── ResponseContext ──────────────────────────────────────────────────────────
108
109/// Contextual information about an HTTP response used by
110/// [`EscalationPolicy::should_escalate`] to decide whether to move to a
111/// higher tier.
112#[derive(Debug, Clone)]
113pub struct ResponseContext {
114    /// HTTP status code (e.g. 200, 403, 503).
115    pub status: u16,
116    /// Whether the response body is empty.
117    pub body_empty: bool,
118    /// Whether the response body contains a Cloudflare challenge marker
119    /// (e.g. `<title>Just a moment...</title>` or a `cf-ray` header).
120    pub has_cloudflare_challenge: bool,
121    /// Whether a CAPTCHA marker was detected in the response
122    /// (e.g. reCAPTCHA, hCaptcha script tags).
123    pub has_captcha: bool,
124}
125
126// ── EscalationResult ─────────────────────────────────────────────────────────
127
128/// The outcome of a tiered escalation run.
129///
130/// Records which tier ultimately succeeded and the full escalation path
131/// for observability.
132#[derive(Debug, Clone)]
133pub struct EscalationResult<T> {
134    /// The tier that produced the final response.
135    pub final_tier: EscalationTier,
136    /// The successful response payload.
137    pub response: T,
138    /// Ordered list of tiers attempted (including the successful one).
139    pub escalation_path: Vec<EscalationTier>,
140}
141
142// ── EscalationPolicy ─────────────────────────────────────────────────────────
143
144/// Port trait for tiered request escalation.
145///
146/// Implementations decide:
147/// - Where to start ([`initial_tier`](Self::initial_tier))
148/// - When to move up ([`should_escalate`](Self::should_escalate))
149/// - Where to stop ([`max_tier`](Self::max_tier))
150///
151/// The trait is purely synchronous — it contains no I/O. The pipeline
152/// executor (see T20) calls into the policy between tiers.
153pub trait EscalationPolicy: Send + Sync {
154    /// The tier to attempt first.
155    fn initial_tier(&self) -> EscalationTier;
156
157    /// Given a response context and the current tier, return the next tier
158    /// to try, or `None` to accept the current response.
159    ///
160    /// Implementations should respect [`max_tier`](Self::max_tier): if
161    /// `current >= self.max_tier()`, return `None`.
162    fn should_escalate(
163        &self,
164        ctx: &ResponseContext,
165        current: EscalationTier,
166    ) -> Option<EscalationTier>;
167
168    /// The highest tier this policy is allowed to reach.
169    fn max_tier(&self) -> EscalationTier;
170}
171
172// ── tests ────────────────────────────────────────────────────────────────────
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used)]
176mod tests {
177    use super::*;
178
179    /// A simple default policy for testing:
180    /// Start at `HttpPlain`, escalate on 403 / challenge / CAPTCHA.
181    struct DefaultPolicy;
182
183    impl EscalationPolicy for DefaultPolicy {
184        fn initial_tier(&self) -> EscalationTier {
185            EscalationTier::HttpPlain
186        }
187
188        fn should_escalate(
189            &self,
190            ctx: &ResponseContext,
191            current: EscalationTier,
192        ) -> Option<EscalationTier> {
193            if current >= self.max_tier() {
194                return None;
195            }
196
197            let needs_escalation = ctx.status == 403
198                || ctx.has_cloudflare_challenge
199                || ctx.has_captcha
200                || (ctx.body_empty && current >= EscalationTier::HttpTlsProfiled);
201
202            if needs_escalation {
203                current.next()
204            } else {
205                None
206            }
207        }
208
209        fn max_tier(&self) -> EscalationTier {
210            EscalationTier::BrowserAdvanced
211        }
212    }
213
214    #[test]
215    fn starts_at_http_plain() {
216        let policy = DefaultPolicy;
217        assert_eq!(policy.initial_tier(), EscalationTier::HttpPlain);
218    }
219
220    #[test]
221    fn escalates_on_403() {
222        let policy = DefaultPolicy;
223        let ctx = ResponseContext {
224            status: 403,
225            body_empty: false,
226            has_cloudflare_challenge: false,
227            has_captcha: false,
228        };
229        assert_eq!(
230            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
231            Some(EscalationTier::HttpTlsProfiled)
232        );
233    }
234
235    #[test]
236    fn escalates_on_cloudflare_challenge() {
237        let policy = DefaultPolicy;
238        let ctx = ResponseContext {
239            status: 503,
240            body_empty: false,
241            has_cloudflare_challenge: true,
242            has_captcha: false,
243        };
244        assert_eq!(
245            policy.should_escalate(&ctx, EscalationTier::HttpTlsProfiled),
246            Some(EscalationTier::BrowserBasic)
247        );
248    }
249
250    #[test]
251    fn max_tier_prevents_further_escalation() {
252        let policy = DefaultPolicy;
253        let ctx = ResponseContext {
254            status: 403,
255            body_empty: false,
256            has_cloudflare_challenge: false,
257            has_captcha: false,
258        };
259        assert_eq!(
260            policy.should_escalate(&ctx, EscalationTier::BrowserAdvanced),
261            None
262        );
263    }
264
265    #[test]
266    fn no_escalation_on_success() {
267        let policy = DefaultPolicy;
268        let ctx = ResponseContext {
269            status: 200,
270            body_empty: false,
271            has_cloudflare_challenge: false,
272            has_captcha: false,
273        };
274        assert_eq!(
275            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
276            None
277        );
278    }
279
280    #[test]
281    fn no_escalation_on_redirect() {
282        let policy = DefaultPolicy;
283        let ctx = ResponseContext {
284            status: 301,
285            body_empty: false,
286            has_cloudflare_challenge: false,
287            has_captcha: false,
288        };
289        assert_eq!(
290            policy.should_escalate(&ctx, EscalationTier::HttpPlain),
291            None
292        );
293    }
294
295    #[test]
296    fn tier_ordering() {
297        assert!(EscalationTier::HttpPlain < EscalationTier::HttpTlsProfiled);
298        assert!(EscalationTier::HttpTlsProfiled < EscalationTier::BrowserBasic);
299        assert!(EscalationTier::BrowserBasic < EscalationTier::BrowserAdvanced);
300    }
301
302    #[test]
303    fn next_tier_chain() {
304        assert_eq!(
305            EscalationTier::HttpPlain.next(),
306            Some(EscalationTier::HttpTlsProfiled)
307        );
308        assert_eq!(
309            EscalationTier::HttpTlsProfiled.next(),
310            Some(EscalationTier::BrowserBasic)
311        );
312        assert_eq!(
313            EscalationTier::BrowserBasic.next(),
314            Some(EscalationTier::BrowserAdvanced)
315        );
316        assert_eq!(EscalationTier::BrowserAdvanced.next(), None);
317    }
318
319    #[test]
320    fn tier_display() {
321        assert_eq!(EscalationTier::HttpPlain.to_string(), "http_plain");
322        assert_eq!(
323            EscalationTier::BrowserAdvanced.to_string(),
324            "browser_advanced"
325        );
326    }
327
328    #[test]
329    fn tier_serde_roundtrip() {
330        let tier = EscalationTier::BrowserBasic;
331        let json = serde_json::to_string(&tier).unwrap();
332        let back: EscalationTier = serde_json::from_str(&json).unwrap();
333        assert_eq!(tier, back);
334    }
335}