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 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}