scrapely-python 0.1.6__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.6 → scrapely_python-0.1.7}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/actor.py +16 -7
  5. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/charging.py +54 -6
  6. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/configuration.py +21 -0
  7. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/proxy_configuration.py +47 -0
  8. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/api_client.py +20 -3
  9. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/client.py +6 -1
  10. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  11. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/SOURCES.txt +2 -0
  12. scrapely_python-0.1.7/tests/test_charge_retries.py +141 -0
  13. scrapely_python-0.1.7/tests/test_input_proxy_aliases.py +98 -0
  14. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/README.md +0 -0
  15. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/setup.cfg +0 -0
  16. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/_browser_locations.py +0 -0
  17. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/browser_configuration.py +0 -0
  18. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/cdp_retry.py +0 -0
  19. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/charging_client.py +0 -0
  20. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/crypto/__init__.py +0 -0
  21. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/crypto/_secrets.py +0 -0
  22. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/errors.py +0 -0
  23. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/__init__.py +0 -0
  24. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/_types.py +0 -0
  25. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/event_manager.py +0 -0
  26. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/log.py +0 -0
  27. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/patch_browser_pool.py +0 -0
  28. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/pricing_types.py +0 -0
  29. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/py.typed +0 -0
  30. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/request_loaders/__init__.py +0 -0
  31. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  32. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/__init__.py +0 -0
  33. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/__init__.py +0 -0
  34. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  35. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  36. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_metadata.py +0 -0
  37. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/patch_storage.py +0 -0
  38. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  39. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  40. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  41. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  42. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  43. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/types.py +0 -0
  44. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/utils.py +0 -0
  45. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage_clients/__init__.py +0 -0
  46. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storages/__init__.py +0 -0
  47. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/types.py +0 -0
  48. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/webhook.py +0 -0
  49. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  50. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/requires.txt +0 -0
  51. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/top_level.txt +0 -0
  52. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_api_client_retries.py +0 -0
  53. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_apify_parity.py +0 -0
  54. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_browser_configuration.py +0 -0
  55. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_cdp_retry.py +0 -0
  56. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_charging_parity.py +0 -0
  57. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_charging_pending.py +0 -0
  58. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_parity.py +0 -0
  59. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_patch_browser_pool.py +0 -0
  60. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_proxy_configuration.py +0 -0
  61. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_run_control.py +0 -0
  62. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_secrets.py +0 -0
  63. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_storage.py +0 -0
  64. {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_terminal_status_message.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.6
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.6"
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.6'
135
+ __version__ = '0.1.7'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -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
@@ -484,8 +485,10 @@ class Actor(metaclass=_ActorMeta):
484
485
  flush = getattr(cls._charging_manager, 'flush_pending', None)
485
486
  if flush is not None:
486
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.
487
490
  await asyncio.wait_for(
488
- flush(max_intents=64, deadline_ms=deadline_ms),
491
+ flush(max_intents=64, deadline_ms=max(500, deadline_ms - 2_000)),
489
492
  timeout=max(1.0, deadline_ms / 1000.0),
490
493
  )
491
494
  except BaseException as e:
@@ -687,8 +690,8 @@ class Actor(metaclass=_ActorMeta):
687
690
  kvs = await cls.open_key_value_store()
688
691
  metamorph_input = await kvs.get_value(f'INPUT-METAMORPH-{count}')
689
692
  if metamorph_input is not None:
690
- cls._cached_input = metamorph_input
691
- 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)
692
695
 
693
696
  raw_input = os.environ.get('ACTOR_INPUT')
694
697
  if raw_input:
@@ -701,7 +704,8 @@ class Actor(metaclass=_ActorMeta):
701
704
  # path is decrypted, mirroring Actor.getInput() in the Node SDK)
702
705
  if isinstance(parsed, dict):
703
706
  parsed = decrypt_secret_inputs(parsed)
704
- cls._cached_input = parsed
707
+ # Proxy objects carry both namings (scrapelyProxy* and apifyProxy*).
708
+ cls._cached_input = with_proxy_key_aliases(parsed)
705
709
  return cast(Dict[str, Any], cls._cached_input)
706
710
 
707
711
  kvs = await cls.open_key_value_store()
@@ -709,8 +713,8 @@ class Actor(metaclass=_ActorMeta):
709
713
  result = await kvs.get_value(input_key)
