scrapely-python 0.1.5__tar.gz → 0.1.6__tar.gz

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 scrapely-python might be problematic. Click here for more details.

Files changed (62) hide show
  1. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/actor.py +43 -10
  5. scrapely_python-0.1.6/src/scrapely/cdp_retry.py +226 -0
  6. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/patch_browser_pool.py +14 -3
  7. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  8. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/SOURCES.txt +4 -1
  9. scrapely_python-0.1.6/tests/test_cdp_retry.py +169 -0
  10. scrapely_python-0.1.6/tests/test_terminal_status_message.py +130 -0
  11. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/README.md +0 -0
  12. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/setup.cfg +0 -0
  13. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/_browser_locations.py +0 -0
  14. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/charging.py +0 -0
  16. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/charging_client.py +0 -0
  17. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/configuration.py +0 -0
  18. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/crypto/__init__.py +0 -0
  19. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/crypto/_secrets.py +0 -0
  20. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/errors.py +0 -0
  21. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/__init__.py +0 -0
  22. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/_types.py +0 -0
  23. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/event_manager.py +0 -0
  24. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/log.py +0 -0
  25. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/pricing_types.py +0 -0
  26. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/proxy_configuration.py +0 -0
  27. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/py.typed +0 -0
  28. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/request_loaders/__init__.py +0 -0
  29. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  30. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/__init__.py +0 -0
  31. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/__init__.py +0 -0
  32. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  33. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  34. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_metadata.py +0 -0
  35. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/api_client.py +0 -0
  36. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/client.py +0 -0
  37. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/patch_storage.py +0 -0
  38. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  39. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  40. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  41. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  42. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  43. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/types.py +0 -0
  44. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/utils.py +0 -0
  45. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage_clients/__init__.py +0 -0
  46. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storages/__init__.py +0 -0
  47. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/types.py +0 -0
  48. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/webhook.py +0 -0
  49. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  50. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/requires.txt +0 -0
  51. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/top_level.txt +0 -0
  52. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_api_client_retries.py +0 -0
  53. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_apify_parity.py +0 -0
  54. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_browser_configuration.py +0 -0
  55. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_charging_parity.py +0 -0
  56. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_charging_pending.py +0 -0
  57. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_parity.py +0 -0
  58. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_patch_browser_pool.py +0 -0
  59. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_proxy_configuration.py +0 -0
  60. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_run_control.py +0 -0
  61. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_secrets.py +0 -0
  62. {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.5'
135
+ __version__ = '0.1.6'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -18,7 +18,7 @@ import warnings
18
18
  from collections.abc import MutableMapping
19
19
  from datetime import datetime, timedelta
20
20
  from decimal import Decimal
21
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast
21
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union, cast
22
22
 
23
23
  from crawlee.configuration import Configuration as CrawleeConfiguration
24
24
  from crawlee.storage_clients._base import StorageClient
@@ -287,6 +287,7 @@ class Actor(metaclass=_ActorMeta):
287
287
  cls._exit_code = self._instance_exit_code
288
288
  if self._instance_status_message:
289
289
  cls._status_message = self._instance_status_message
290
+ await cls._persist_terminal_status_message(self._instance_status_message)
290
291
  await cls._shutdown(timeout_secs=int(self._cleanup_timeout.total_seconds()))
291
292
 
292
293
  @classmethod
@@ -589,6 +590,7 @@ class Actor(metaclass=_ActorMeta):
589
590
  cls._exit_code = opts.exit_code
590
591
  cls._status_message = opts.status_message
591
592
 
593
+ await cls._persist_terminal_status_message(opts.status_message)
592
594
  await cls._shutdown(timeout_secs=opts.timeout_secs)
593
595
 
594
596
  if opts.exit:
@@ -1021,13 +1023,32 @@ class Actor(metaclass=_ActorMeta):
1021
1023
  else:
1022
1024
  logger.info(logged)
1023
1025
 
1026
+ result = await cls._put_run_status_message(message, is_terminal=is_terminal)
1027
+ if result is None:
1028
+ return ActorRun(id='', actor_id='', status='')
1029
+ run_id, data = result
1030
+
1031
+ return ActorRun(
1032
+ id=data.get('id', run_id),
1033
+ actor_id=data.get('actorId', ''),
1034
+ status=data.get('status', ''),
1035
+ started_at=data.get('startedAt'),
1036
+ finished_at=data.get('finishedAt'),
1037
+ status_message=data.get('statusMessage'),
1038
+ )
1039
+
1040
+ @classmethod
1041
+ async def _put_run_status_message(
1042
+ cls, message: str, *, is_terminal: bool
1043
+ ) -> Optional[Tuple[str, Dict[str, Any]]]:
1044
+ """PUT the status message to the current run. Returns ``None`` when not on the platform."""
1024
1045
  config = cls.get_config()
1025
1046
  api_url = config.get('api_base_url')
1026
1047
  token = config.get('token')
1027
1048
  run_id = config.get('actor_run_id')
1028
1049
 
1029
1050
  if not api_url or not token or not run_id:
1030
- return ActorRun(id='', actor_id='', status='')
1051
+ return None
1031
1052
 
1032
1053
  client = cls.new_client(token=token, base_url=api_url)
1033
1054
  response = await client.put(
@@ -1041,15 +1062,27 @@ class Actor(metaclass=_ActorMeta):
1041
1062
  if response.content:
1042
1063
  body = response.json()
1043
1064
  data = body.get('data', body) if isinstance(body, dict) else {}
1065
+ return run_id, data if isinstance(data, dict) else {}
1044
1066
 
1045
- return ActorRun(
1046
- id=data.get('id', run_id),
1047
- actor_id=data.get('actorId', ''),
1048
- status=data.get('status', ''),
1049
- started_at=data.get('startedAt'),
1050
- finished_at=data.get('finishedAt'),
1051
- status_message=data.get('statusMessage'),
1052
- )
1067
+ @classmethod
1068
+ async def _persist_terminal_status_message(cls, message: Optional[str], timeout_secs: float = 10.0) -> None:
1069
+ """Store the exit/fail status message on the run as terminal (Apify parity).
1070
+
1071
+ Runs before teardown so it lands while the run still exists and the
1072
+ process is alive, on success and failure alike. Best effort: a failed
1073
+ update is logged and never blocks the exit.
1074
+ """
1075
+ if not message or not cls._initialized or cls._config is None:
1076
+ return
1077
+ try:
1078
+ await asyncio.wait_for(
1079
+ cls._put_run_status_message(message, is_terminal=True),
1080
+ timeout=max(1.0, timeout_secs),
1081
+ )
1082
+ except asyncio.CancelledError:
1083
+ raise
1084
+ except BaseException as e:
1085
+ logger.warning('Failed to store the final status message on the run: %s', e)
1053
1086
 
1054
1087
  @_ClassProperty
1055
1088
  def log(cls) -> logging.Logger:
@@ -0,0 +1,226 @@
1
+ """Retry remote-browser (CDP) connects that fail for transient reasons.
2
+
3
+ The remote browser answers ``429`` when it is at capacity and ``503`` when it
4
+ cannot start a browser or validate the session right now, both with a
5
+ ``Retry-After`` delay (also repeated in the response body, because Playwright
6
+ only puts the status line and body into its error message). Connection errors
7
+ and timeouts are transient too. Those are retried with exponential backoff,
8
+ waiting at least the ``Retry-After`` delay when the error carries one. Any
9
+ other status (``400``/``401``/``402``/``403``/``404``) fails at once.
10
+
11
+ Tuning (environment):
12
+
13
+ - ``SCRAPELY_CDP_CONNECT_ATTEMPTS``: total attempts, default ``5`` (``1`` disables retries).
14
+ - ``SCRAPELY_CDP_RETRY_BASE_SECS``: first backoff delay, default ``1``; doubles per attempt.
15
+ - ``SCRAPELY_CDP_RETRY_MAX_SECS``: cap for one backoff delay, default ``30``.
16
+ - ``SCRAPELY_CDP_RETRY_AFTER_MAX_SECS``: longest ``Retry-After`` the SDK waits
17
+ out, default ``120``. A longer ``Retry-After`` ends the retries (the SDK never
18
+ retries earlier than the server asked).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import functools
25
+ import logging
26
+ import os
27
+ import random
28
+ import re
29
+ import time
30
+ from typing import Any, Awaitable, Callable, Optional, Tuple, TypeVar
31
+ from urllib.parse import urlsplit
32
+
33
+ logger = logging.getLogger('scrapely.browser')
34
+
35
+ T = TypeVar('T')
36
+
37
+ _RETRY_WRAPPED_ATTR = '_scrapely_cdp_retry_wrapped'
38
+
39
+ _REASONS = (
40
+ 'Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout|'
41
+ 'Request Timeout|Bad Request|Unauthorized|Payment Required|Forbidden|Not Found'
42
+ )
43
+ _STATUS_RE = re.compile(rf'\b([1-5]\d\d) (?:{_REASONS})\b')
44
+ _WS_STATUS_RE = re.compile(r'Unexpected server response:?\s*(\d{3})', re.IGNORECASE)
45
+ _RETRY_AFTER_RE = re.compile(r'Retry-After:?\s*(\d+(?:\.\d+)?)', re.IGNORECASE)
46
+ _TRANSIENT_MARKERS = (
47
+ 'econnrefused',
48
+ 'econnreset',
49
+ 'etimedout',
50
+ 'epipe',
51
+ 'eai_again',
52
+ 'ehostunreach',
53
+ 'enetunreach',
54
+ 'socket hang up',
55
+ 'timeout',
56
+ 'timed out',
57
+ 'connection closed',
58
+ 'connection refused',
59
+ 'connection reset',
60
+ 'websocket error',
61
+ 'net::err_',
62
+ 'target closed',
63
+ )
64
+
65
+
66
+ def _env_float(name: str, default: float) -> float:
67
+ try:
68
+ value = float(os.environ.get(name, ''))
69
+ except ValueError:
70
+ return default
71
+ return value if value > 0 else default
72
+
73
+
74
+ def _max_attempts() -> int:
75
+ return max(1, int(_env_float('SCRAPELY_CDP_CONNECT_ATTEMPTS', 5)))
76
+
77
+
78
+ def parse_status(message: str) -> Optional[int]:
79
+ """HTTP status from a Playwright/Puppeteer connect error message, if any."""
80
+ match = _WS_STATUS_RE.search(message) or _STATUS_RE.search(message)
81
+ return int(match.group(1)) if match else None
82
+
83
+
84
+ def parse_retry_after(message: str) -> Optional[float]:
85
+ match = _RETRY_AFTER_RE.search(message)
86
+ if not match:
87
+ return None
88
+ return float(match.group(1))
89
+
90
+
91
+ def classify_connect_error(err: BaseException) -> Tuple[bool, Optional[int], Optional[float]]:
92
+ """Return ``(retryable, status, retry_after_secs)`` for a connect error."""
93
+ message = f'{type(err).__name__}: {err}'
94
+ status = parse_status(message)
95
+ retry_after = parse_retry_after(message)
96
+ if status is not None:
97
+ return (status == 408 or status == 429 or status >= 500), status, retry_after
98
+ if isinstance(err, (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)):
99
+ return True, None, retry_after
100
+ lowered = message.lower()
101
+ return any(marker in lowered for marker in _TRANSIENT_MARKERS), None, retry_after
102
+
103
+
104
+ def backoff_delay(attempt: int, retry_after: Optional[float]) -> float:
105
+ """Delay before retry number ``attempt`` (1-based), honoring Retry-After."""
106
+ base = _env_float('SCRAPELY_CDP_RETRY_BASE_SECS', 1.0)
107
+ cap = _env_float('SCRAPELY_CDP_RETRY_MAX_SECS', 30.0)
108
+ delay = min(cap, base * (2 ** (attempt - 1)))
109
+ delay = delay * (0.75 + random.random() * 0.5)
110
+ if retry_after is not None:
111
+ delay = max(delay, retry_after)
112
+ return delay
113
+
114
+
115
+ def _retry_after_too_long(retry_after: Optional[float]) -> bool:
116
+ return retry_after is not None and retry_after > _env_float('SCRAPELY_CDP_RETRY_AFTER_MAX_SECS', 120.0)
117
+
118
+
119
+ def _describe(err: BaseException, status: Optional[int]) -> str:
120
+ if status is not None:
121
+ return f'HTTP {status}'
122
+ # First line only: connect errors can carry the session URL with ids.
123
+ text = str(err).strip().splitlines()[0] if str(err).strip() else type(err).__name__
124
+ return re.sub(r'(wss?|https?)://\S+', '<cdp-url>', text)[:200]
125
+
126
+
127
+ def _log_retry(err: BaseException, status: Optional[int], attempt: int, attempts: int, delay: float) -> None:
128
+ logger.warning(
129
+ 'Remote browser connect failed (%s); retrying in %.1fs (attempt %d/%d)',
130
+ _describe(err, status),
131
+ delay,
132
+ attempt + 1,
133
+ attempts,
134
+ )
135
+
136
+
137
+ async def connect_with_retry_async(connect: Callable[[], Awaitable[T]]) -> T:
138
+ """Run an async CDP connect, retrying transient failures."""
139
+ attempts = _max_attempts()
140
+ attempt = 1
141
+ while True:
142
+ try:
143
+ return await connect()
144
+ except asyncio.CancelledError:
145
+ raise
146
+ except Exception as err:
147
+ retryable, status, retry_after = classify_connect_error(err)
148
+ # Never retry earlier than the server asked; give up instead of waiting that long.
149
+ if not retryable or attempt >= attempts or _retry_after_too_long(retry_after):
150
+ raise
151
+ delay = backoff_delay(attempt, retry_after)
152
+ _log_retry(err, status, attempt, attempts, delay)
153
+ await asyncio.sleep(delay)
154
+ attempt += 1
155
+
156
+
157
+ def connect_with_retry_sync(connect: Callable[[], T]) -> T:
158
+ """Run a blocking CDP connect, retrying transient failures."""
159
+ attempts = _max_attempts()
160
+ attempt = 1
161
+ while True:
162
+ try:
163
+ return connect()
164
+ except Exception as err:
165
+ retryable, status, retry_after = classify_connect_error(err)
166
+ # Never retry earlier than the server asked; give up instead of waiting that long.
167
+ if not retryable or attempt >= attempts or _retry_after_too_long(retry_after):
168
+ raise
169
+ delay = backoff_delay(attempt, retry_after)
170
+ _log_retry(err, status, attempt, attempts, delay)
171
+ time.sleep(delay)
172
+ attempt += 1
173
+
174
+
175
+ def is_platform_cdp_url(url: Any, platform_cdp_url: Optional[str]) -> bool:
176
+ """True when ``url`` points at the same host as the platform's CDP_URL."""
177
+ if not isinstance(url, str) or not platform_cdp_url:
178
+ return False
179
+ try:
180
+ target, platform = urlsplit(url), urlsplit(platform_cdp_url)
181
+ except ValueError:
182
+ return False
183
+ return bool(target.netloc) and target.netloc.lower() == platform.netloc.lower()
184
+
185
+
186
+ def wrap_connect_over_cdp(chromium: Any, is_async: bool, get_platform_cdp_url: Callable[[], Optional[str]]) -> None:
187
+ """Make ``chromium.connect_over_cdp`` retry transient failures for the platform CDP host.
188
+
189
+ Covers actor code that connects to ``CDP_URL`` directly as well as the
190
+ SDK's own redirected launches. Connects to other hosts are left alone.
191
+ Idempotent.
192
+ """
193
+ original = getattr(chromium, 'connect_over_cdp', None)
194
+ if original is None or getattr(original, _RETRY_WRAPPED_ATTR, False):
195
+ return
196
+
197
+ def _target(args: Tuple[Any, ...], kwargs: dict) -> Any:
198
+ if args:
199
+ return args[0]
200
+ return kwargs.get('endpoint_url') or kwargs.get('endpointURL')
201
+
202
+ if is_async:
203
+
204
+ @functools.wraps(original)
205
+ async def connect_over_cdp(*args: Any, **kwargs: Any) -> Any:
206
+ if not is_platform_cdp_url(_target(args, kwargs), get_platform_cdp_url()):
207
+ return await original(*args, **kwargs)
208
+ return await connect_with_retry_async(lambda: original(*args, **kwargs))
209
+
210
+ else:
211
+
212
+ @functools.wraps(original)
213
+ def connect_over_cdp(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
214
+ if not is_platform_cdp_url(_target(args, kwargs), get_platform_cdp_url()):
215
+ return original(*args, **kwargs)
216
+ return connect_with_retry_sync(lambda: original(*args, **kwargs))
217
+
218
+ setattr(connect_over_cdp, _RETRY_WRAPPED_ATTR, True)
219
+ try:
220
+ chromium.connect_over_cdp = connect_over_cdp
221
+ except (AttributeError, TypeError):
222
+ pass
223
+
224
+
225
+ def is_retry_wrapped(fn: Any) -> bool:
226
+ return bool(getattr(fn, _RETRY_WRAPPED_ATTR, False))
@@ -70,6 +70,7 @@ from typing import Any, List, Optional, Tuple
70
70
  from urllib.parse import SplitResult, parse_qsl, unquote, urlencode, urlsplit, urlunsplit
71
71
 
72
72
  from ._browser_locations import subdivision_to_state_name
73
+ from .cdp_retry import connect_with_retry_async, wrap_connect_over_cdp
73
74
 
74
75
  logger = logging.getLogger(__name__)
75
76
 
@@ -427,7 +428,9 @@ def _apply_patch() -> None:
427
428
  cdp_url = apply_proxy_info_to_cdp_url(cdp_url, proxy_info, browser_configuration)
428
429
 
429
430
  # Remote browser infrastructure is Chromium CDP (same as Node connectOverCDP).
430
- browser = await self._playwright.chromium.connect_over_cdp(cdp_url)
431
+ chromium = self._playwright.chromium
432
+ wrap_connect_over_cdp(chromium, True, get_cdp_url)
433
+ browser = await chromium.connect_over_cdp(cdp_url)
431
434
 
432
435
  controller = PlaywrightBrowserController(
433
436
  browser,
@@ -518,7 +521,9 @@ def _apply_camoufox_patch() -> None:
518
521
  # Same as the PlaywrightBrowserPlugin patch above: the first page's
519
522
  # proxy info / browser configuration pins the connection's location.
520
523
  cdp_url = apply_proxy_info_to_cdp_url(cdp_url, _launch_proxy_info.get(), browser_configuration)
521
- return await playwright.chromium.connect_over_cdp(cdp_url)
524
+ chromium = playwright.chromium
525
+ wrap_connect_over_cdp(chromium, True, get_cdp_url)
526
+ return await chromium.connect_over_cdp(cdp_url)
522
527
 
523
528
  camoufox.AsyncNewBrowser = _patched_async_new_browser
524
529
  camoufox._scrapely_cdp_patched = True
@@ -726,6 +731,10 @@ def _patch_playwright_object(playwright: Any, is_async: bool) -> Any:
726
731
  chromium = getattr(playwright, 'chromium', None)
727
732
  if chromium is None or getattr(chromium, 'connect_over_cdp', None) is None:
728
733
  return playwright
734
+ # Retry transient connect failures (429/5xx/network) for the platform CDP
735
+ # host: covers direct chromium.connect_over_cdp(CDP_URL) calls in actor
736
+ # code and the redirected launches below, which call this same attribute.
737
+ wrap_connect_over_cdp(chromium, is_async, get_cdp_url)
729
738
  for name in ('chromium', 'firefox', 'webkit'):
730
739
  browser_type = getattr(playwright, name, None)
731
740
  if browser_type is None:
@@ -845,7 +854,9 @@ def _apply_pyppeteer_patch() -> None:
845
854
  'ignored: %s (the remote browser manages headless mode, binary and flags).',
846
855
  ', '.join(sorted(merged.keys())),
847
856
  )
848
- return await pyppeteer_launcher.connect(browserWSEndpoint=cdp_url)
857
+ return await connect_with_retry_async(
858
+ lambda: pyppeteer_launcher.connect(browserWSEndpoint=cdp_url)
859
+ )
849
860
 
850
861
  pyppeteer_launcher.launch = _patched_launch
851
862
  if hasattr(pyppeteer_mod, 'launch'):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT
@@ -4,6 +4,7 @@ src/scrapely/__init__.py
4
4
  src/scrapely/_browser_locations.py
5
5
  src/scrapely/actor.py
6
6
  src/scrapely/browser_configuration.py
7
+ src/scrapely/cdp_retry.py
7
8
  src/scrapely/charging.py
8
9
  src/scrapely/charging_client.py
9
10
  src/scrapely/configuration.py
@@ -47,6 +48,7 @@ src/scrapely_python.egg-info/top_level.txt
47
48
  tests/test_api_client_retries.py
48
49
  tests/test_apify_parity.py
49
50
  tests/test_browser_configuration.py
51
+ tests/test_cdp_retry.py
50
52
  tests/test_charging_parity.py
51
53
  tests/test_charging_pending.py
52
54
  tests/test_parity.py
@@ -54,4 +56,5 @@ tests/test_patch_browser_pool.py
54
56
  tests/test_proxy_configuration.py
55
57
  tests/test_run_control.py
56
58
  tests/test_secrets.py
57
- tests/test_storage.py
59
+ tests/test_storage.py
60
+ tests/test_terminal_status_message.py
@@ -0,0 +1,169 @@
1
+ """Remote-browser connects retry 429/5xx/connection failures and honor Retry-After."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, List
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from scrapely import cdp_retry
12
+ from scrapely.cdp_retry import (
13
+ classify_connect_error,
14
+ connect_with_retry_async,
15
+ connect_with_retry_sync,
16
+ wrap_connect_over_cdp,
17
+ )
18
+
19
+ PLATFORM = 'wss://browser.example.test?scrapely_run_id=r&scrapely_user_id=u'
20
+
21
+
22
+ class PlaywrightError(Exception):
23
+ pass
24
+
25
+
26
+ def _ws_error(status: str, body: str = '') -> PlaywrightError:
27
+ msg = f'BrowserType.connect_over_cdp: WebSocket error: wss://browser.example.test/ {status}'
28
+ if body:
29
+ msg += f'\n{body}'
30
+ return PlaywrightError(msg)
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def _fast(monkeypatch: pytest.MonkeyPatch) -> List[float]:
35
+ sleeps: List[float] = []
36
+
37
+ async def fake_sleep(delay: float) -> None:
38
+ sleeps.append(delay)
39
+
40
+ monkeypatch.setattr(cdp_retry.asyncio, 'sleep', fake_sleep)
41
+ monkeypatch.setattr(cdp_retry.time, 'sleep', lambda d: sleeps.append(d))
42
+ monkeypatch.delenv('SCRAPELY_CDP_CONNECT_ATTEMPTS', raising=False)
43
+ return sleeps
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ 'err,retryable,status',
48
+ [
49
+ (_ws_error('429 Too Many Requests'), True, 429),
50
+ (_ws_error('500 Internal Server Error'), True, 500),
51
+ (_ws_error('503 Service Unavailable'), True, 503),
52
+ (PlaywrightError('Unexpected server response: 502'), True, 502),
53
+ (_ws_error('401 Unauthorized', 'Scrapely: run_not_active'), False, 401),
54
+ (_ws_error('402 Payment Required', 'Scrapely: no_credits'), False, 402),
55
+ (PlaywrightError('connect ECONNREFUSED 1.2.3.4:443'), True, None),
56
+ (PlaywrightError('BrowserType.connect_over_cdp: Timeout 25000ms exceeded.'), True, None),
57
+ (ConnectionResetError('reset'), True, None),
58
+ (ValueError('bad option'), False, None),
59
+ ],
60
+ )
61
+ def test_classify(err: BaseException, retryable: bool, status: Any) -> None:
62
+ got_retryable, got_status, _ = classify_connect_error(err)
63
+ assert (got_retryable, got_status) == (retryable, status)
64
+
65
+
66
+ def test_retry_after_from_body() -> None:
67
+ err = _ws_error('429 Too Many Requests', 'Too many requests\nRetry-After: 7')
68
+ assert classify_connect_error(err) == (True, 429, 7.0)
69
+
70
+
71
+ async def test_async_retries_then_succeeds_and_honors_retry_after(_fast: List[float]) -> None:
72
+ calls = 0
73
+
74
+ async def connect() -> str:
75
+ nonlocal calls
76
+ calls += 1
77
+ if calls == 1:
78
+ raise _ws_error('429 Too Many Requests', 'Retry-After: 9')
79
+ if calls == 2:
80
+ raise _ws_error('500 Internal Server Error')
81
+ return 'browser'
82
+
83
+ assert await connect_with_retry_async(connect) == 'browser'
84
+ assert calls == 3
85
+ assert _fast[0] >= 9
86
+ assert len(_fast) == 2
87
+
88
+
89
+ async def test_async_does_not_retry_auth_failure(_fast: List[float]) -> None:
90
+ calls = 0
91
+
92
+ async def connect() -> str:
93
+ nonlocal calls
94
+ calls += 1
95
+ raise _ws_error('401 Unauthorized')
96
+
97
+ with pytest.raises(PlaywrightError):
98
+ await connect_with_retry_async(connect)
99
+ assert calls == 1
100
+ assert _fast == []
101
+
102
+
103
+ async def test_async_gives_up_after_max_attempts(monkeypatch: pytest.MonkeyPatch, _fast: List[float]) -> None:
104
+ monkeypatch.setenv('SCRAPELY_CDP_CONNECT_ATTEMPTS', '3')
105
+ calls = 0
106
+
107
+ async def connect() -> str:
108
+ nonlocal calls
109
+ calls += 1
110
+ raise _ws_error('503 Service Unavailable')
111
+
112
+ with pytest.raises(PlaywrightError):
113
+ await connect_with_retry_async(connect)
114
+ assert calls == 3
115
+ assert len(_fast) == 2
116
+
117
+
118
+ def test_sync_retries() -> None:
119
+ calls = 0
120
+
121
+ def connect() -> str:
122
+ nonlocal calls
123
+ calls += 1
124
+ if calls < 3:
125
+ raise ConnectionRefusedError('refused')
126
+ return 'browser'
127
+
128
+ assert connect_with_retry_sync(connect) == 'browser'
129
+ assert calls == 3
130
+
131
+
132
+ class _Chromium:
133
+ def __init__(self, failures: int) -> None:
134
+ self.failures = failures
135
+ self.urls: List[str] = []
136
+
137
+ async def connect_over_cdp(self, endpoint_url: str, **kwargs: Any) -> str:
138
+ self.urls.append(endpoint_url)
139
+ if len(self.urls) <= self.failures:
140
+ raise _ws_error('429 Too Many Requests')
141
+ return 'browser'
142
+
143
+
144
+ async def test_wrapped_connect_over_cdp_retries_platform_host_only() -> None:
145
+ chromium = _Chromium(failures=2)
146
+ wrap_connect_over_cdp(chromium, True, lambda: PLATFORM)
147
+ wrap_connect_over_cdp(chromium, True, lambda: PLATFORM) # idempotent
148
+ assert await chromium.connect_over_cdp(PLATFORM + '&proxy_country=US') == 'browser'
149
+ assert len(chromium.urls) == 3
150
+
151
+ other = _Chromium(failures=1)
152
+ wrap_connect_over_cdp(other, True, lambda: PLATFORM)
153
+ with pytest.raises(PlaywrightError):
154
+ await other.connect_over_cdp('ws://localhost:9222/devtools/browser/x')
155
+ assert len(other.urls) == 1
156
+
157
+
158
+ async def test_long_retry_after_ends_retries_instead_of_retrying_early(_fast: List[float]) -> None:
159
+ calls = 0
160
+
161
+ async def connect() -> str:
162
+ nonlocal calls
163
+ calls += 1
164
+ raise _ws_error('429 Too Many Requests', 'Retry-After: 600')
165
+
166
+ with pytest.raises(PlaywrightError):
167
+ await connect_with_retry_async(connect)
168
+ assert calls == 1
169
+ assert _fast == []
@@ -0,0 +1,130 @@
1
+ """Actor.exit()/fail() store their status message on the run as terminal, before teardown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ import pytest
9
+
10
+ from scrapely import Actor
11
+ from scrapely.configuration import Configuration
12
+
13
+
14
+ class _FakeResponse:
15
+ def __init__(self, data: Dict[str, Any]) -> None:
16
+ self._data = data
17
+ self.content = b'x'
18
+
19
+ def json(self) -> Dict[str, Any]:
20
+ return {'data': self._data}
21
+
22
+
23
+ class _FakeClient:
24
+ def __init__(self, events: List[Tuple[str, Any]], fail: bool = False, hang: bool = False) -> None:
25
+ self.events = events
26
+ self.fail = fail
27
+ self.hang = hang
28
+
29
+ async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None) -> _FakeResponse:
30
+ if self.hang:
31
+ await asyncio.sleep(60)
32
+ if self.fail:
33
+ raise RuntimeError('API down')
34
+ self.events.append(('put', (path, json_data)))
35
+ return _FakeResponse({'id': 'run123', **(json_data or {})})
36
+
37
+
38
+ @pytest.fixture
39
+ def lifecycle(monkeypatch: pytest.MonkeyPatch):
40
+ events: List[Tuple[str, Any]] = []
41
+ exit_codes: List[int] = []
42
+ state = {
43
+ '_exit_code': Actor._exit_code,
44
+ '_status_message': Actor._status_message,
45
+ '_initialized': Actor._initialized,
46
+ '_config': Actor._config,
47
+ }
48
+ config = Configuration({'api_base_url': 'http://api.test', 'token': 'tok', 'actor_run_id': 'run123'})
49
+ Actor._config = config
50
+ Actor._initialized = True
51
+
52
+ async def fake_shutdown(timeout_secs: int = 30) -> None:
53
+ events.append(('shutdown', timeout_secs))
54
+ Actor._initialized = False
55
+
56
+ client = _FakeClient(events)
57
+ monkeypatch.setattr(Actor, '_shutdown', fake_shutdown)
58
+ monkeypatch.setattr(Actor, 'new_client', classmethod(lambda cls, token=None, base_url=None: client))
59
+ monkeypatch.setattr('sys.exit', lambda code=0: exit_codes.append(code))
60
+ try:
61
+ yield events, exit_codes, client
62
+ finally:
63
+ for key, value in state.items():
64
+ setattr(Actor, key, value)
65
+
66
+
67
+ async def test_fail_stores_terminal_status_message_before_shutdown(lifecycle) -> None:
68
+ events, exit_codes, _ = lifecycle
69
+ await Actor.fail(status_message='No results: bad input')
70
+ assert events[0] == (
71
+ 'put',
72
+ ('/v2/actor-runs/run123', {'statusMessage': 'No results: bad input', 'isStatusMessageTerminal': True}),
73
+ )
74
+ assert events[1][0] == 'shutdown'
75
+ assert exit_codes == [1]
76
+
77
+
78
+ async def test_exit_stores_terminal_status_message(lifecycle) -> None:
79
+ events, exit_codes, _ = lifecycle
80
+ await Actor.exit('Done: 10 items')
81
+ assert events[0] == (
82
+ 'put',
83
+ ('/v2/actor-runs/run123', {'statusMessage': 'Done: 10 items', 'isStatusMessageTerminal': True}),
84
+ )
85
+ assert exit_codes == [0]
86
+
87
+
88
+ async def test_exit_without_message_sends_nothing(lifecycle) -> None:
89
+ events, _, _ = lifecycle
90
+ await Actor.exit(exit_code=1)
91
+ assert [e[0] for e in events] == ['shutdown']
92
+
93
+
94
+ async def test_failed_update_does_not_block_exit(lifecycle) -> None:
95
+ events, exit_codes, client = lifecycle
96
+ client.fail = True
97
+ await Actor.fail(status_message='boom')
98
+ assert [e[0] for e in events] == ['shutdown']
99
+ assert exit_codes == [1]
100
+
101
+
102
+ async def test_hanging_update_is_bounded(lifecycle, monkeypatch: pytest.MonkeyPatch) -> None:
103
+ events, exit_codes, client = lifecycle
104
+ client.hang = True
105
+ original = Actor._persist_terminal_status_message.__func__
106
+
107
+ async def quick(cls, message, timeout_secs: float = 10.0):
108
+ await original(cls, message, timeout_secs=0.01)
109
+
110
+ monkeypatch.setattr(Actor, '_persist_terminal_status_message', classmethod(quick))
111
+ await asyncio.wait_for(Actor.fail(status_message='boom'), timeout=5)
112
+ assert [e[0] for e in events] == ['shutdown']
113
+ assert exit_codes == [1]
114
+
115
+
116
+ async def test_context_manager_status_message_is_terminal(lifecycle, monkeypatch: pytest.MonkeyPatch) -> None:
117
+ events, _, _ = lifecycle
118
+
119
+ async def fake_init(options: Any = None) -> None:
120
+ Actor._initialized = True
121
+
122
+ monkeypatch.setattr(Actor, 'init', fake_init)
123
+ with pytest.raises(ValueError):
124
+ async with Actor(status_message='Crashed while parsing'):
125
+ raise ValueError('parse error')
126
+ assert events[0] == (
127
+ 'put',
128
+ ('/v2/actor-runs/run123', {'statusMessage': 'Crashed while parsing', 'isStatusMessageTerminal': True}),
129
+ )
130
+ assert events[1][0] == 'shutdown'