scythe-ttp 0.13.0__py3-none-any.whl → 0.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scythe-ttp might be problematic. Click here for more details.
- scythe/cli/__init__.py +3 -0
- scythe/cli/main.py +601 -0
- scythe/core/headers.py +69 -9
- scythe/journeys/actions.py +172 -61
- scythe/journeys/base.py +121 -5
- scythe/journeys/executor.py +40 -1
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/METADATA +83 -17
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/RECORD +12 -9
- scythe_ttp-0.14.0.dist-info/entry_points.txt +2 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/WHEEL +0 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/top_level.txt +0 -0
scythe/core/headers.py
CHANGED
|
@@ -19,6 +19,51 @@ class HeaderExtractor:
|
|
|
19
19
|
def __init__(self):
|
|
20
20
|
self.logger = logging.getLogger("HeaderExtractor")
|
|
21
21
|
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _normalize_url(url: str) -> str:
|
|
24
|
+
"""Ensure the URL has a scheme so requests can handle it."""
|
|
25
|
+
if not isinstance(url, str):
|
|
26
|
+
return url
|
|
27
|
+
lower = url.lower().strip()
|
|
28
|
+
if lower.startswith("http://") or lower.startswith("https://"):
|
|
29
|
+
return url
|
|
30
|
+
return f"http://{url}"
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _is_static_asset(url: str, headers: Optional[Dict[str, Any]] = None) -> bool:
|
|
34
|
+
"""Heuristically determine if a URL/log entry is a static asset (css/js/image/font/etc.)."""
|
|
35
|
+
try:
|
|
36
|
+
if not isinstance(url, str):
|
|
37
|
+
return False
|
|
38
|
+
u = url.lower()
|
|
39
|
+
# Common static file extensions
|
|
40
|
+
static_exts = (
|
|
41
|
+
'.css', '.js', '.mjs', '.map', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
|
|
42
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot', '.webp', '.mp4', '.webm', '.mp3', '.wav'
|
|
43
|
+
)
|
|
44
|
+
if any(u.endswith(ext) for ext in static_exts):
|
|
45
|
+
return True
|
|
46
|
+
if '/static/' in u or '/assets/' in u:
|
|
47
|
+
return True
|
|
48
|
+
# Content-Type hint
|
|
49
|
+
if isinstance(headers, dict):
|
|
50
|
+
# case-insensitive lookup
|
|
51
|
+
ctype = None
|
|
52
|
+
for k, v in headers.items():
|
|
53
|
+
if isinstance(k, str) and k.lower() == 'content-type':
|
|
54
|
+
ctype = str(v).lower()
|
|
55
|
+
break
|
|
56
|
+
if ctype and (ctype.startswith('text/css') or
|
|
57
|
+
ctype.startswith('application/javascript') or
|
|
58
|
+
ctype.startswith('text/javascript') or
|
|
59
|
+
ctype.startswith('image/') or
|
|
60
|
+
ctype.startswith('font/')):
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
# Be safe: if unsure, do not classify as static
|
|
64
|
+
return False
|
|
65
|
+
return False
|
|
66
|
+
|
|
22
67
|
@staticmethod
|
|
23
68
|
def enable_logging_for_driver(chrome_options: Options) -> None:
|
|
24
69
|
"""
|
|
@@ -50,13 +95,14 @@ class HeaderExtractor:
|
|
|
50
95
|
Version string if header found, None otherwise
|
|
51
96
|
"""
|
|
52
97
|
try:
|
|
53
|
-
self.
|
|
98
|
+
norm_url = self._normalize_url(url)
|
|
99
|
+
self.logger.debug(f"Making {method} request to {norm_url} for header extraction")
|
|
54
100
|
|
|
55
101
|
# Use HEAD by default for efficiency, fallback to GET if needed
|
|
56
102
|
if method.upper() == "HEAD":
|
|
57
|
-
response = requests.head(
|
|
103
|
+
response = requests.head(norm_url, timeout=timeout, allow_redirects=True)
|
|
58
104
|
else:
|
|
59
|
-
response = requests.get(
|
|
105
|
+
response = requests.get(norm_url, timeout=timeout, allow_redirects=True)
|
|
60
106
|
|
|
61
107
|
# Check if request was successful
|
|
62
108
|
response.raise_for_status()
|
|
@@ -71,7 +117,8 @@ class HeaderExtractor:
|
|
|
71
117
|
return None
|
|
72
118
|
|
|
73
119
|
except requests.exceptions.RequestException as e:
|
|
74
|
-
|
|
120
|
+
hint = " (tip: include http:// or https://)" if isinstance(url, str) and not url.lower().startswith(("http://","https://")) else ""
|
|
121
|
+
self.logger.warning(f"Failed to make {method} request to {url}: {e}{hint}")
|
|
75
122
|
return None
|
|
76
123
|
except Exception as e:
|
|
77
124
|
self.logger.warning(f"Unexpected error during banner grab: {e}")
|
|
@@ -90,12 +137,13 @@ class HeaderExtractor:
|
|
|
90
137
|
Dictionary of all response headers
|
|
91
138
|
"""
|
|
92
139
|
try:
|
|
93
|
-
self.
|
|
140
|
+
norm_url = self._normalize_url(url)
|
|
141
|
+
self.logger.debug(f"Making {method} request to {norm_url} for all headers")
|
|
94
142
|
|
|
95
143
|
if method.upper() == "HEAD":
|
|
96
|
-
response = requests.head(
|
|
144
|
+
response = requests.head(norm_url, timeout=timeout, allow_redirects=True)
|
|
97
145
|
else:
|
|
98
|
-
response = requests.get(
|
|
146
|
+
response = requests.get(norm_url, timeout=timeout, allow_redirects=True)
|
|
99
147
|
|
|
100
148
|
response.raise_for_status()
|
|
101
149
|
|
|
@@ -103,7 +151,8 @@ class HeaderExtractor:
|
|
|
103
151
|
return {k: str(v) for k, v in response.headers.items()}
|
|
104
152
|
|
|
105
153
|
except requests.exceptions.RequestException as e:
|
|
106
|
-
|
|
154
|
+
hint = " (tip: include http:// or https://)" if isinstance(url, str) and not url.lower().startswith(("http://","https://")) else ""
|
|
155
|
+
self.logger.warning(f"Failed to get headers from {url}: {e}{hint}")
|
|
107
156
|
return {}
|
|
108
157
|
except Exception as e:
|
|
109
158
|
self.logger.warning(f"Unexpected error getting headers: {e}")
|
|
@@ -179,7 +228,11 @@ class HeaderExtractor:
|
|
|
179
228
|
self.logger.debug(f"Successfully extracted version '{version}' via banner grab")
|
|
180
229
|
return version
|
|
181
230
|
else:
|
|
182
|
-
self.logger.debug("Banner grab failed
|
|
231
|
+
self.logger.debug("Banner grab failed")
|
|
232
|
+
|
|
233
|
+
# In API mode (no driver), do not fall back to Selenium to avoid noisy warnings
|
|
234
|
+
if driver is None:
|
|
235
|
+
return None
|
|
183
236
|
|
|
184
237
|
# Fall back to Selenium performance logs
|
|
185
238
|
self.logger.debug("Using Selenium performance logs method")
|
|
@@ -223,6 +276,13 @@ class HeaderExtractor:
|
|
|
223
276
|
if target_url and target_url not in response_url:
|
|
224
277
|
continue
|
|
225
278
|
|
|
279
|
+
# Ignore static assets (css/js/images/fonts) to avoid false detections/noise
|
|
280
|
+
try:
|
|
281
|
+
if self._is_static_asset(response_url, headers):
|
|
282
|
+
continue
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
226
286
|
# Look for the version header (case-insensitive)
|
|
227
287
|
version = self._find_version_header(headers)
|
|
228
288
|
if version:
|
scythe/journeys/actions.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
+
import logging
|
|
2
3
|
from typing import Dict, Any, Optional
|
|
3
4
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
4
5
|
from selenium.webdriver.common.by import By
|
|
@@ -700,70 +701,180 @@ class ApiRequestAction(Action):
|
|
|
700
701
|
if self.headers:
|
|
701
702
|
final_headers.update(self.headers)
|
|
702
703
|
|
|
704
|
+
# Simple masking for sensitive headers
|
|
705
|
+
def _mask_headers(headers: Dict[str, Any]) -> Dict[str, Any]:
|
|
706
|
+
masked = {}
|
|
707
|
+
for k, v in (headers or {}).items():
|
|
708
|
+
if k is None:
|
|
709
|
+
continue
|
|
710
|
+
key_lower = str(k).lower()
|
|
711
|
+
if key_lower in {"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"}:
|
|
712
|
+
masked[k] = "***"
|
|
713
|
+
else:
|
|
714
|
+
masked[k] = v
|
|
715
|
+
return masked
|
|
716
|
+
|
|
703
717
|
# Resolve URL: absolute or join with target_url from context
|
|
704
718
|
from urllib.parse import urljoin
|
|
719
|
+
from ..core.headers import HeaderExtractor
|
|
705
720
|
base_url = context.get('target_url') or ''
|
|
706
|
-
|
|
721
|
+
# Ensure base_url has a scheme so urljoin works with relative paths
|
|
722
|
+
if isinstance(base_url, str) and base_url and not base_url.lower().startswith(('http://', 'https://')):
|
|
723
|
+
base_url = HeaderExtractor._normalize_url(base_url)
|
|
724
|
+
if isinstance(self.url, str) and self.url.lower().startswith('http'):
|
|
725
|
+
resolved_url = self.url
|
|
726
|
+
else:
|
|
727
|
+
resolved_url = urljoin(base_url, self.url)
|
|
707
728
|
|
|
729
|
+
# Store request details early
|
|
730
|
+
self.store_result('request_method', self.method)
|
|
731
|
+
self.store_result('url', resolved_url)
|
|
732
|
+
if self.params:
|
|
733
|
+
self.store_result('request_params', self.params)
|
|
734
|
+
if self.body_json is not None:
|
|
735
|
+
self.store_result('request_json', self.body_json)
|
|
736
|
+
if self.data is not None:
|
|
737
|
+
self.store_result('request_data', self.data)
|
|
738
|
+
self.store_result('request_headers', _mask_headers(final_headers))
|
|
739
|
+
|
|
740
|
+
logger = logging.getLogger("Journey.ApiRequestAction")
|
|
741
|
+
# Honor any pending rate-limit resume time set by previous actions/steps
|
|
708
742
|
try:
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
headers=final_headers or None,
|
|
716
|
-
timeout=self.timeout,
|
|
717
|
-
)
|
|
718
|
-
|
|
719
|
-
# Store details
|
|
720
|
-
self.store_result('url', resolved_url)
|
|
721
|
-
self.store_result('request_headers', final_headers)
|
|
722
|
-
self.store_result('status_code', getattr(response, 'status_code', None))
|
|
723
|
-
response_headers = dict(getattr(response, 'headers', {}) or {})
|
|
724
|
-
self.store_result('response_headers', response_headers)
|
|
725
|
-
# Publish to context for downstream version extraction
|
|
726
|
-
context['last_response_headers'] = response_headers
|
|
727
|
-
context['last_response_url'] = resolved_url
|
|
728
|
-
# Try JSON, fallback to text
|
|
729
|
-
body = None
|
|
730
|
-
parsed_model = None
|
|
731
|
-
validation_error = None
|
|
732
|
-
try:
|
|
733
|
-
body = response.json()
|
|
734
|
-
self.store_result('response_json', body)
|
|
735
|
-
# If a response_model is provided, attempt validation/parsing
|
|
736
|
-
if self.response_model is not None:
|
|
743
|
+
resume_at = context.get('rate_limit_resume_at')
|
|
744
|
+
now = time.time()
|
|
745
|
+
if isinstance(resume_at, (int, float)) and resume_at > now:
|
|
746
|
+
wait_s = min(resume_at - now, 30)
|
|
747
|
+
if wait_s > 0:
|
|
748
|
+
self.store_result('waited_ms_before_request', int(wait_s * 1000))
|
|
737
749
|
try:
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
750
|
+
logger.info(f"Delaying {wait_s:.2f}s due to prior rate limit (resume_at)")
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
time.sleep(wait_s)
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
def _h(headers: Dict[str, Any], name: str):
|
|
758
|
+
lname = (name or '').lower()
|
|
759
|
+
for k, v in (headers or {}).items():
|
|
760
|
+
try:
|
|
761
|
+
if isinstance(k, str) and k.lower() == lname:
|
|
762
|
+
return v
|
|
763
|
+
except Exception:
|
|
764
|
+
continue
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
attempts = 2 # at most one retry on 429 for idempotent methods
|
|
768
|
+
last_exception = None
|
|
769
|
+
for attempt in range(attempts):
|
|
770
|
+
start_ts = time.time()
|
|
771
|
+
try:
|
|
772
|
+
response = session.request(
|
|
773
|
+
self.method,
|
|
774
|
+
resolved_url,
|
|
775
|
+
params=self.params or None,
|
|
776
|
+
json=self.body_json,
|
|
777
|
+
data=self.data,
|
|
778
|
+
headers=final_headers or None,
|
|
779
|
+
timeout=self.timeout,
|
|
780
|
+
)
|
|
781
|
+
duration_ms = int((time.time() - start_ts) * 1000)
|
|
782
|
+
self.store_result('duration_ms', duration_ms)
|
|
783
|
+
|
|
784
|
+
# Store details
|
|
785
|
+
status_code = getattr(response, 'status_code', None)
|
|
786
|
+
self.store_result('status_code', status_code)
|
|
787
|
+
response_headers = dict(getattr(response, 'headers', {}) or {})
|
|
788
|
+
# Mask sensitive response headers
|
|
789
|
+
self.store_result('response_headers', _mask_headers(response_headers))
|
|
790
|
+
# Publish to context for downstream version extraction and rate-limit coordination
|
|
791
|
+
context['last_response_headers'] = response_headers
|
|
792
|
+
context['last_response_url'] = resolved_url
|
|
793
|
+
|
|
794
|
+
# Parse rate-limit headers
|
|
795
|
+
try:
|
|
796
|
+
# Normalize numeric values
|
|
797
|
+
remaining = _h(response_headers, 'X-RateLimit-Remaining')
|
|
798
|
+
if remaining is None:
|
|
799
|
+
remaining = _h(response_headers, 'X-Ratelimit-Remaining')
|
|
800
|
+
reset = _h(response_headers, 'X-RateLimit-Reset')
|
|
801
|
+
if reset is None:
|
|
802
|
+
reset = _h(response_headers, 'X-Ratelimit-Reset')
|
|
803
|
+
retry_after = _h(response_headers, 'Retry-After')
|
|
804
|
+
|
|
805
|
+
# If explicit Retry-After or 429, set resume time and optionally retry
|
|
806
|
+
if status_code == 429:
|
|
807
|
+
wait_s = 0
|
|
808
|
+
try:
|
|
809
|
+
wait_s = int(str(retry_after).strip()) if retry_after is not None else 1
|
|
810
|
+
except Exception:
|
|
811
|
+
wait_s = 1
|
|
812
|
+
wait_s = max(1, min(wait_s, 30))
|
|
813
|
+
context['rate_limit_resume_at'] = time.time() + wait_s
|
|
814
|
+
self.store_result('rate_limit_wait_s', wait_s)
|
|
815
|
+
if attempt == 0 and self.method in {'GET', 'HEAD', 'OPTIONS'}:
|
|
816
|
+
try:
|
|
817
|
+
logger.info(f"Hit 429 Too Many Requests; backing off {wait_s}s and retrying once")
|
|
818
|
+
except Exception:
|
|
819
|
+
pass
|
|
820
|
+
time.sleep(wait_s)
|
|
821
|
+
continue # retry once
|
|
822
|
+
else:
|
|
823
|
+
# If remaining == 0 and reset provided, set resume time
|
|
824
|
+
try:
|
|
825
|
+
if remaining is not None and str(remaining).strip() == '0' and reset is not None:
|
|
826
|
+
wait_s2 = int(str(reset).strip())
|
|
827
|
+
if wait_s2 > 0:
|
|
828
|
+
context['rate_limit_resume_at'] = time.time() + min(wait_s2, 30)
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
833
|
+
|
|
834
|
+
# Try JSON, fallback to text
|
|
835
|
+
parsed_model = None
|
|
836
|
+
try:
|
|
837
|
+
body = response.json()
|
|
838
|
+
self.store_result('response_json', body)
|
|
839
|
+
# If a response_model is provided, attempt validation/parsing
|
|
840
|
+
if self.response_model is not None:
|
|
841
|
+
try:
|
|
842
|
+
# Pydantic v2 preferred: model_validate
|
|
843
|
+
if hasattr(self.response_model, 'model_validate'):
|
|
844
|
+
parsed_model = self.response_model.model_validate(body)
|
|
845
|
+
else:
|
|
846
|
+
# Pydantic v1 fallback
|
|
847
|
+
parsed_model = self.response_model.parse_obj(body)
|
|
848
|
+
self.store_result('response_model_instance', parsed_model)
|
|
849
|
+
# Save into context for downstream actions
|
|
850
|
+
key = self.response_model_context_key or 'last_response_model'
|
|
851
|
+
context[key] = parsed_model
|
|
852
|
+
except Exception as ve:
|
|
853
|
+
self.store_result('response_validation_error', str(ve))
|
|
854
|
+
except Exception:
|
|
855
|
+
text = getattr(response, 'text', '')
|
|
856
|
+
# Limit stored text to keep logs light
|
|
857
|
+
if text is not None and isinstance(text, str):
|
|
858
|
+
self.store_result('response_text', text if len(text) <= 2000 else text[:2000])
|
|
859
|
+
else:
|
|
860
|
+
self.store_result('response_text', text)
|
|
861
|
+
|
|
862
|
+
# Determine success (status-based by default)
|
|
863
|
+
if self.expected_status is not None:
|
|
864
|
+
http_ok = (getattr(response, 'status_code', None) == self.expected_status)
|
|
865
|
+
else:
|
|
866
|
+
http_ok = bool(getattr(response, 'ok', False))
|
|
867
|
+
|
|
868
|
+
# Optionally fail on validation error
|
|
869
|
+
if self.response_model is not None and self.fail_on_validation_error and self.get_result('response_validation_error'):
|
|
870
|
+
return False
|
|
871
|
+
|
|
872
|
+
return http_ok
|
|
873
|
+
except Exception as e:
|
|
874
|
+
last_exception = e
|
|
875
|
+
self.store_result('duration_ms', int((time.time() - start_ts) * 1000))
|
|
876
|
+
self.store_result('error', str(e))
|
|
877
|
+
break
|
|
878
|
+
|
|
879
|
+
# If we got here and had an exception or no return, fail
|
|
880
|
+
return False
|
scythe/journeys/base.py
CHANGED
|
@@ -102,7 +102,7 @@ class Step:
|
|
|
102
102
|
"""Add an action to this step."""
|
|
103
103
|
self.actions.append(action)
|
|
104
104
|
|
|
105
|
-
def execute(self, driver: WebDriver, context: Dict[str, Any]) -> bool:
|
|
105
|
+
def execute(self, driver: WebDriver|None, context: Dict[str, Any]) -> bool:
|
|
106
106
|
"""
|
|
107
107
|
Execute all actions in this step.
|
|
108
108
|
|
|
@@ -111,7 +111,7 @@ class Step:
|
|
|
111
111
|
context: Shared context data
|
|
112
112
|
|
|
113
113
|
Returns:
|
|
114
|
-
True if step succeeded, False otherwise
|
|
114
|
+
True if a step succeeded, False otherwise
|
|
115
115
|
"""
|
|
116
116
|
logger = logging.getLogger(f"Journey.Step.{self.name}")
|
|
117
117
|
logger.info(f"Executing step: {self.name}")
|
|
@@ -136,12 +136,14 @@ class Step:
|
|
|
136
136
|
result = action.execute(driver, context)
|
|
137
137
|
|
|
138
138
|
# Store result
|
|
139
|
+
details = getattr(action, 'execution_data', {})
|
|
139
140
|
action_result = {
|
|
140
141
|
'action_name': action.name,
|
|
141
142
|
'action_description': action.description,
|
|
142
143
|
'expected': action.expected_result,
|
|
143
144
|
'actual': result,
|
|
144
|
-
'timestamp': time.time()
|
|
145
|
+
'timestamp': time.time(),
|
|
146
|
+
'details': details.copy() if isinstance(details, dict) else {}
|
|
145
147
|
}
|
|
146
148
|
self.execution_results.append(action_result)
|
|
147
149
|
|
|
@@ -156,6 +158,47 @@ class Step:
|
|
|
156
158
|
else:
|
|
157
159
|
if action.expected_result:
|
|
158
160
|
logger.error(f"✗ Action failed: {action.name}")
|
|
161
|
+
# Emit diagnostic details when available (e.g., for API requests)
|
|
162
|
+
try:
|
|
163
|
+
ad = action_result.get('details', {}) or {}
|
|
164
|
+
method = ad.get('request_method') or getattr(action, 'method', None)
|
|
165
|
+
url = ad.get('url') or getattr(action, 'url', None)
|
|
166
|
+
status = ad.get('status_code')
|
|
167
|
+
dur = ad.get('duration_ms')
|
|
168
|
+
if method or url or status is not None:
|
|
169
|
+
parts = []
|
|
170
|
+
if method:
|
|
171
|
+
parts.append(f"method={method}")
|
|
172
|
+
if url:
|
|
173
|
+
parts.append(f"url={url}")
|
|
174
|
+
if status is not None:
|
|
175
|
+
parts.append(f"status={status}")
|
|
176
|
+
if dur is not None:
|
|
177
|
+
parts.append(f"duration_ms={dur}")
|
|
178
|
+
logger.error(" Details: " + ", ".join(parts))
|
|
179
|
+
req_headers = ad.get('request_headers')
|
|
180
|
+
if req_headers:
|
|
181
|
+
logger.error(f" Request headers: {req_headers}")
|
|
182
|
+
req_params = ad.get('request_params')
|
|
183
|
+
if req_params:
|
|
184
|
+
logger.error(f" Request params: {req_params}")
|
|
185
|
+
req_json = ad.get('request_json')
|
|
186
|
+
if req_json is not None:
|
|
187
|
+
logger.error(f" Request JSON: {req_json}")
|
|
188
|
+
req_data = ad.get('request_data')
|
|
189
|
+
if req_data is not None:
|
|
190
|
+
logger.error(f" Request data: {req_data}")
|
|
191
|
+
resp_headers = ad.get('response_headers')
|
|
192
|
+
if resp_headers:
|
|
193
|
+
logger.error(f" Response headers: {resp_headers}")
|
|
194
|
+
if 'response_json' in ad:
|
|
195
|
+
logger.error(f" Response JSON: {ad.get('response_json')}")
|
|
196
|
+
elif 'response_text' in ad:
|
|
197
|
+
logger.error(f" Response text: {ad.get('response_text')}")
|
|
198
|
+
if ad.get('error'):
|
|
199
|
+
logger.error(f" Error: {ad.get('error')}")
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
159
202
|
failure_count += 1
|
|
160
203
|
if not self.continue_on_failure:
|
|
161
204
|
return False
|
|
@@ -165,6 +208,35 @@ class Step:
|
|
|
165
208
|
|
|
166
209
|
except Exception as e:
|
|
167
210
|
logger.error(f"Exception in action {action.name}: {str(e)}")
|
|
211
|
+
# Emit any available diagnostics even on exceptions
|
|
212
|
+
try:
|
|
213
|
+
details = getattr(action, 'execution_data', {}) or {}
|
|
214
|
+
if details:
|
|
215
|
+
method = details.get('request_method') or getattr(action, 'method', None)
|
|
216
|
+
url = details.get('url') or getattr(action, 'url', None)
|
|
217
|
+
status = details.get('status_code')
|
|
218
|
+
dur = details.get('duration_ms')
|
|
219
|
+
parts = []
|
|
220
|
+
if method:
|
|
221
|
+
parts.append(f"method={method}")
|
|
222
|
+
if url:
|
|
223
|
+
parts.append(f"url={url}")
|
|
224
|
+
if status is not None:
|
|
225
|
+
parts.append(f"status={status}")
|
|
226
|
+
if dur is not None:
|
|
227
|
+
parts.append(f"duration_ms={dur}")
|
|
228
|
+
if parts:
|
|
229
|
+
logger.error(" Details: " + ", ".join(parts))
|
|
230
|
+
if details.get('request_headers'):
|
|
231
|
+
logger.error(f" Request headers: {details.get('request_headers')}")
|
|
232
|
+
if details.get('response_headers'):
|
|
233
|
+
logger.error(f" Response headers: {details.get('response_headers')}")
|
|
234
|
+
if 'response_json' in details:
|
|
235
|
+
logger.error(f" Response JSON: {details.get('response_json')}")
|
|
236
|
+
elif 'response_text' in details:
|
|
237
|
+
logger.error(f" Response text: {details.get('response_text')}")
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
168
240
|
failure_count += 1
|
|
169
241
|
if not self.continue_on_failure:
|
|
170
242
|
return False
|
|
@@ -260,7 +332,7 @@ class Journey:
|
|
|
260
332
|
logger.error(f"Authentication failed: {str(e)}")
|
|
261
333
|
return False
|
|
262
334
|
|
|
263
|
-
def execute(self, driver: WebDriver, target_url: str) -> Dict[str, Any]:
|
|
335
|
+
def execute(self, driver: WebDriver|None, target_url: str) -> Dict[str, Any]:
|
|
264
336
|
"""
|
|
265
337
|
Execute the complete journey.
|
|
266
338
|
|
|
@@ -283,7 +355,9 @@ class Journey:
|
|
|
283
355
|
start_time = time.time()
|
|
284
356
|
|
|
285
357
|
# Set initial context
|
|
286
|
-
|
|
358
|
+
# Normalize target_url to include scheme when missing (e.g., 'localhost:8080' -> 'http://localhost:8080')
|
|
359
|
+
normalized_target_url = HeaderExtractor._normalize_url(target_url) if isinstance(target_url, str) else target_url
|
|
360
|
+
self.set_context('target_url', normalized_target_url)
|
|
287
361
|
self.set_context('journey_name', self.name)
|
|
288
362
|
self.set_context('start_time', start_time)
|
|
289
363
|
|
|
@@ -391,6 +465,48 @@ class Journey:
|
|
|
391
465
|
'target_version': target_version
|
|
392
466
|
}
|
|
393
467
|
results['step_results'].append(step_result)
|
|
468
|
+
|
|
469
|
+
# If the previous step exhausted the rate limit, pause before starting the next one
|
|
470
|
+
try:
|
|
471
|
+
# Prefer an explicit resume time set by actions
|
|
472
|
+
resume_at = self.context.get('rate_limit_resume_at')
|
|
473
|
+
now = time.time()
|
|
474
|
+
if isinstance(resume_at, (int, float)) and resume_at > now:
|
|
475
|
+
wait_s = min(resume_at - now, 30)
|
|
476
|
+
if wait_s > 0:
|
|
477
|
+
logger.info(f"Rate limit backoff in effect; waiting {wait_s:.2f}s before next step")
|
|
478
|
+
time.sleep(wait_s)
|
|
479
|
+
else:
|
|
480
|
+
last_headers = (self.context.get('last_response_headers') or {})
|
|
481
|
+
if isinstance(last_headers, dict) and last_headers:
|
|
482
|
+
def _h(name: str):
|
|
483
|
+
name = (name or '').lower()
|
|
484
|
+
for k, v in last_headers.items():
|
|
485
|
+
if isinstance(k, str) and k.lower() == name:
|
|
486
|
+
return v
|
|
487
|
+
return None
|
|
488
|
+
retry_after = _h('retry-after')
|
|
489
|
+
if retry_after is not None:
|
|
490
|
+
try:
|
|
491
|
+
wait_s = int(str(retry_after).strip())
|
|
492
|
+
if wait_s > 0:
|
|
493
|
+
logger.info(f"Rate-limited by server (Retry-After={wait_s}s); pausing before next step")
|
|
494
|
+
time.sleep(min(wait_s, 30))
|
|
495
|
+
except Exception:
|
|
496
|
+
pass
|
|
497
|
+
else:
|
|
498
|
+
remaining = _h('x-ratelimit-remaining')
|
|
499
|
+
reset = _h('x-ratelimit-reset')
|
|
500
|
+
if remaining is not None and str(remaining).strip() == '0' and reset is not None:
|
|
501
|
+
try:
|
|
502
|
+
wait_s = int(str(reset).strip())
|
|
503
|
+
if wait_s > 0:
|
|
504
|
+
logger.info(f"Rate limit reached (remaining=0). Waiting {wait_s}s for reset before next step")
|
|
505
|
+
time.sleep(min(wait_s, 30))
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
394
510
|
|
|
395
511
|
except Exception as e:
|
|
396
512
|
logger.error(f"Exception in step {step.name}: {str(e)}")
|
scythe/journeys/executor.py
CHANGED
|
@@ -147,7 +147,7 @@ class JourneyExecutor:
|
|
|
147
147
|
self.journey.set_context('auth_headers', auth_headers)
|
|
148
148
|
self.journey.set_context('auth_cookies', auth_cookies)
|
|
149
149
|
|
|
150
|
-
# Execute journey with a None driver (API actions ignore driver)
|
|
150
|
+
# Execute a journey with a None driver (API actions ignore a driver)
|
|
151
151
|
self.execution_results = self.journey.execute(None, self.target_url)
|
|
152
152
|
else:
|
|
153
153
|
# UI mode (default)
|
|
@@ -352,6 +352,45 @@ class JourneyExecutor:
|
|
|
352
352
|
|
|
353
353
|
self.logger.info(f" {status} Step {i}: {step_name} - {result_text} ({expected_text}){version_info}")
|
|
354
354
|
self.logger.info(f" Actions: {len([a for a in actions if a.get('actual', False)])}/{len(actions)} succeeded")
|
|
355
|
+
# Print diagnostic details only for unexpected outcomes
|
|
356
|
+
for a in actions:
|
|
357
|
+
actual = a.get('actual', False)
|
|
358
|
+
expected = a.get('expected', True)
|
|
359
|
+
if actual != expected:
|
|
360
|
+
prefix = "✗ Action failed" if expected else "✗ Action unexpectedly succeeded"
|
|
361
|
+
self.logger.error(f" {prefix}: {a.get('action_name')}")
|
|
362
|
+
ad = a.get('details', {}) or {}
|
|
363
|
+
method = ad.get('request_method')
|
|
364
|
+
url = ad.get('url')
|
|
365
|
+
status_code = ad.get('status_code')
|
|
366
|
+
dur = ad.get('duration_ms')
|
|
367
|
+
parts = []
|
|
368
|
+
if method:
|
|
369
|
+
parts.append(f"method={method}")
|
|
370
|
+
if url:
|
|
371
|
+
parts.append(f"url={url}")
|
|
372
|
+
if status_code is not None:
|
|
373
|
+
parts.append(f"status={status_code}")
|
|
374
|
+
if dur is not None:
|
|
375
|
+
parts.append(f"duration_ms={dur}")
|
|
376
|
+
if parts:
|
|
377
|
+
self.logger.error(" Details: " + ", ".join(parts))
|
|
378
|
+
if ad.get('request_headers'):
|
|
379
|
+
self.logger.error(f" Request headers: {ad.get('request_headers')}")
|
|
380
|
+
if ad.get('request_params'):
|
|
381
|
+
self.logger.error(f" Request params: {ad.get('request_params')}")
|
|
382
|
+
if ad.get('request_json') is not None:
|
|
383
|
+
self.logger.error(f" Request JSON: {ad.get('request_json')}")
|
|
384
|
+
if ad.get('request_data') is not None:
|
|
385
|
+
self.logger.error(f" Request data: {ad.get('request_data')}")
|
|
386
|
+
if ad.get('response_headers'):
|
|
387
|
+
self.logger.error(f" Response headers: {ad.get('response_headers')}")
|
|
388
|
+
if 'response_json' in ad:
|
|
389
|
+
self.logger.error(f" Response JSON: {ad.get('response_json')}")
|
|
390
|
+
elif 'response_text' in ad:
|
|
391
|
+
self.logger.error(f" Response text: {ad.get('response_text')}")
|
|
392
|
+
if ad.get('error'):
|
|
393
|
+
self.logger.error(f" Error: {ad.get('error')}")
|
|
355
394
|
|
|
356
395
|
# Version summary
|
|
357
396
|
target_versions = self.execution_results.get('target_versions', [])
|