710
714
  if isinstance(result, dict):
711
715
  result = decrypt_secret_inputs(result)
712
- cls._cached_input = result
713
- 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)
714
718
 
715
719
  @classmethod
716
720
  async def get_input_or_throw(cls) -> Dict[str, Any]:
@@ -739,7 +743,7 @@ class Actor(metaclass=_ActorMeta):
739
743
 
740
744
  # Decrypt secret inputs after loading (JS parity with Actor.input getter)
741
745
  if isinstance(cls._cached_input, dict):
742
- cls._cached_input = decrypt_secret_inputs(cls._cached_input)
746
+ cls._cached_input = with_proxy_key_aliases(decrypt_secret_inputs(cls._cached_input))
743
747
 
744
748
  return cls._cached_input
745
749
 
@@ -1172,6 +1176,11 @@ class Actor(metaclass=_ActorMeta):
1172
1176
  """Return the Configuration instance."""
1173
1177
  return cls.get_config()
1174
1178
 
1179
+ @_ClassProperty
1180
+ def config(cls) -> Configuration:
1181
+ """Return the Configuration instance (Apify SDK ``Actor.config`` parity)."""
1182
+ return cls.get_config()
1183
+
1175
1184
  @_ClassProperty
1176
1185
  def event_manager(cls) -> Optional[EventManager]:
1177
1186
  """Return the EventManager instance."""
