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}