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.

Files changed (64) hide show
  1. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/actor.py +59 -17
  5. scrapely_python-0.1.7/src/scrapely/cdp_retry.py +226 -0
  6. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/charging.py +54 -6
  7. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/configuration.py +21 -0
  8. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/patch_browser_pool.py +14 -3
  9. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/proxy_configuration.py +47 -0
  10. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/api_client.py +20 -3
  11. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/client.py +6 -1
  12. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  13. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/SOURCES.txt +6 -1
  14. scrapely_python-0.1.7/tests/test_cdp_retry.py +169 -0
  15. scrapely_python-0.1.7/tests/test_charge_retries.py +141 -0
  16. scrapely_python-0.1.7/tests/test_input_proxy_aliases.py +98 -0
  17. scrapely_python-0.1.7/tests/test_terminal_status_message.py +130 -0
  18. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/README.md +0 -0
  19. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/setup.cfg +0 -0
  20. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/_browser_locations.py +0 -0
  21. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/browser_configuration.py +0 -0
  22. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/charging_client.py +0 -0
  23. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/crypto/__init__.py +0 -0
  24. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/crypto/_secrets.py +0 -0
  25. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/errors.py +0 -0
  26. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/__init__.py +0 -0
  27. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/_types.py +0 -0
  28. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/events/event_manager.py +0 -0
  29. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/log.py +0 -0
  30. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/pricing_types.py +0 -0
  31. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/py.typed +0 -0
  32. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/request_loaders/__init__.py +0 -0
  33. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  34. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/__init__.py +0 -0
  35. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/__init__.py +0 -0
  36. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  37. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  38. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/_metadata.py +0 -0
  39. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/patch_storage.py +0 -0
  40. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  41. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  52. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/requires.txt +0 -0
  53. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/top_level.txt +0 -0
  54. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_api_client_retries.py +0 -0
  55. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_apify_parity.py +0 -0
  56. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_browser_configuration.py +0 -0
  57. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_charging_parity.py +0 -0
  58. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_charging_pending.py +0 -0
  59. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_parity.py +0 -0
  60. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_patch_browser_pool.py +0 -0
  61. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_proxy_configuration.py +0 -0
  62. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_run_control.py +0 -0
  63. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/tests/test_secrets.py +0 -0
  64. {scrapely_python-0.1.5 → scrapely_python-0.1.7}/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.7
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.7"
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.7'
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
@@ -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], metamorph_input)
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
- cls._cached_input = parsed
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]], result)
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 ActorRun(id='', actor_id='', status='')
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
- 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
- )
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
- # Immediate same-key POST attempts before leaving intent pending / raising.
51
- _POST_ATTEMPTS = 3
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 attempt + 1 < self._POST_ATTEMPTS:
482
- await asyncio.sleep(0.25 * (attempt + 1))
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 + 1 < self._POST_ATTEMPTS:
585
- await asyncio.sleep(0.25 * (attempt + 1))
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
- 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'):