@@ -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."""
@@ -127,6 +127,53 @@ def _input_proxy_option(actor_proxy_input: Dict[str, Any], name: str) -> Any:
127
127
  return value if value is not None else actor_proxy_input.get(f'apifyProxy{name}')
128
128
 
129
129
 
130
+ _PROXY_OPTION_NAMES = ('Groups', 'Country', 'Subdivision')
131
+
132
+
133
+ def _is_proxy_input_object(value: Any) -> bool:
134
+ if not isinstance(value, dict):
135
+ return False
136
+ keys = ['useScrapelyProxy', 'useApifyProxy']
137
+ for name in _PROXY_OPTION_NAMES:
138
+ keys += [f'scrapelyProxy{name}', f'apifyProxy{name}']
139
+ return any(key in value for key in keys)
140
+
141
+
142
+ def with_proxy_key_aliases(actor_input: Any) -> Any:
143
+ """Actor input with every proxy option under both its Scrapely and Apify name.
144
+
145
+ For each top-level object that has a proxy key of either naming
146
+ (``useScrapelyProxy`` / ``useApifyProxy``, ``scrapelyProxyGroups`` /
147
+ ``apifyProxyGroups``, ``...Country``, ``...Subdivision``), every set value
148
+ is written under both names, so code that reads either naming sees the
149
+ user's choice. A boolean ``useScrapelyProxy`` and a set ``scrapelyProxy*``
150
+ value win over their Apify counterparts. Nothing is removed: keys with
151
+ unset or non-boolean values stay as sent. Returns ``actor_input`` itself
152
+ when nothing changes; never mutates it.
153
+ """
154
+ if not isinstance(actor_input, dict):
155
+ return actor_input
156
+ result: Optional[Dict[str, Any]] = None
157
+ for key, value in actor_input.items():
158
+ if not _is_proxy_input_object(value):
159
+ continue
160
+ aliased = dict(value)
161
+ use = _input_use_platform_proxy(value)
162
+ if use is not None:
163
+ aliased['useScrapelyProxy'] = use
164
+ aliased['useApifyProxy'] = use
165
+ for name in _PROXY_OPTION_NAMES:
166
+ option = _input_proxy_option(value, name)
167
+ if option is not None:
168
+ aliased[f'scrapelyProxy{name}'] = option
169
+ aliased[f'apifyProxy{name}'] = option
170
+ if aliased != value:
171
+ if result is None:
172
+ result = dict(actor_input)
173
+ result[key] = aliased
174
+ return result if result is not None else actor_input
175
+
176
+
130
177
  def _platform_env() -> Optional[Dict[str, Any]]:
131
178
  """Read platform proxy credentials from the environment (or None)."""
132
179
  hostname = os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME')
@@ -258,8 +258,15 @@ class ApiClient:
258
258
  raise_for_status: bool = True,
259
259
  do_not_retry_timeouts: bool = False,
260
260
  idempotent: Optional[bool] = None,
261
+ retry_deadline: Optional[float] = None,
261
262
  ) -> httpx.Response:
262
- """Make an HTTP request with adaptive rate limiting and 429 retries."""
263
+ """Make an HTTP request with adaptive rate limiting and 429 retries.
264
+
265
+ ``retry_deadline`` (a ``time.monotonic()`` value) stops the 5xx /
266
+ connection retries once the next backoff would end after it, so a
267
+ caller with a time budget (e.g. the exit flush) gets an answer instead
268
+ of being cancelled mid-retry.
269
+ """
263
270
  client = await self._ensure_client()
264
271
  url = self._url(path)
265
272
  logger.debug('%s %s params=%s', method, url, params)
@@ -293,8 +300,12 @@ class ApiClient:
293
300
  # No response. A refused/unresolvable connection never reached
294
301
  # the API, so it is safe for every method; anything else (reset
295
302
  # mid-request) only for idempotent ones.
296
- if (idempotent or isinstance(exc, httpx.ConnectError)) and server_attempt < self._max_server_retries:
297
- wait = server_retry_delay(server_attempt, None)
303
+ wait = server_retry_delay(server_attempt, None)
304
+ if (
305
+ (idempotent or isinstance(exc, httpx.ConnectError))
306
+ and server_attempt < self._max_server_retries
307
+ and (retry_deadline is None or time.monotonic() + wait <= retry_deadline)
308
+ ):
298
309
  server_attempt += 1
299
310
  logger.warning(
300
311
  '%s %s failed (%s); retry %d/%d in %.1fs',
@@ -316,6 +327,10 @@ class ApiClient:
316
327
  if (
317
328
  not response.is_success
318
329
  and server_attempt < self._max_server_retries
330
+ and (
331
+ retry_deadline is None
332
+ or time.monotonic() + server_retry_delay(server_attempt, response.headers.get('Retry-After')) <= retry_deadline
333
+ )
319
334
  and is_retryable_response(
320
335
  response.status_code,
321
336
  idempotent,
@@ -364,6 +379,7 @@ class ApiClient:
364
379
  *,
365
380
  raise_for_status: bool = True,
366
381
  idempotent: Optional[bool] = None,
382
+ retry_deadline: Optional[float] = None,
367
383
  ) -> httpx.Response:
368
384
  return await self.request(
369
385
  'POST',
@@ -374,6 +390,7 @@ class ApiClient:
374
390
  headers=headers,
375
391
  raise_for_status=raise_for_status,
376
392
  idempotent=idempotent,
393
+ retry_deadline=retry_deadline,
377
394
  )
378
395
 
379
396
  async def put(
@@ -293,8 +293,13 @@ class ApiStorageClient:
293
293
  value: Any,
294
294
  content_type: Optional[str] = None,
295
295
  ) -> None:
296
+ if isinstance(value, (bytearray, memoryview)):
297
+ value = bytes(value)
296
298
  if content_type is None:
297
- if isinstance(value, (str, bytes)):
299
+ # Same defaults as Apify: bytes are binary, str is text, anything else JSON.
300
+ if isinstance(value, bytes):
301
+ content_type = 'application/octet-stream'
302
+ elif isinstance(value, str):
298
303
  content_type = 'text/plain'
299
304
  else:
300
305
  content_type = 'application/json'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.6
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
@@ -49,8 +49,10 @@ tests/test_api_client_retries.py
49
49
  tests/test_apify_parity.py
50
50
  tests/test_browser_configuration.py
51
51
  tests/test_cdp_retry.py
52
+ tests/test_charge_retries.py
52
53
  tests/test_charging_parity.py
53
54
  tests/test_charging_pending.py
55
+ tests/test_input_proxy_aliases.py
54
56
  tests/test_parity.py
55
57
  tests/test_patch_browser_pool.py
56
58
  tests/test_proxy_configuration.py
@@ -0,0 +1,141 @@
1
+ """Charge and void POSTs ride out 5xx answers and timeouts with the long backoff.
2
+
3
+ Staging 2026-10-14: about 3 of 40 pay-per-event runs logged "Failed to record
4
+ charge" three times within 1.5 s and raised ChargeUncertainError, because the
5
+ charge POST retried 3 times with 0.25 s / 0.5 s sleeps and the API client did
6
+ not retry 500/502/504 for POST. Every attempt carries the same
7
+ Idempotency-Key, so re-sending is safe: the client now retries any 5xx (and
8
+ reset connections) with its 0.5 s → 8 s backoff, and timeouts get the same
9
+ backoff from the charging manager.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import Decimal
15
+ from typing import List
16
+ from unittest.mock import MagicMock
17
+
18
+ import httpx
19
+ import pytest
20
+
21
+ from scrapely import charging as charging_mod
22
+ from scrapely.charging import ChargeRejectedError, ChargingManager
23
+ from scrapely.pricing_types import PayPerEventActorPricingInfo, PricingPerEvent
24
+ from scrapely.storage import api_client as client_mod
25
+ from scrapely.storage.api_client import ApiClient
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
30
+ slept: List[float] = []
31
+
32
+ async def fake_sleep(secs: float) -> None:
33
+ slept.append(secs)
34
+
35
+ monkeypatch.setattr(client_mod.asyncio, 'sleep', fake_sleep)
36
+ monkeypatch.setattr(charging_mod.asyncio, 'sleep', fake_sleep)
37
+ return slept
38
+
39
+
40
+ def manager_for(responses: list) -> tuple[ChargingManager, list]:
41
+ calls: list = []
42
+
43
+ def handler(request: httpx.Request) -> httpx.Response:
44
+ calls.append((request.url.path, request.headers.get('Idempotency-Key')))
45
+ r = responses.pop(0) if len(responses) > 1 else responses[0]
46
+ if isinstance(r, Exception):
47
+ raise r
48
+ return r
49
+
50
+ client = ApiClient('http://api.test', 'st_x', 'run-1', transport=httpx.MockTransport(handler))
51
+ config = MagicMock()
52
+ config.get = MagicMock(side_effect=lambda key, default=None: {
53
+ 'is_at_home': True, 'actor_run_id': 'run-1', 'max_total_charge_usd': '100',
54
+ }.get(key, default))
55
+ mgr = ChargingManager(config, client)
56
+ mgr._pricing_info = PayPerEventActorPricingInfo(pricing_per_event={'page-open': PricingPerEvent(event_price_usd=Decimal('0.01'))})
57
+ mgr._per_event_prices = {'page-open': Decimal('0.01')}
58
+ mgr._initialized = True
59
+ mgr._actor_run_id = 'run-1'
60
+ mgr._max_total_charge_usd = Decimal('100')
61
+ return mgr, calls
62
+
63
+
64
+ def ok(count: int = 1) -> httpx.Response:
65
+ return httpx.Response(201, json={'acceptedCount': count, 'requestedCount': count})
66
+
67
+
68
+ @pytest.mark.parametrize('status', [500, 502, 503, 504])
69
+ async def test_charge_5xx_is_retried_with_the_same_key(status: int, no_sleep: List[float]) -> None:
70
+ mgr, calls = manager_for([httpx.Response(status, json={'error': {'type': 'internal-error'}})] * 5 + [ok(2)])
71
+ result = await mgr.charge('page-open', 2)
72
+ assert result.charged_count == 2
73
+ assert len(calls) == 6 and len({key for _, key in calls}) == 1, 'one operation, one Idempotency-Key'
74
+ assert sum(no_sleep) > 7, f'long backoff, not 1.5 s: {no_sleep}'
75
+ assert not mgr._pending_charge_keys
76
+
77
+
78
+ async def test_charge_timeouts_back_off_until_confirmed(no_sleep: List[float]) -> None:
79
+ timeout = httpx.ReadTimeout('timed out')
80
+ mgr, calls = manager_for([timeout] * 9 + [ok()])
81
+ result = await mgr.charge('page-open', 1)
82
+ assert result.charged_count == 1
83
+ assert len({key for _, key in calls}) == 1
84
+ assert any(s >= 1.0 for s in no_sleep), f'timeouts get the growing backoff too: {no_sleep}'
85
+
86
+
87
+ async def test_charge_reset_connection_is_retried() -> None:
88
+ mgr, calls = manager_for([httpx.RemoteProtocolError('reset'), ok()])
89
+ assert (await mgr.charge('page-open', 1)).charged_count == 1
90
+ assert len(calls) == 2
91
+
92
+
93
+ async def test_rejection_is_not_retried() -> None:
94
+ mgr, calls = manager_for([httpx.Response(402, json={'error': {'type': 'charge-limit-exceeded', 'message': 'limit'}})])
95
+ with pytest.raises(ChargeRejectedError):
96
+ await mgr._post_charge_with_retries(event_name='page-open', count=1, idempotency_key='k1')
97
+ assert len(calls) == 1
98
+
99
+
100
+ async def test_retries_stop_within_the_budget(no_sleep: List[float]) -> None:
101
+ mgr, calls = manager_for([httpx.ReadTimeout('timed out')])
102
+ assert await mgr._post_charge_with_retries(event_name='page-open', count=1, idempotency_key='k1') is None
103
+ assert sum(s for s in no_sleep if s >= 0.5) <= ChargingManager._RETRY_BUDGET_SECS + 1
104
+ assert len({key for _, key in calls}) == 1
105
+
106
+
107
+ async def test_void_5xx_is_retried() -> None:
108
+ mgr, calls = manager_for([httpx.Response(503, json={'error': {'type': 'service-unavailable'}}), httpx.Response(200, json={})])
109
+ assert await mgr._void_charge('page-open', 'op-1', 1) is True
110
+ assert [path for path, _ in calls] == ['/v2/actor-runs/run-1/charge/void'] * 2
111
+
112
+
113
+ async def test_flush_deadline_bounds_the_client_retries(no_sleep: List[float]) -> None:
114
+ """A flush with a time budget stops retrying in time; the intent stays pending."""
115
+ import time as _time
116
+
117
+ mgr, calls = manager_for([httpx.Response(503, json={'error': {'type': 'service-unavailable'}})])
118
+ mgr._pending_charge_keys['k1'] = {'event_name': 'page-open', 'count': 1, 'created_at': _time.monotonic()}
119
+ clock = [1000.0]
120
+
121
+ def fake_monotonic() -> float:
122
+ return clock[0]
123
+
124
+ async def advancing_sleep(secs: float) -> None:
125
+ no_sleep.append(secs)
126
+ clock[0] += secs
127
+
128
+ import pytest as _pytest
129
+ mp = _pytest.MonkeyPatch()
130
+ try:
131
+ mp.setattr(client_mod.time, 'monotonic', fake_monotonic)
132
+ mp.setattr(charging_mod.time, 'monotonic', fake_monotonic)
133
+ mp.setattr(client_mod.asyncio, 'sleep', advancing_sleep)
134
+ mp.setattr(charging_mod.asyncio, 'sleep', advancing_sleep)
135
+ confirmed = await mgr.flush_pending(deadline_ms=3000)
136
+ finally:
137
+ mp.undo()
138
+ assert confirmed == 0
139
+ assert 'k1' in mgr._pending_charge_keys, 'still pending for a later flush'
140
+ assert sum(no_sleep) <= 3.0, f'no retry sleeps past the deadline: {no_sleep}'
141
+ assert len(calls) >= 2, 'it did retry within the budget'
@@ -0,0 +1,98 @@
1
+ """Proxy input objects carry both namings; Actor.config exposes standby_port.
2
+
3
+ Actors read the proxy choice either as useScrapelyProxy / scrapelyProxyGroups /
4
+ scrapelyProxyCountry / scrapelyProxySubdivision or under the Apify names.
5
+ Actor.get_input() and Actor.input fill in whichever naming is missing, so both
6
+ reads see the user's choice whatever naming the input was written with.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ from typing import Any
14
+
15
+ import pytest
16
+
17
+ from scrapely import Actor
18
+ from scrapely.configuration import Configuration
19
+ from scrapely.proxy_configuration import with_proxy_key_aliases
20
+
21
+
22
+ BOTH_RESIDENTIAL_CN = {
23
+ 'useScrapelyProxy': True, 'useApifyProxy': True,
24
+ 'scrapelyProxyGroups': ['RESIDENTIAL'], 'apifyProxyGroups': ['RESIDENTIAL'],
25
+ 'scrapelyProxyCountry': 'CN', 'apifyProxyCountry': 'CN',
26
+ }
27
+
28
+
29
+ def test_scrapely_names_get_apify_aliases() -> None:
30
+ sent = {'proxy': {'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'CN'}, 'q': 'x'}
31
+ out = with_proxy_key_aliases(sent)
32
+ assert out == {'proxy': BOTH_RESIDENTIAL_CN, 'q': 'x'}
33
+ assert sent['proxy'] == {'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'CN'}, 'not mutated'
34
+
35
+
36
+ def test_apify_names_get_scrapely_aliases() -> None:
37
+ out = with_proxy_key_aliases({'proxy': {'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'CN'}})
38
+ assert out == {'proxy': BOTH_RESIDENTIAL_CN}
39
+
40
+
41
+ def test_proxy_off_and_custom_urls() -> None:
42
+ assert with_proxy_key_aliases({'p': {'useScrapelyProxy': False}}) == {'p': {'useScrapelyProxy': False, 'useApifyProxy': False}}
43
+ out = with_proxy_key_aliases({'p': {'useApifyProxy': False, 'proxyUrls': ['http://u:p@1.2.3.4:8080']}})
44
+ assert out == {'p': {'useScrapelyProxy': False, 'useApifyProxy': False, 'proxyUrls': ['http://u:p@1.2.3.4:8080']}}
45
+
46
+
47
+ def test_scrapely_value_wins_and_unset_values_stay() -> None:
48
+ out = with_proxy_key_aliases({'p': {
49
+ 'useScrapelyProxy': False, 'useApifyProxy': True,
50
+ 'scrapelyProxyGroups': None, 'apifyProxyGroups': ['DATACENTER'],
51
+ 'scrapelyProxyCountry': 'DE', 'apifyProxyCountry': 'US', 'apifyProxySubdivision': None,
52
+ }})
53
+ assert out == {'p': {
54
+ 'useScrapelyProxy': False, 'useApifyProxy': False,
55
+ 'scrapelyProxyGroups': ['DATACENTER'], 'apifyProxyGroups': ['DATACENTER'],
56
+ 'scrapelyProxyCountry': 'DE', 'apifyProxyCountry': 'DE', 'apifyProxySubdivision': None,
57
+ }}
58
+
59
+
60
+ def test_unrelated_fields_are_never_removed() -> None:
61
+ sent = {'settings': {'useApifyProxy': 'false', 'label': 'x'}}
62
+ assert with_proxy_key_aliases(sent) is sent, 'a non-boolean switch is kept as sent'
63
+
64
+
65
+ @pytest.mark.parametrize('value', [None, 'x', 1, [], [{'useScrapelyProxy': True}], {'a': 1}, {'p': {'x': 1}}])
66
+ def test_other_values_unchanged(value: Any) -> None:
67
+ assert with_proxy_key_aliases(value) is value
68
+
69
+
70
+ def test_complete_input_is_returned_as_is() -> None:
71
+ sent = {'p': dict(BOTH_RESIDENTIAL_CN)}
72
+ assert with_proxy_key_aliases(sent) is sent
73
+
74
+
75
+ async def test_get_input_and_input_getter_alias(monkeypatch: pytest.MonkeyPatch) -> None:
76
+ raw = {'proxyConfiguration': {'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'CN'}}
77
+ monkeypatch.setenv('ACTOR_INPUT', base64.b64encode(json.dumps(raw).encode()).decode())
78
+ monkeypatch.delenv('ACTOR_METAMORPH_COUNT', raising=False)
79
+ monkeypatch.setattr(Actor, '_cached_input', None)
80
+ monkeypatch.setattr(Actor, '_initialized', True, raising=False)
81
+ monkeypatch.setattr(Actor, '_ensure_initialized', classmethod(lambda cls, name: None))
82
+ got = await Actor.get_input()
83
+ assert got['proxyConfiguration'] == BOTH_RESIDENTIAL_CN
84
+ monkeypatch.setattr(Actor, '_cached_input', None)
85
+ assert Actor.input['proxyConfiguration'] == BOTH_RESIDENTIAL_CN
86
+
87
+
88
+ def test_config_standby_port(monkeypatch: pytest.MonkeyPatch) -> None:
89
+ monkeypatch.delenv('ACTOR_STANDBY_PORT', raising=False)
90
+ assert Configuration().standby_port == 4321
91
+ monkeypatch.setenv('ACTOR_STANDBY_PORT', '5000')
92
+ monkeypatch.setenv('ACTOR_META_ORIGIN', 'STANDBY')
93
+ config = Configuration()
94
+ assert config.standby_port == 5000
95
+ assert config.meta_origin == 'STANDBY', 'known settings are attributes too'
96
+ with pytest.raises(AttributeError):
97
+ config.not_a_setting # noqa: B018
98
+ assert Actor.config is Actor.configuration