scrapely-python 0.1.5__tar.gz → 0.1.7__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.
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/PKG-INFO +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/pyproject.toml +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/actor.py +59 -17
- scrapely_python-0.1.7/src/scrapely/cdp_retry.py +226 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/charging.py +54 -6
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/configuration.py +21 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/patch_browser_pool.py +14 -3
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/proxy_configuration.py +47 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/api_client.py +20 -3
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/client.py +6 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/SOURCES.txt +6 -1
- scrapely_python-0.1.7/tests/test_cdp_retry.py +169 -0
- scrapely_python-0.1.7/tests/test_charge_retries.py +141 -0
- scrapely_python-0.1.7/tests/test_input_proxy_aliases.py +98 -0
- scrapely_python-0.1.7/tests/test_terminal_status_message.py +130 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/README.md +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/setup.cfg +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_storage.py +0 -0
|
@@ -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
|
|
@@ -27,6 +27,7 @@ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
|
|
|
27
27
|
from .charging import ChargingManager, is_default_dataset_item_event, push_data_and_charge
|
|
28
28
|
from .configuration import Configuration
|
|
29
29
|
from .crypto import decrypt_secret_inputs
|
|
30
|
+
from .proxy_configuration import with_proxy_key_aliases
|
|
30
31
|
from crawlee.events import EventManager
|
|
31
32
|
|
|
32
33
|
from .events import Event, LocalEventManager, ScrapelyEventManager
|
|
@@ -287,6 +288,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
287
288
|
cls._exit_code = self._instance_exit_code
|
|
288
289
|
if self._instance_status_message:
|
|
289
290
|
cls._status_message = self._instance_status_message
|
|
291
|
+
await cls._persist_terminal_status_message(self._instance_status_message)
|
|
290
292
|
await cls._shutdown(timeout_secs=int(self._cleanup_timeout.total_seconds()))
|
|
291
293
|
|
|
292
294
|
@classmethod
|
|
@@ -483,8 +485,10 @@ class Actor(metaclass=_ActorMeta):
|
|
|
483
485
|
flush = getattr(cls._charging_manager, 'flush_pending', None)
|
|
484
486
|
if flush is not None:
|
|
485
487
|
deadline_ms = min(max(1, timeout_secs) * 1000, 30_000)
|
|
488
|
+
# The flush's own deadline ends its retries (charge POSTs back off
|
|
489
|
+
# for up to ~45 s) a little before wait_for would cancel it.
|
|
486
490
|
await asyncio.wait_for(
|
|
487
|
-
flush(max_intents=64, deadline_ms=deadline_ms),
|
|
491
|
+
flush(max_intents=64, deadline_ms=max(500, deadline_ms - 2_000)),
|
|
488
492
|
timeout=max(1.0, deadline_ms / 1000.0),
|
|
489
493
|
)
|
|
490
494
|
except BaseException as e:
|
|
@@ -589,6 +593,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
589
593
|
cls._exit_code = opts.exit_code
|
|
590
594
|
cls._status_message = opts.status_message
|
|
591
595
|
|
|
596
|
+
await cls._persist_terminal_status_message(opts.status_message)
|
|
592
597
|
await cls._shutdown(timeout_secs=opts.timeout_secs)
|
|
593
598
|
|
|
594
599
|
if opts.exit:
|
|
@@ -685,8 +690,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
685
690
|
kvs = await cls.open_key_value_store()
|
|
686
691
|
metamorph_input = await kvs.get_value(f'INPUT-METAMORPH-{count}')
|
|
687
692
|
if metamorph_input is not None:
|
|
688
|
-
cls._cached_input = metamorph_input
|
|
689
|
-
return cast(Dict[str, Any],
|
|
693
|
+
cls._cached_input = with_proxy_key_aliases(metamorph_input)
|
|
694
|
+
return cast(Dict[str, Any], cls._cached_input)
|
|
690
695
|
|
|
691
696
|
raw_input = os.environ.get('ACTOR_INPUT')
|
|
692
697
|
if raw_input:
|
|
@@ -699,7 +704,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
699
704
|
# path is decrypted, mirroring Actor.getInput() in the Node SDK)
|
|
700
705
|
if isinstance(parsed, dict):
|
|
701
706
|
parsed = decrypt_secret_inputs(parsed)
|
|
702
|
-
|
|
707
|
+
# Proxy objects carry both namings (scrapelyProxy* and apifyProxy*).
|
|
708
|
+
cls._cached_input = with_proxy_key_aliases(parsed)
|
|
703
709
|
return cast(Dict[str, Any], cls._cached_input)
|
|
704
710
|
|
|
705
711
|
kvs = await cls.open_key_value_store()
|
|
@@ -707,8 +713,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
707
713
|
result = await kvs.get_value(input_key)
|
|
708
714
|
if isinstance(result, dict):
|
|
709
715
|
result = decrypt_secret_inputs(result)
|
|
710
|
-
cls._cached_input = result
|
|
711
|
-
return cast(Optional[Dict[str, Any]],
|
|
716
|
+
cls._cached_input = with_proxy_key_aliases(result)
|
|
717
|
+
return cast(Optional[Dict[str, Any]], cls._cached_input)
|
|
712
718
|
|
|
713
719
|
@classmethod
|
|
714
720
|
async def get_input_or_throw(cls) -> Dict[str, Any]:
|
|
@@ -737,7 +743,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
737
743
|
|
|
738
744
|
# Decrypt secret inputs after loading (JS parity with Actor.input getter)
|
|
739
745
|
if isinstance(cls._cached_input, dict):
|
|
740
|
-
cls._cached_input = decrypt_secret_inputs(cls._cached_input)
|
|
746
|
+
cls._cached_input = with_proxy_key_aliases(decrypt_secret_inputs(cls._cached_input))
|
|
741
747
|
|
|
742
748
|
return cls._cached_input
|
|
743
749
|
|
|
@@ -1021,13 +1027,32 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1021
1027
|
else:
|
|
1022
1028
|
logger.info(logged)
|
|
1023
1029
|
|
|
1030
|
+
result = await cls._put_run_status_message(message, is_terminal=is_terminal)
|
|
1031
|
+
if result is None:
|
|
1032
|
+
return ActorRun(id='', actor_id='', status='')
|
|
1033
|
+
run_id, data = result
|
|
1034
|
+
|
|
1035
|
+
return ActorRun(
|
|
1036
|
+
id=data.get('id', run_id),
|
|
1037
|
+
actor_id=data.get('actorId', ''),
|
|
1038
|
+
status=data.get('status', ''),
|
|
1039
|
+
started_at=data.get('startedAt'),
|
|
1040
|
+
finished_at=data.get('finishedAt'),
|
|
1041
|
+
status_message=data.get('statusMessage'),
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
@classmethod
|
|
1045
|
+
async def _put_run_status_message(
|
|
1046
|
+
cls, message: str, *, is_terminal: bool
|
|
1047
|
+
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
|
1048
|
+
"""PUT the status message to the current run. Returns ``None`` when not on the platform."""
|
|
1024
1049
|
config = cls.get_config()
|
|
1025
1050
|
api_url = config.get('api_base_url')
|
|
1026
1051
|
token = config.get('token')
|
|
1027
1052
|
run_id = config.get('actor_run_id')
|
|
1028
1053
|
|
|
1029
1054
|
if not api_url or not token or not run_id:
|
|
1030
|
-
return
|
|
1055
|
+
return None
|
|
1031
1056
|
|
|
1032
1057
|
client = cls.new_client(token=token, base_url=api_url)
|
|
1033
1058
|
response = await client.put(
|
|
@@ -1041,15 +1066,27 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1041
1066
|
if response.content:
|
|
1042
1067
|
body = response.json()
|
|
1043
1068
|
data = body.get('data', body) if isinstance(body, dict) else {}
|
|
1069
|
+
return run_id, data if isinstance(data, dict) else {}
|
|
1044
1070
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1071
|
+
@classmethod
|
|
1072
|
+
async def _persist_terminal_status_message(cls, message: Optional[str], timeout_secs: float = 10.0) -> None:
|
|
1073
|
+
"""Store the exit/fail status message on the run as terminal (Apify parity).
|
|
1074
|
+
|
|
1075
|
+
Runs before teardown so it lands while the run still exists and the
|
|
1076
|
+
process is alive, on success and failure alike. Best effort: a failed
|
|
1077
|
+
update is logged and never blocks the exit.
|
|
1078
|
+
"""
|
|
1079
|
+
if not message or not cls._initialized or cls._config is None:
|
|
1080
|
+
return
|
|
1081
|
+
try:
|
|
1082
|
+
await asyncio.wait_for(
|
|
1083
|
+
cls._put_run_status_message(message, is_terminal=True),
|
|
1084
|
+
timeout=max(1.0, timeout_secs),
|
|
1085
|
+
)
|
|
1086
|
+
except asyncio.CancelledError:
|
|
1087
|
+
raise
|
|
1088
|
+
except BaseException as e:
|
|
1089
|
+
logger.warning('Failed to store the final status message on the run: %s', e)
|
|
1053
1090
|
|
|
1054
1091
|
@_ClassProperty
|
|
1055
1092
|
def log(cls) -> logging.Logger:
|
|
@@ -1139,6 +1176,11 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1139
1176
|
"""Return the Configuration instance."""
|
|
1140
1177
|
return cls.get_config()
|
|
1141
1178
|
|
|
1179
|
+
@_ClassProperty
|
|
1180
|
+
def config(cls) -> Configuration:
|
|
1181
|
+
"""Return the Configuration instance (Apify SDK ``Actor.config`` parity)."""
|
|
1182
|
+
return cls.get_config()
|
|
1183
|
+
|
|
1142
1184
|
@_ClassProperty
|
|
1143
1185
|
def event_manager(cls) -> Optional[EventManager]:
|
|
1144
1186
|
"""Return the EventManager instance."""
|
|
@@ -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))
|
|
@@ -47,8 +47,16 @@ CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
|
|
|
47
47
|
# Soft cap on open unresolved intents. Refuse NEW billable charges when full.
|
|
48
48
|
_PENDING_MAX_ENTRIES = 1024
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
|
|
50
|
+
# Same-key POST attempts before leaving an intent pending / raising. The API
|
|
51
|
+
# client itself retries 5xx answers and refused or reset connections of these
|
|
52
|
+
# POSTs with its long backoff (0.5 s doubling to 8 s, about 45 s): re-sending
|
|
53
|
+
# is safe because every attempt carries the same Idempotency-Key. These outer
|
|
54
|
+
# attempts cover what the client gives up on quickly (timeouts, other
|
|
55
|
+
# transport errors), with the same backoff, within _RETRY_BUDGET_SECS.
|
|
56
|
+
_POST_ATTEMPTS = 8
|
|
57
|
+
|
|
58
|
+
# Outer retries stop once this much time has passed since the first attempt.
|
|
59
|
+
_RETRY_BUDGET_SECS = 60.0
|
|
52
60
|
|
|
53
61
|
# Local-only dataset collecting one entry per logical charge operation
|
|
54
62
|
# (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
|
|
@@ -160,6 +168,7 @@ class ChargingManager:
|
|
|
160
168
|
_PENDING_MAX_ENTRIES = _PENDING_MAX_ENTRIES
|
|
161
169
|
_IDEMPOTENCY_SAFE_AGE_SECS = CHARGE_IDEMPOTENCY_SAFE_AGE_SECS
|
|
162
170
|
_POST_ATTEMPTS = _POST_ATTEMPTS
|
|
171
|
+
_RETRY_BUDGET_SECS = _RETRY_BUDGET_SECS
|
|
163
172
|
|
|
164
173
|
def __init__(self, config: 'Configuration', client: 'ApiClient'):
|
|
165
174
|
"""Initialize the charging manager.
|
|
@@ -458,12 +467,14 @@ class ChargingManager:
|
|
|
458
467
|
"""
|
|
459
468
|
if self._client is None or not self._is_at_home or not self._actor_run_id:
|
|
460
469
|
return False
|
|
470
|
+
started = time.monotonic()
|
|
461
471
|
for attempt in range(self._POST_ATTEMPTS):
|
|
462
472
|
try:
|
|
463
473
|
await self._client.post(
|
|
464
474
|
f'/v2/actor-runs/{self._actor_run_id}/charge/void',
|
|
465
475
|
json_data={},
|
|
466
476
|
headers={'Idempotency-Key': operation_id},
|
|
477
|
+
idempotent=True,
|
|
467
478
|
)
|
|
468
479
|
self._unwind_reservation(event_name, reserved)
|
|
469
480
|
return True
|
|
@@ -478,8 +489,8 @@ class ChargingManager:
|
|
|
478
489
|
# 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
479
490
|
if status is not None and 400 <= status < 500:
|
|
480
491
|
return False
|
|
481
|
-
if
|
|
482
|
-
|
|
492
|
+
if not await self._backoff_before_retry(attempt, started, err):
|
|
493
|
+
break
|
|
483
494
|
# Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
484
495
|
self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
|
|
485
496
|
logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
|
|
@@ -528,6 +539,7 @@ class ChargingManager:
|
|
|
528
539
|
event_name: str,
|
|
529
540
|
count: int,
|
|
530
541
|
idempotency_key: str,
|
|
542
|
+
deadline: Optional[float] = None,
|
|
531
543
|
) -> Dict[str, Any]:
|
|
532
544
|
if self._client is None:
|
|
533
545
|
raise RuntimeError('ChargingManager has no API client; cannot post charge intent')
|
|
@@ -538,6 +550,8 @@ class ChargingManager:
|
|
|
538
550
|
'count': count,
|
|
539
551
|
},
|
|
540
552
|
headers={'Idempotency-Key': idempotency_key},
|
|
553
|
+
idempotent=True,
|
|
554
|
+
**({'retry_deadline': deadline} if deadline is not None else {}),
|
|
541
555
|
)
|
|
542
556
|
if isinstance(response, dict):
|
|
543
557
|
return response
|
|
@@ -550,25 +564,58 @@ class ChargingManager:
|
|
|
550
564
|
pass
|
|
551
565
|
return {}
|
|
552
566
|
|
|
567
|
+
async def _backoff_before_retry(
|
|
568
|
+
self, attempt: int, started: float, err: BaseException, deadline: Optional[float] = None
|
|
569
|
+
) -> bool:
|
|
570
|
+
"""Sleep before the next same-key attempt; False when no attempt is left.
|
|
571
|
+
|
|
572
|
+
A 5xx answer reaching this point was already retried by the API client
|
|
573
|
+
with its long backoff, so it ends the attempts. Timeouts and other
|
|
574
|
+
errors without an answer back off the same way (0.5 s doubling to 8 s)
|
|
575
|
+
while attempts and the retry budget last.
|
|
576
|
+
"""
|
|
577
|
+
from .storage.api_client import server_retry_delay
|
|
578
|
+
|
|
579
|
+
if attempt + 1 >= self._POST_ATTEMPTS:
|
|
580
|
+
return False
|
|
581
|
+
status = _http_status_of(err)
|
|
582
|
+
if status is not None and status >= 500:
|
|
583
|
+
return False
|
|
584
|
+
delay = server_retry_delay(attempt, None)
|
|
585
|
+
if time.monotonic() - started + delay > self._RETRY_BUDGET_SECS:
|
|
586
|
+
return False
|
|
587
|
+
if deadline is not None and time.monotonic() + delay > deadline:
|
|
588
|
+
return False
|
|
589
|
+
logger.warning('Charge request failed (%s); retrying in %.1fs', type(err).__name__, delay)
|
|
590
|
+
await asyncio.sleep(delay)
|
|
591
|
+
return True
|
|
592
|
+
|
|
553
593
|
async def _post_charge_with_retries(
|
|
554
594
|
self,
|
|
555
595
|
*,
|
|
556
596
|
event_name: str,
|
|
557
597
|
count: int,
|
|
558
598
|
idempotency_key: str,
|
|
599
|
+
deadline: Optional[float] = None,
|
|
559
600
|
) -> Optional[int]:
|
|
560
601
|
"""POST charge; return acceptedCount on success, None if uncertain.
|
|
561
602
|
|
|
603
|
+
``deadline`` (``time.monotonic()``) bounds every retry, the API
|
|
604
|
+
client's included, so a flush with a time budget ends with the intent
|
|
605
|
+
still pending instead of being cancelled mid-request.
|
|
606
|
+
|
|
562
607
|
Raises ChargeRejectedError for definitive platform rejections.
|
|
563
608
|
Same idempotency key on every attempt.
|
|
564
609
|
"""
|
|
565
610
|
last_error: Optional[BaseException] = None
|
|
611
|
+
started = time.monotonic()
|
|
566
612
|
for attempt in range(self._POST_ATTEMPTS):
|
|
567
613
|
try:
|
|
568
614
|
body = await self._post_charge_once(
|
|
569
615
|
event_name=event_name,
|
|
570
616
|
count=count,
|
|
571
617
|
idempotency_key=idempotency_key,
|
|
618
|
+
deadline=deadline,
|
|
572
619
|
)
|
|
573
620
|
return _read_accepted_count(body, count)
|
|
574
621
|
except Exception as e:
|
|
@@ -581,8 +628,8 @@ class ChargingManager:
|
|
|
581
628
|
)
|
|
582
629
|
if rejected is not None:
|
|
583
630
|
raise rejected from e
|
|
584
|
-
if attempt
|
|
585
|
-
|
|
631
|
+
if not await self._backoff_before_retry(attempt, started, e, deadline):
|
|
632
|
+
break
|
|
586
633
|
if last_error is not None:
|
|
587
634
|
logger.warning(
|
|
588
635
|
'Failed to record charge for event %s (count=%s, key=%s): %s',
|
|
@@ -638,6 +685,7 @@ class ChargingManager:
|
|
|
638
685
|
event_name=event_name,
|
|
639
686
|
count=count,
|
|
640
687
|
idempotency_key=idempotency_key,
|
|
688
|
+
deadline=deadline,
|
|
641
689
|
)
|
|
642
690
|
except ChargeRejectedError as err:
|
|
643
691
|
self._unwind_reservation(event_name, count)
|
|
@@ -145,6 +145,8 @@ class Configuration:
|
|
|
145
145
|
'proxy_hostname': 'proxy.scrape.ly',
|
|
146
146
|
'proxy_port': 8000,
|
|
147
147
|
'container_port': 4321,
|
|
148
|
+
# Apify parity: standby runs listen on ACTOR_STANDBY_PORT (4321 unless set).
|
|
149
|
+
'standby_port': 4321,
|
|
148
150
|
'container_url': 'http://localhost:4321',
|
|
149
151
|
'metamorph_after_sleep_millis': 300000,
|
|
150
152
|
'persist_state_interval_millis': 60000,
|
|
@@ -245,6 +247,25 @@ class Configuration:
|
|
|
245
247
|
"""
|
|
246
248
|
self._config[key] = value
|
|
247
249
|
|
|
250
|
+
def __getattr__(self, name: str) -> Any:
|
|
251
|
+
"""Attribute access to any known setting (Apify ``Actor.config.<field>`` parity).
|
|
252
|
+
|
|
253
|
+
``Actor.config.standby_port``, ``Actor.config.meta_origin`` and the other
|
|
254
|
+
keys this class reads from the environment resolve like ``get(name)``.
|
|
255
|
+
"""
|
|
256
|
+
if name.startswith('_'):
|
|
257
|
+
raise AttributeError(name)
|
|
258
|
+
known = name in self.DEFAULTS or name in self.ENV_MAP.values() or name in self.__dict__.get('_config', {})
|
|
259
|
+
if not known:
|
|
260
|
+
raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}')
|
|
261
|
+
return self.get(name)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def standby_port(self) -> int:
|
|
265
|
+
"""Port a standby run serves HTTP on (ACTOR_STANDBY_PORT, default 4321)."""
|
|
266
|
+
value = self.get('standby_port')
|
|
267
|
+
return value if isinstance(value, int) else 4321
|
|
268
|
+
|
|
248
269
|
@property
|
|
249
270
|
def token(self) -> Optional[str]:
|
|
250
271
|
"""Get the authentication token."""
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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'):
|