scrapely-python 0.1.11__tar.gz → 0.1.13__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.
Files changed (68) hide show
  1. {scrapely_python-0.1.11/src/scrapely_python.egg-info → scrapely_python-0.1.13}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/actor.py +14 -5
  5. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/charging.py +58 -25
  6. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/configuration.py +16 -0
  7. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/api_client.py +137 -99
  8. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/client.py +9 -1
  9. {scrapely_python-0.1.11 → scrapely_python-0.1.13/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  10. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_api_client_retries.py +80 -9
  11. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_apify_parity.py +50 -0
  12. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charge_retries.py +74 -20
  13. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_parity.py +6 -1
  14. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_storage.py +1 -1
  15. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_terminal_status_message.py +1 -1
  16. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/LICENSE +0 -0
  17. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/LICENSE-APACHE +0 -0
  18. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/NOTICE +0 -0
  19. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/README.md +0 -0
  20. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/setup.cfg +0 -0
  21. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/_browser_locations.py +0 -0
  22. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/browser_configuration.py +0 -0
  23. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/cdp_retry.py +0 -0
  24. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/charging_client.py +0 -0
  25. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/crypto/__init__.py +0 -0
  26. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/crypto/_secrets.py +0 -0
  27. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/errors.py +0 -0
  28. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/__init__.py +0 -0
  29. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/_types.py +0 -0
  30. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/event_manager.py +0 -0
  31. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/log.py +0 -0
  32. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/patch_browser_pool.py +0 -0
  33. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/pricing_types.py +0 -0
  34. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/proxy_configuration.py +0 -0
  35. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/py.typed +0 -0
  36. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/request_loaders/__init__.py +0 -0
  37. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  38. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/__init__.py +0 -0
  39. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/__init__.py +0 -0
  40. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  41. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  42. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_metadata.py +0 -0
  43. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/patch_storage.py +0 -0
  44. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  45. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  46. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  47. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  48. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  49. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/types.py +0 -0
  50. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/utils.py +0 -0
  51. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage_clients/__init__.py +0 -0
  52. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storages/__init__.py +0 -0
  53. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/types.py +0 -0
  54. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/webhook.py +0 -0
  55. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  56. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  57. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/requires.txt +0 -0
  58. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/top_level.txt +0 -0
  59. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_browser_configuration.py +0 -0
  60. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_cdp_retry.py +0 -0
  61. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charge_limit_guard.py +0 -0
  62. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charging_parity.py +0 -0
  63. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charging_pending.py +0 -0
  64. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_input_proxy_aliases.py +0 -0
  65. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_patch_browser_pool.py +0 -0
  66. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_proxy_configuration.py +0 -0
  67. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_run_control.py +0 -0
  68. {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_secrets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.11
3
+ Version: 0.1.13
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 AND Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.11"
7
+ version = "0.1.13"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT AND Apache-2.0"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.11'
135
+ __version__ = '0.1.13'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -491,7 +491,7 @@ class Actor(metaclass=_ActorMeta):
491
491
  if flush is not None:
492
492
  deadline_ms = min(max(1, timeout_secs) * 1000, 30_000)
493
493
  # The flush's own deadline ends its retries (charge POSTs back off
494
- # for up to ~45 s) a little before wait_for would cancel it.
494
+ # for up to ~2 min) a little before wait_for would cancel it.
495
495
  await asyncio.wait_for(
496
496
  flush(max_intents=64, deadline_ms=max(500, deadline_ms - 2_000)),
497
497
  timeout=max(1.0, deadline_ms / 1000.0),
@@ -652,12 +652,12 @@ class Actor(metaclass=_ActorMeta):
652
652
 
653
653
  @classmethod
654
654
  def is_at_home(cls) -> bool:
655
- return cls._config.get('is_at_home', False) if cls._config else False
655
+ return bool(cls._env_config().get('is_at_home', False))
656
656
 
657
657
  @classmethod
658
658
  def is_standby(cls) -> bool:
659
659
  """Return True when the actor run was started in standby mode (Node SDK ``isStandby`` parity)."""
660
- return (cls._config.get('meta_origin') == 'STANDBY') if cls._config else False
660
+ return cls._env_config().get('meta_origin') == 'STANDBY'
661
661
 
662
662
  @_ClassProperty
663
663
  def is_paying(cls) -> bool:
@@ -721,7 +721,7 @@ class Actor(metaclass=_ActorMeta):
721
721
  return cast(Dict[str, Any], cls._cached_input)
722
722
 
723
723
  kvs = await cls.open_key_value_store()
724
- input_key = cls._config.get('input_key', 'INPUT') if cls._config else 'INPUT'
724
+ input_key = cls._env_config().get('input_key', 'INPUT')
725
725
  result = await kvs.get_value(input_key)
726
726
  if isinstance(result, dict):
727
727
  result = decrypt_secret_inputs(result)
@@ -848,7 +848,7 @@ class Actor(metaclass=_ActorMeta):
848
848
  name = name_or_id
849
849
 
850
850
  if alias is not None:
851
- storages_json = cls._config.get('actor_storages_json') if cls._config else None
851
+ storages_json = cls._env_config().get('actor_storages_json')
852
852
  resolved = resolve_alias_from_storages_json(alias, storage_type, storages_json)
853
853
  if resolved is not None:
854
854
  id = resolved
@@ -1067,12 +1067,15 @@ class Actor(metaclass=_ActorMeta):
1067
1067
  return None
1068
1068
 
1069
1069
  client = cls.new_client(token=token, base_url=api_url)
1070
+ # Setting the message is idempotent: a 429 (run API rate limit), 5xx or
1071
+ # dropped connection is retried with backoff instead of failing the run.
1070
1072
  response = await client.put(
1071
1073
  f'/v2/actor-runs/{run_id}',
1072
1074
  json_data={
1073
1075
  'statusMessage': message,
1074
1076
  'isStatusMessageTerminal': is_terminal,
1075
1077
  },
1078
+ idempotent=True,
1076
1079
  )
1077
1080
  body: Any = {}
1078
1081
  if response.content:
@@ -1126,6 +1129,12 @@ class Actor(metaclass=_ActorMeta):
1126
1129
  cls._config = Configuration.get_global_config()
1127
1130
  return cls._config
1128
1131
 
1132
+ @classmethod
1133
+ def _env_config(cls) -> Configuration:
1134
+ # Apify parity: is_at_home() and friends read the environment even
1135
+ # before init(). Doesn't set cls._config, so init() state is unchanged.
1136
+ return cls._config if cls._config is not None else Configuration.get_global_config()
1137
+
1129
1138
  @classmethod
1130
1139
  def new_client(cls, token: Optional[str] = None, base_url: Optional[str] = None) -> ApiClient:
1131
1140
  config = cls.get_config()
@@ -49,16 +49,26 @@ CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
49
49
  # Soft cap on open unresolved intents. Refuse NEW billable charges when full.
50
50
  _PENDING_MAX_ENTRIES = 1024
51
51
 
52
- # Same-key POST attempts before leaving an intent pending / raising. The API
53
- # client itself retries 5xx answers and refused or reset connections of these
54
- # POSTs with its long backoff (0.5 s doubling to 8 s, about 45 s): re-sending
52
+ # Same-key POST attempts before leaving an intent pending / re-queueing a void.
53
+ # The API client itself retries 429 and 5xx answers and refused or reset
54
+ # connections of these POSTs with exponential backoff (honouring Retry-After)
55
+ # for its retry window (2 minutes, SCRAPELY_API_RETRY_WINDOW_SECS): re-sending
55
56
  # is safe because every attempt carries the same Idempotency-Key. These outer
56
57
  # attempts cover what the client gives up on quickly (timeouts, other
57
- # transport errors), with the same backoff, within _RETRY_BUDGET_SECS.
58
+ # transport errors), within _RETRY_BUDGET_SECS. Then the charge stays pending
59
+ # (the void is re-queued) and is retried before the next push and at exit,
60
+ # the same as the JS SDK.
58
61
  _POST_ATTEMPTS = 8
59
62
 
60
- # Outer retries stop once this much time has passed since the first attempt.
61
- _RETRY_BUDGET_SECS = 60.0
63
+ # Outer retries stop once this much time has passed since the first attempt
64
+ # (the API retry window; JS SDK parity).
65
+ _RETRY_BUDGET_SECS = 120.0
66
+
67
+
68
+ def _is_transient_status(status: Optional[int]) -> bool:
69
+ """408/429 and 5xx: the platform could not take the request now; try again later."""
70
+ return status is not None and (status in (408, 429) or status >= 500)
71
+
62
72
 
63
73
  # Local-only dataset collecting one entry per logical charge operation
64
74
  # (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
@@ -563,7 +573,12 @@ class ChargingManager:
563
573
  return await self._void_charge(event_name, operation_id, reserved, not_found_means_voided=True)
564
574
 
565
575
  async def _void_charge(
566
- self, event_name: str, operation_id: str, reserved: int, not_found_means_voided: bool = False
576
+ self,
577
+ event_name: str,
578
+ operation_id: str,
579
+ reserved: int,
580
+ not_found_means_voided: bool = False,
581
+ deadline: Optional[float] = None,
567
582
  ) -> bool:
568
583
  """Reverse an accepted charge whose items were never stored, then unwind locally.
569
584
 
@@ -573,6 +588,8 @@ class ChargingManager:
573
588
  if self._client is None or not self._is_at_home or not self._actor_run_id:
574
589
  return False
575
590
  started = time.monotonic()
591
+ budget_end = started + self._RETRY_BUDGET_SECS
592
+ deadline = budget_end if deadline is None else min(deadline, budget_end)
576
593
  for attempt in range(self._POST_ATTEMPTS):
577
594
  try:
578
595
  await self._client.post(
@@ -580,6 +597,7 @@ class ChargingManager:
580
597
  json_data={},
581
598
  headers={'Idempotency-Key': operation_id},
582
599
  idempotent=True,
600
+ retry_deadline=deadline,
583
601
  )
584
602
  self._unwind_reservation(event_name, reserved)
585
603
  return True
@@ -592,24 +610,37 @@ class ChargingManager:
592
610
  self._unwind_reservation(event_name, reserved)
593
611
  return True
594
612
  # 404: nothing to void; 409: already settled; 4xx auth: permanent.
595
- if status is not None and 400 <= status < 500:
613
+ # 408/429 (rate limited) and 5xx are not answers: re-queue below.
614
+ if status is not None and 400 <= status < 500 and not _is_transient_status(status):
596
615
  return False
597
- if not await self._backoff_before_retry(attempt, started, err):
616
+ if not await self._backoff_before_retry(attempt, started, err, deadline):
598
617
  break
599
618
  # Outcome unknown: keep the reservation and retry before the next push / at exit.
600
619
  self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
601
620
  logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
602
621
  return False
603
622
 
604
- async def _retry_pending_voids(self) -> bool:
623
+ async def _retry_pending_voids(self, deadline: Optional[float] = None) -> bool:
605
624
  """Retry unconfirmed voids (caller holds the operation lock).
606
625
 
607
- Returns False while any earlier void is still unconfirmed.
626
+ With ``deadline`` (``time.monotonic()``, the exit flush) keeps retrying
627
+ with backoff while time is left. Returns False while any earlier void
628
+ is still unconfirmed.
608
629
  """
609
- pending, self._pending_voids = self._pending_voids, []
610
- for event_name, operation_id, reserved, not_found_means_voided in pending:
611
- await self._void_charge(event_name, operation_id, reserved, not_found_means_voided)
612
- return not self._pending_voids
630
+ from .storage.api_client import server_retry_delay
631
+
632
+ round_ = 0
633
+ while True:
634
+ pending, self._pending_voids = self._pending_voids, []
635
+ for event_name, operation_id, reserved, not_found_means_voided in pending:
636
+ await self._void_charge(event_name, operation_id, reserved, not_found_means_voided, deadline)
637
+ if not self._pending_voids:
638
+ return True
639
+ left = 0.0 if deadline is None else deadline - time.monotonic()
640
+ if left <= 0:
641
+ return False
642
+ await asyncio.sleep(min(server_retry_delay(round_, None), left))
643
+ round_ += 1
613
644
 
614
645
  def _quarantine_stale_pending(self) -> None:
615
646
  """Remove intents older than the safe replay window (fail closed, no replay).
@@ -656,7 +687,7 @@ class ChargingManager:
656
687
  },
657
688
  headers={'Idempotency-Key': idempotency_key},
658
689
  idempotent=True,
659
- **({'retry_deadline': deadline} if deadline is not None else {}),
690
+ retry_deadline=deadline,
660
691
  )
661
692
  if isinstance(response, dict):
662
693
  return response
@@ -674,17 +705,17 @@ class ChargingManager:
674
705
  ) -> bool:
675
706
  """Sleep before the next same-key attempt; False when no attempt is left.
676
707
 
677
- A 5xx answer reaching this point was already retried by the API client
678
- with its long backoff, so it ends the attempts. Timeouts and other
679
- errors without an answer back off the same way (0.5 s doubling to 8 s)
680
- while attempts and the retry budget last.
708
+ A 429/5xx answer reaching this point was already retried by the API
709
+ client for the whole budget, so it ends the attempts. Timeouts, 408 and
710
+ other errors back off the same way while attempts and the retry budget
711
+ last.
681
712
  """
682
713
  from .storage.api_client import server_retry_delay
683
714
 
684
715
  if attempt + 1 >= self._POST_ATTEMPTS:
685
716
  return False
686
717
  status = _http_status_of(err)
687
- if status is not None and status >= 500:
718
+ if status is not None and (status == 429 or status >= 500):
688
719
  return False
689
720
  delay = server_retry_delay(attempt, None)
690
721
  if time.monotonic() - started + delay > self._RETRY_BUDGET_SECS:
@@ -714,6 +745,9 @@ class ChargingManager:
714
745
  """
715
746
  last_error: Optional[BaseException] = None
716
747
  started = time.monotonic()
748
+ # The client's own retries count against the same budget.
749
+ budget_end = started + self._RETRY_BUDGET_SECS
750
+ deadline = budget_end if deadline is None else min(deadline, budget_end)
717
751
  for attempt in range(self._POST_ATTEMPTS):
718
752
  try:
719
753
  body = await self._post_charge_once(
@@ -762,11 +796,10 @@ class ChargingManager:
762
796
  """Caller must hold _operation_lock."""
763
797
  if not self._is_at_home or self._client is None or not self._actor_run_id:
764
798
  return 0
765
- await self._retry_pending_voids()
766
-
767
- import time
768
-
769
799
  deadline = None if deadline_ms is None else (time.monotonic() + max(0.0, deadline_ms) / 1000.0)
800
+ # Voids first: an unconfirmed one means the user may be billed for items
801
+ # that were never stored. At exit (with a deadline) keep retrying them.
802
+ await self._retry_pending_voids(deadline)
770
803
  confirmed_total = 0
771
804
  async with self._flush_lock:
772
805
  while True:
@@ -266,6 +266,22 @@ class Configuration:
266
266
  value = self.get('standby_port')
267
267
  return value if isinstance(value, int) else 4321
268
268
 
269
+ @property
270
+ def web_server_port(self) -> int:
271
+ """Apify name for the container port (ACTOR_WEB_SERVER_PORT, default 4321)."""
272
+ value = self.get('container_port')
273
+ return value if isinstance(value, int) else 4321
274
+
275
+ @property
276
+ def web_server_url(self) -> str:
277
+ """Apify name for the container URL (ACTOR_WEB_SERVER_URL)."""
278
+ return cast(str, self.get('container_url'))
279
+
280
+ @property
281
+ def standby_url(self) -> str:
282
+ """Public standby URL (ACTOR_STANDBY_URL), falling back to the container URL."""
283
+ return cast(str, self.get('standby_url') or self.get('container_url'))
284
+
269
285
  @property
270
286
  def token(self) -> Optional[str]:
271
287
  """Get the authentication token."""
@@ -5,7 +5,9 @@ Base API client for Scrapely storage operations.
5
5
  from __future__ import annotations
6
6
 
7
7
  import asyncio
8
+ import json
8
9
  import logging
10
+ import os
9
11
  import random
10
12
  import time
11
13
  from typing import Any, Dict, Optional, Union
@@ -36,13 +38,16 @@ def is_retryable_response(
36
38
  ) -> bool:
37
39
  """Whether a non-2xx answer is retried.
38
40
 
39
- Idempotent methods: 500/502/503/504. Others (dataset push, KV PUT) only on
40
- a 503 that certainly was not processed: the rate limiter refused it
41
- ("rate-limit-unavailable", before the handler), or HAProxy had no backend
42
- (its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
43
- (a direct dataset write whose commit was not confirmed), and re-sending
44
- could store the items twice.
41
+ 429 always: the run API limiter and dataset backpressure refuse it before
42
+ anything is written. Idempotent methods: also 500/502/503/504. Others
43
+ (dataset push, KV PUT) only on a 503 that certainly was not processed: the
44
+ rate limiter refused it ("rate-limit-unavailable", before the handler), or
45
+ HAProxy had no backend (its own page, nothing forwarded). Any other 503 may
46
+ mean "outcome unknown" (a direct dataset write whose commit was not
47
+ confirmed), and re-sending could store the items twice.
45
48
  """
49
+ if status == 429:
50
+ return True
46
51
  if idempotent:
47
52
  return status in (500, 502, 503, 504)
48
53
  if status != 503:
@@ -70,22 +75,60 @@ def _error_type(response: httpx.Response) -> Optional[str]:
70
75
  return None
71
76
 
72
77
 
73
- def server_retry_delay(server_attempt: int, retry_after: Optional[str], now: Optional[float] = None) -> float:
74
- """Exponential backoff with jitter, in seconds; honours a Retry-After (seconds or HTTP date) of up to 30 s."""
75
- if retry_after is not None and retry_after.strip():
76
- secs: Optional[float] = None
78
+ # Default time budget for retrying one API call (429, 503, network errors).
79
+ DEFAULT_API_RETRY_WINDOW_SECS = 120.0
80
+ _MAX_BACKOFF_SECS = 30.0
81
+
82
+
83
+ def api_retry_window_secs() -> float:
84
+ """How long one API call keeps retrying before it gives up.
85
+
86
+ ``SCRAPELY_API_RETRY_WINDOW_SECS`` when set, else ``ApiClient.retry_window_secs``
87
+ (2 minutes by default).
88
+ """
89
+ raw = os.environ.get('SCRAPELY_API_RETRY_WINDOW_SECS')
90
+ if raw:
77
91
  try:
78
- secs = float(retry_after)
92
+ value = float(raw)
93
+ if value >= 0:
94
+ return value
79
95
  except ValueError:
80
- try:
81
- from email.utils import parsedate_to_datetime
96
+ pass
97
+ return ApiClient.retry_window_secs
98
+
99
+
100
+ def _parse_retry_after(retry_after: Optional[str], now: Optional[float]) -> Optional[float]:
101
+ if retry_after is None or not retry_after.strip():
102
+ return None
103
+ secs: Optional[float] = None
104
+ try:
105
+ secs = float(retry_after)
106
+ except ValueError:
107
+ try:
108
+ from email.utils import parsedate_to_datetime
82
109
 
83
- secs = max(0.0, parsedate_to_datetime(retry_after).timestamp() - (time.time() if now is None else now))
84
- except Exception:
85
- secs = None
86
- if secs is not None and secs >= 0:
87
- return min(secs, 30.0) + random.random() * 0.1
88
- return min(8.0, 0.5 * 2 ** server_attempt) + random.random() * 0.25
110
+ secs = max(0.0, parsedate_to_datetime(retry_after).timestamp() - (time.time() if now is None else now))
111
+ except Exception:
112
+ secs = None
113
+ if secs is None or secs < 0 or secs != secs:
114
+ return None
115
+ # Not capped: a Retry-After past the retry window ends the retries instead.
116
+ return secs
117
+
118
+
119
+ def server_retry_delay(attempt: int, retry_after: Optional[str], now: Optional[float] = None) -> float:
120
+ """Delay in seconds before retry number ``attempt + 1``.
121
+
122
+ Exponential backoff (0.5 s doubling, capped at 30 s) with equal jitter, and
123
+ never earlier than the server's Retry-After (seconds or HTTP date) plus a little jitter so throttled clients don't all come back in
124
+ the same instant.
125
+ """
126
+ exp: float = min(_MAX_BACKOFF_SECS, 0.5 * float(2 ** min(attempt, 16)))
127
+ backoff = exp / 2 + random.random() * (exp / 2)
128
+ ra = _parse_retry_after(retry_after, now)
129
+ if ra is None:
130
+ return backoff
131
+ return max(ra + random.random() * min(1.0, ra * 0.2 + 0.1), backoff)
89
132
 
90
133
 
91
134
  class AdaptiveRateLimiter:
@@ -139,25 +182,8 @@ class AdaptiveRateLimiter:
139
182
 
140
183
  self.last_request_time = time.time() * 1000
141
184
 
142
- async def handle_rate_limited(self, response: httpx.Response) -> None:
185
+ def record_rate_limited(self) -> None:
143
186
  self._rate_limit_errors[429] = self._rate_limit_errors.get(429, 0) + 1
144
- retry_after = response.headers.get('Retry-After')
145
- wait_ms = 1000.0
146
- if retry_after:
147
- try:
148
- wait_ms = float(retry_after) * 1000
149
- except ValueError:
150
- try:
151
- from email.utils import parsedate_to_datetime
152
-
153
- retry_date = parsedate_to_datetime(retry_after).timestamp() * 1000
154
- wait_ms = max(0.0, retry_date - time.time() * 1000)
155
- except Exception:
156
- wait_ms = 1000.0
157
-
158
- wait_ms = wait_ms + random.random() * 100
159
- logger.warning('[RateLimiter] Rate limited, waiting %.0fms before retry', wait_ms)
160
- await asyncio.sleep(wait_ms / 1000)
161
187
 
162
188
  def get_rate_limit_errors(self) -> Dict[int, int]:
163
189
  return dict(self._rate_limit_errors)
@@ -170,11 +196,10 @@ class ApiClient:
170
196
  """
171
197
 
172
198
  _rate_limiter = AdaptiveRateLimiter()
173
- _max_retries = 3
174
- # Retries while the API is briefly unavailable (503/5xx, connection
175
- # refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
176
- # covers a main redeploy.
177
- _max_server_retries = 8
199
+ # How long one call keeps retrying 429/503/network errors before it gives
200
+ # up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
201
+ # throttled run slows down instead of failing, and covers a main redeploy.
202
+ retry_window_secs = DEFAULT_API_RETRY_WINDOW_SECS
178
203
 
179
204
  def __init__(
180
205
  self,
@@ -260,12 +285,25 @@ class ApiClient:
260
285
  idempotent: Optional[bool] = None,
261
286
  retry_deadline: Optional[float] = None,
262
287
  ) -> httpx.Response:
263
- """Make an HTTP request with adaptive rate limiting and 429 retries.
288
+ """Make an HTTP request with the API retry policy.
289
+
290
+ Retries, with exponential backoff and jitter that honours Retry-After,
291
+ until the retry window (2 minutes by default) runs out:
292
+
293
+ - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
294
+ - 503 from the limiter or HAProxy: always; nothing was forwarded.
295
+ - 500/502/503/504, read/write timeouts and dropped connections:
296
+ idempotent calls only.
297
+ - Refused connections and connect/pool timeouts: always; the request
298
+ never left.
299
+
300
+ A write (dataset push, KV PUT) whose request may have reached the API is
301
+ never re-sent: there is no idempotency key for it, so a re-send could
302
+ store the same rows twice. Such failures surface to the caller instead.
264
303
 
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.
304
+ ``retry_deadline`` (a ``time.monotonic()`` value) ends the retries
305
+ earlier, so a caller with a time budget (e.g. the exit flush) gets an
306
+ answer instead of being cancelled mid-retry.
269
307
  """
270
308
  client = await self._ensure_client()
271
309
  url = self._url(path)
@@ -274,78 +312,76 @@ class ApiClient:
274
312
  request_headers = dict(headers or {})
275
313
  if json_data is not None and 'Content-Type' not in request_headers:
276
314
  request_headers['Content-Type'] = 'application/json'
315
+ if json_data is not None:
316
+ # Apify/crawlee parity: unknown objects (datetimes, LLM result
317
+ # objects, ...) are stored as strings instead of raising.
318
+ content = json.dumps(json_data, ensure_ascii=False, default=str).encode('utf-8')
277
319
 
278
320
  if idempotent is None:
279
321
  idempotent = method.upper() in IDEMPOTENT_METHODS
280
- attempt = 0 # timeouts and 429s
281
- server_attempt = 0 # 503/5xx and connection failures (API briefly unavailable)
322
+ deadline = time.monotonic() + max(0.0, api_retry_window_secs())
323
+ if retry_deadline is not None:
324
+ deadline = min(deadline, retry_deadline)
325
+ attempt = 0
326
+
327
+ async def backoff(why: str, retry_after: Optional[str]) -> bool:
328
+ """Sleep before the next attempt; False once the retry window is used up."""
329
+ nonlocal attempt
330
+ # Retrying sooner than asked would only be refused again: give up
331
+ # when the next try would land past the window / deadline.
332
+ wait = server_retry_delay(attempt, retry_after)
333
+ if time.monotonic() + wait > deadline:
334
+ return False
335
+ attempt += 1
336
+ logger.warning('%s %s %s; retry %d in %.1fs', method, path, why, attempt, wait)
337
+ await asyncio.sleep(wait)
338
+ return True
339
+
282
340
  while True:
283
341
  await self._rate_limiter.wait_for_slot()
342
+ request_kwargs: Dict[str, Any] = {}
343
+ if retry_deadline is not None:
344
+ # A caller with a time budget: no attempt may outlive it.
345
+ request_kwargs['timeout'] = max(0.5, min(self.timeout, retry_deadline - time.monotonic()))
284
346
  try:
285
347
  response = await client.request(
286
348
  method,
287
349
  url,
288
350
  params=params,
289
- json=json_data,
290
351
  content=content,
291
352
  headers=request_headers or None,
353
+ **request_kwargs,
292
354
  )
293
- except httpx.TimeoutException:
294
- if do_not_retry_timeouts or attempt >= self._max_retries:
355
+ except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ConnectError) as exc:
356
+ # Never reached the API: safe for every method.
357
+ if isinstance(exc, (httpx.ConnectTimeout, httpx.PoolTimeout)) and do_not_retry_timeouts:
295
358
  raise
296
- attempt += 1
297
- await asyncio.sleep(0.25 * attempt)
298
- continue
359
+ if await backoff(f'failed ({type(exc).__name__})', None):
360
+ continue
361
+ raise
362
+ except httpx.TimeoutException as exc:
363
+ # Read/write timeout: the request may have been applied.
364
+ if idempotent and not do_not_retry_timeouts and await backoff(f'timed out ({type(exc).__name__})', None):
365
+ continue
366
+ raise
299
367
  except httpx.TransportError as exc:
300
- # No response. A refused/unresolvable connection never reached
301
- # the API, so it is safe for every method; anything else (reset
302
- # mid-request) only for idempotent ones.
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
- ):
309
- server_attempt += 1
310
- logger.warning(
311
- '%s %s failed (%s); retry %d/%d in %.1fs',
312
- method, path, type(exc).__name__, server_attempt, self._max_server_retries, wait,
313
- )
314
- await asyncio.sleep(wait)
368
+ # Reset mid-request: only idempotent calls are re-sent.
369
+ if idempotent and await backoff(f'failed ({type(exc).__name__})', None):
315
370
  continue
316
371
  raise
317
372
 
318
373
  self._rate_limiter.update_from_headers(response.headers)
319
-
320
- if response.status_code == 429 and attempt < self._max_retries:
321
- attempt += 1
322
- await self._rate_limiter.handle_rate_limited(response)
323
- continue
324
-
325
- # API briefly unavailable (main redeploy, limiter store blip); see
326
- # is_retryable_response for which answers are safe to re-send.
327
- if (
328
- not response.is_success
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
- )
334
- and is_retryable_response(
335
- response.status_code,
336
- idempotent,
337
- _error_type(response) if response.status_code == 503 else None,
338
- response.text if response.status_code == 503 else None,
339
- )
374
+ if response.status_code == 429:
375
+ self._rate_limiter.record_rate_limited()
376
+
377
+ if not response.is_success and is_retryable_response(
378
+ response.status_code,
379
+ idempotent,
380
+ _error_type(response) if response.status_code == 503 else None,
381
+ response.text if response.status_code == 503 else None,
340
382
  ):
341
- wait = server_retry_delay(server_attempt, response.headers.get('Retry-After'))
342
- server_attempt += 1
343
- logger.warning(
344
- '%s %s answered %d; retry %d/%d in %.1fs',
345
- method, path, response.status_code, server_attempt, self._max_server_retries, wait,
346
- )
347
- await asyncio.sleep(wait)
348
- continue
383
+ if await backoff(f'answered {response.status_code}', response.headers.get('Retry-After')):
384
+ continue
349
385
 
350
386
  if raise_for_status:
351
387
  await self._raise_for_status(response)
@@ -402,6 +438,7 @@ class ApiClient:
402
438
  params: Optional[Dict[str, Any]] = None,
403
439
  *,
404
440
  raise_for_status: bool = True,
441
+ idempotent: Optional[bool] = None,
405
442
  ) -> httpx.Response:
406
443
  return await self.request(
407
444
  'PUT',
@@ -411,6 +448,7 @@ class ApiClient:
411
448
  content=content,
412
449
  headers=headers,
413
450
  raise_for_status=raise_for_status,
451
+ idempotent=idempotent,
414
452
  )
415
453
 
416
454
  async def delete(
@@ -6,6 +6,7 @@ Ports the patterns from the TypeScript ApiStorageClient.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  import logging
10
11
  import re
11
12
  import uuid
@@ -358,7 +359,14 @@ class ApiStorageClient:
358
359
  return cast(Dict[str, Any], await self.api_client.get_json(f'/v2/key-value-stores/{store_id}/keys', params=params))
359
360
 
360
361
  def kvs_public_url(self, store_id: str, key: str) -> str:
361
- return f'{self.api_client.api_url}{kvs_record_path(store_id, key)}'
362
+ # api_url is the container-internal API address; links handed to users
363
+ # must use the public base URL the platform injects.
364
+ public_base = (
365
+ os.environ.get('APIFY_API_PUBLIC_BASE_URL')
366
+ or os.environ.get('ACTOR_API_PUBLIC_BASE_URL')
367
+ or self.api_client.api_url
368
+ ).rstrip('/')
369
+ return f'{public_base}{kvs_record_path(store_id, key)}'
362
370
 
363
371
  # --- Request queue resource ops ---
364
372
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.11
3
+ Version: 0.1.13
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 AND Apache-2.0
@@ -20,12 +20,16 @@ from scrapely.storage.api_client import ApiClient, ApiClientError, is_retryable_
20
20
 
21
21
  @pytest.fixture(autouse=True)
22
22
  def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
23
+ """Sleeps return at once but advance a fake monotonic clock, so retry windows run out."""
23
24
  slept: List[float] = []
25
+ clock = [1000.0]
24
26
 
25
27
  async def fake_sleep(secs: float) -> None:
26
28
  slept.append(secs)
29
+ clock[0] += secs
27
30
 
28
31
  monkeypatch.setattr(mod.asyncio, 'sleep', fake_sleep)
32
+ monkeypatch.setattr(mod.time, 'monotonic', lambda: clock[0])
29
33
  return slept
30
34
 
31
35
 
@@ -86,22 +90,69 @@ async def test_post_retries_503_until_success(no_sleep: List[float]) -> None:
86
90
  r = await c.request('POST', '/v2/datasets/d/items', json_data=[{'a': 1}])
87
91
  assert r.status_code == 201
88
92
  assert calls == ['POST'] * 3
89
- backoff = [x for x in no_sleep if x >= 0.4] # the rate limiter's own pacing sleeps are shorter
90
- assert len(backoff) == 2 and 0.5 <= backoff[0] < 0.8 and 1.0 <= backoff[1] < 1.3 # exponential
93
+ backoff = [x for x in no_sleep if x >= 0.2] # the rate limiter's own pacing sleeps are shorter
94
+ assert len(backoff) == 2 and 0.25 <= backoff[0] <= 0.5 and 0.5 <= backoff[1] <= 1.0 # exponential
91
95
 
92
96
 
93
97
  async def test_retry_after_is_honoured(no_sleep: List[float]) -> None:
94
98
  c, _ = client_for([httpx.Response(503, headers={'Retry-After': '3'}), httpx.Response(200, json={})])
95
99
  await c.request('GET', '/v2/key-value-stores/k/records/x')
96
- assert any(3.0 <= x < 3.2 for x in no_sleep)
100
+ assert any(3.0 <= x < 3.8 for x in no_sleep)
97
101
 
98
102
 
99
- async def test_gives_up_after_budget() -> None:
103
+ async def test_gives_up_after_the_retry_window(no_sleep: List[float]) -> None:
100
104
  c, calls = client_for([unavailable()])
101
105
  with pytest.raises(ApiClientError) as e:
102
106
  await c.request('GET', '/v2/datasets/d')
103
107
  assert e.value.status_code == 503
104
- assert len(calls) == ApiClient._max_server_retries + 1
108
+ backoff = sum(x for x in no_sleep if x >= 0.2) # the rate limiter's own pacing sleeps are shorter
109
+ assert 75 <= backoff <= ApiClient.retry_window_secs, 'most of 2 minutes, never past it'
110
+ assert len(calls) >= 8
111
+
112
+
113
+ async def test_retry_window_is_configurable(no_sleep: List[float], monkeypatch: pytest.MonkeyPatch) -> None:
114
+ monkeypatch.setenv('SCRAPELY_API_RETRY_WINDOW_SECS', '10')
115
+ c, calls = client_for([httpx.Response(429, headers={'Retry-After': '1'})])
116
+ with pytest.raises(ApiClientError) as e:
117
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
118
+ assert e.value.status_code == 429
119
+ assert sum(x for x in no_sleep if x >= 0.2) <= 10.01 and len(calls) >= 3
120
+
121
+
122
+ async def test_429_sequence_backs_off_honouring_retry_after(no_sleep: List[float]) -> None:
123
+ """Run API rate limit (PR #664): 429 + Retry-After 1-10 s. Nothing was stored, so a push is re-sent."""
124
+ limited = [httpx.Response(429, headers={'Retry-After': str(s)}, json={'error': {'type': 'rate-limit-exceeded'}}) for s in (3, 1, 1, 1, 1, 1)]
125
+ c, calls = client_for(limited + [httpx.Response(201, json={})])
126
+ assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{'a': 1}])).status_code == 201
127
+ assert calls == ['POST'] * 7
128
+ backoff = [x for x in no_sleep if x >= 0.2]
129
+ assert len(backoff) == 6
130
+ assert 3.0 <= backoff[0] < 3.8, 'never earlier than Retry-After'
131
+ assert all(b >= 1.0 for b in backoff)
132
+ assert backoff[5] >= 8.0, f'grows past a short Retry-After: {backoff}'
133
+ assert c.get_rate_limit_errors().get(429, 0) >= 6
134
+
135
+
136
+ async def test_429_past_the_old_three_retries_still_succeeds(no_sleep: List[float]) -> None:
137
+ c, calls = client_for([httpx.Response(429, headers={'Retry-After': '1'})] * 8 + [httpx.Response(200, json={})])
138
+ assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
139
+ assert len(calls) == 9
140
+
141
+
142
+ async def test_post_read_timeout_is_not_resent(no_sleep: List[float]) -> None:
143
+ """No idempotency key for dataset pushes: a timed-out push may be stored, so it is not re-sent."""
144
+ c, calls = client_for([httpx.ReadTimeout('slow'), httpx.Response(201, json={})])
145
+ with pytest.raises(httpx.ReadTimeout):
146
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
147
+ assert len(calls) == 1
148
+ # Connect timeout: the request never left, so it is re-sent.
149
+ c, calls = client_for([httpx.ConnectTimeout('slow'), httpx.Response(201, json={})])
150
+ assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{}])).status_code == 201
151
+ assert len(calls) == 2
152
+ # GET read timeouts are retried.
153
+ c, calls = client_for([httpx.ReadTimeout('slow'), httpx.Response(200, json={})])
154
+ assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
155
+ assert len(calls) == 2
105
156
 
106
157
 
107
158
  async def test_502_retried_for_get_not_for_post() -> None:
@@ -164,7 +215,27 @@ def test_helpers() -> None:
164
215
  assert not is_retryable_response(400, True) and not is_retryable_response(501, True)
165
216
  from email.utils import parsedate_to_datetime
166
217
  now = parsedate_to_datetime('Thu, 15 Oct 2026 00:00:00 GMT').timestamp()
167
- assert 5.0 <= server_retry_delay(0, 'Thu, 15 Oct 2026 00:00:05 GMT', now=now) < 5.2
168
- assert 4.0 <= server_retry_delay(3, 'garbage') < 4.3
169
- assert server_retry_delay(10, None) < 8.3
170
- assert 30.0 <= server_retry_delay(0, '120') < 30.2
218
+ assert is_retryable_response(429, False) and is_retryable_response(429, True)
219
+ assert 5.0 <= server_retry_delay(0, 'Thu, 15 Oct 2026 00:00:05 GMT', now=now) < 6.1
220
+ assert 2.0 <= server_retry_delay(3, 'garbage') <= 4.0
221
+ assert 0.25 <= server_retry_delay(0, None) <= 0.5
222
+ assert 15.0 <= server_retry_delay(10, None) <= 30.0, 'capped at 30 s'
223
+ assert 120.0 <= server_retry_delay(0, '120') < 121.1, 'Retry-After is not capped'
224
+ assert server_retry_delay(6, '1') >= 15.0, 'backoff grows past a short Retry-After'
225
+ assert server_retry_delay(0, '0') <= 0.5
226
+
227
+
228
+ async def test_status_message_put_is_retried_as_idempotent(no_sleep: List[float]) -> None:
229
+ """Actor.set_status_message PUTs with idempotent=True: a 429 or 502 is retried, not raised."""
230
+ c, calls = client_for([httpx.Response(429, headers={'Retry-After': '1'}), httpx.Response(502), httpx.Response(200, json={'data': {}})])
231
+ r = await c.put('/v2/actor-runs/run1', json_data={'statusMessage': 'x'}, idempotent=True)
232
+ assert r.status_code == 200
233
+ assert calls == ['PUT'] * 3
234
+
235
+
236
+ async def test_retry_after_past_the_window_gives_up_at_once(no_sleep: List[float]) -> None:
237
+ c, calls = client_for([httpx.Response(429, headers={'Retry-After': '300'}), httpx.Response(200, json={})])
238
+ with pytest.raises(ApiClientError) as e:
239
+ await c.request('GET', '/v2/datasets/d')
240
+ assert e.value.status_code == 429
241
+ assert len(calls) == 1, 'no early retry that the server would refuse again'
@@ -9,6 +9,7 @@ update them when targeting a newer Apify version.
9
9
  from __future__ import annotations
10
10
 
11
11
  import importlib
12
+ import json
12
13
 
13
14
  import pytest
14
15
 
@@ -83,3 +84,52 @@ def test_configuration_has_apify_global_accessor() -> None:
83
84
  from scrapely import Configuration
84
85
 
85
86
  assert Configuration.get_global_configuration() is Configuration.get_global_config()
87
+
88
+
89
+ def test_configuration_has_apify_web_server_names(monkeypatch: pytest.MonkeyPatch) -> None:
90
+ from scrapely import Configuration
91
+
92
+ monkeypatch.setenv('ACTOR_WEB_SERVER_PORT', '5555')
93
+ monkeypatch.setenv('ACTOR_WEB_SERVER_URL', 'http://10.0.0.1:5555')
94
+ config = Configuration()
95
+ assert config.web_server_port == 5555
96
+ assert config.web_server_url == 'http://10.0.0.1:5555'
97
+ assert config.standby_url == 'http://10.0.0.1:5555'
98
+
99
+
100
+ def test_is_at_home_reads_env_before_init(monkeypatch: pytest.MonkeyPatch) -> None:
101
+ # Apify templates pick their port at import time, before Actor.init().
102
+ from scrapely import Actor, Configuration
103
+
104
+ monkeypatch.setenv('APIFY_IS_AT_HOME', '1')
105
+ monkeypatch.setenv('APIFY_META_ORIGIN', 'STANDBY')
106
+ Configuration.reset_global_state()
107
+ monkeypatch.setattr(Actor, '_config', None)
108
+ try:
109
+ assert Actor.is_at_home() is True
110
+ assert Actor.is_standby() is True
111
+ assert Actor._config is None
112
+ finally:
113
+ Configuration.reset_global_state()
114
+
115
+
116
+ async def test_json_body_stringifies_unknown_objects() -> None:
117
+ import httpx
118
+
119
+ from scrapely.storage.api_client import ApiClient
120
+
121
+ class AgentOutput:
122
+ def __str__(self) -> str:
123
+ return 'the answer'
124
+
125
+ bodies: list = []
126
+
127
+ def handler(request: httpx.Request) -> httpx.Response:
128
+ bodies.append((request.headers['content-type'], request.content))
129
+ return httpx.Response(201, json={})
130
+
131
+ client = ApiClient('http://api.test', 'st_x', 'run1', transport=httpx.MockTransport(handler))
132
+ await client.post_json('/v2/datasets/d1/items', [{'answer': AgentOutput(), 'q': 'café'}])
133
+ content_type, body = bodies[0]
134
+ assert content_type == 'application/json'
135
+ assert json.loads(body) == [{'answer': 'the answer', 'q': 'café'}]
@@ -27,13 +27,17 @@ from scrapely.storage.api_client import ApiClient
27
27
 
28
28
  @pytest.fixture(autouse=True)
29
29
  def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
30
+ """Sleeps return at once but advance a fake monotonic clock, so retry windows run out."""
30
31
  slept: List[float] = []
32
+ clock = [1000.0]
31
33
 
32
34
  async def fake_sleep(secs: float) -> None:
33
35
  slept.append(secs)
36
+ clock[0] += secs
34
37
 
35
38
  monkeypatch.setattr(client_mod.asyncio, 'sleep', fake_sleep)
36
39
  monkeypatch.setattr(charging_mod.asyncio, 'sleep', fake_sleep)
40
+ monkeypatch.setattr(client_mod.time, 'monotonic', lambda: clock[0])
37
41
  return slept
38
42
 
39
43
 
@@ -116,26 +120,76 @@ async def test_flush_deadline_bounds_the_client_retries(no_sleep: List[float]) -
116
120
 
117
121
  mgr, calls = manager_for([httpx.Response(503, json={'error': {'type': 'service-unavailable'}})])
118
122
  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()
123
+ confirmed = await mgr.flush_pending(deadline_ms=3000)
138
124
  assert confirmed == 0
139
125
  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}'
126
+ assert sum(x for x in no_sleep if x >= 0.2) <= 3.0, f'no retry sleeps past the deadline: {no_sleep}'
141
127
  assert len(calls) >= 2, 'it did retry within the budget'
128
+
129
+
130
+ async def test_charge_429_backs_off_honouring_retry_after(no_sleep: List[float]) -> None:
131
+ limited = httpx.Response(429, headers={'Retry-After': '4'}, json={'error': {'type': 'rate-limit-exceeded'}})
132
+ mgr, calls = manager_for([limited, limited, limited, ok()])
133
+ assert (await mgr.charge('page-open', 1)).charged_count == 1
134
+ assert len(calls) == 4 and len({key for _, key in calls}) == 1
135
+ backoff = [x for x in no_sleep if x >= 0.4]
136
+ assert len(backoff) == 3 and all(4.0 <= x < 5.0 for x in backoff[:2]), backoff
137
+
138
+
139
+ async def test_charge_429_for_longer_than_the_window_stays_pending(no_sleep: List[float]) -> None:
140
+ limited = httpx.Response(429, headers={'Retry-After': '10'}, json={'error': {'type': 'rate-limit-exceeded'}})
141
+ mgr, calls = manager_for([limited])
142
+ assert await mgr._post_charge_with_retries(event_name='page-open', count=1, idempotency_key='k1') is None
143
+ assert sum(no_sleep) <= ChargingManager._RETRY_BUDGET_SECS + 1
144
+ assert len(calls) >= 8, 'kept retrying through the window'
145
+
146
+
147
+ async def test_void_that_429s_is_requeued_then_succeeds(no_sleep: List[float]) -> None:
148
+ """A 429 on a void is not an answer: the void is re-queued and retried, never dropped."""
149
+ limited = httpx.Response(429, headers={'Retry-After': '10'}, json={'error': {'type': 'rate-limit-exceeded'}})
150
+ mgr, calls = manager_for([limited])
151
+ assert await mgr._void_charge('page-open', 'op-1', 1) is False
152
+ assert mgr._pending_voids == [('page-open', 'op-1', 1, False)], 're-queued, not treated as final'
153
+ # The limiter lets it through on the next try (e.g. before the next push).
154
+ ok_void = httpx.Response(200, json={'data': {'status': 'voided'}})
155
+ mgr._client._transport = None # noqa: SLF001
156
+ mgr2_calls: list = []
157
+
158
+ def handler(request: httpx.Request) -> httpx.Response:
159
+ mgr2_calls.append(request.url.path)
160
+ return ok_void
161
+
162
+ mgr._client = ApiClient('http://api.test', 'st_x', 'run-1', transport=httpx.MockTransport(handler))
163
+ assert await mgr._retry_pending_voids() is True
164
+ assert mgr2_calls == ['/v2/actor-runs/run-1/charge/void']
165
+ assert not mgr._pending_voids
166
+
167
+
168
+ async def test_exit_flush_keeps_retrying_a_rate_limited_void(no_sleep: List[float]) -> None:
169
+ limited = httpx.Response(429, headers={'Retry-After': '2'}, json={'error': {'type': 'rate-limit-exceeded'}})
170
+ responses = [limited] * 6 + [httpx.Response(200, json={'data': {'status': 'voided'}})]
171
+ mgr, calls = manager_for(responses)
172
+ mgr._RETRY_BUDGET_SECS = 5.0 # one void call gives up quickly; the flush loop retries it
173
+ mgr._pending_voids.append(('page-open', 'op-1', 1, False))
174
+ await mgr.flush_pending(deadline_ms=60_000)
175
+ assert not mgr._pending_voids, 'voided before the run exits'
176
+ assert len(calls) == 7
177
+
178
+
179
+ async def test_charge_408_is_retried_with_the_same_key(no_sleep: List[float]) -> None:
180
+ """The API client does not retry 408; the charging manager does (same key)."""
181
+ mgr, calls = manager_for([httpx.Response(408, json={'error': {'type': 'timeout'}}), ok()])
182
+ assert (await mgr.charge('page-open', 1)).charged_count == 1
183
+ assert len(calls) == 2 and len({key for _, key in calls}) == 1
184
+
185
+
186
+ async def test_flush_retry_never_starts_past_the_deadline(no_sleep: List[float]) -> None:
187
+ """A Retry-After longer than the time left ends the retries instead of firing at the deadline."""
188
+ import time as _time
189
+
190
+ limited = httpx.Response(429, headers={'Retry-After': '10'}, json={'error': {'type': 'rate-limit-exceeded'}})
191
+ mgr, calls = manager_for([limited])
192
+ mgr._pending_charge_keys['k1'] = {'event_name': 'page-open', 'count': 1, 'created_at': _time.monotonic()}
193
+ await mgr.flush_pending(deadline_ms=5000)
194
+ assert len(calls) == 1, 'no second request: it would come after the deadline'
195
+ assert 'k1' in mgr._pending_charge_keys
@@ -64,13 +64,18 @@ def test_actor_is_standby_reflects_meta_origin() -> None:
64
64
  Actor._config = original
65
65
 
66
66
 
67
- def test_actor_is_standby_without_config_is_false() -> None:
67
+ def test_actor_is_standby_without_config_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
68
+ # Before init() is_standby() reads the environment (Apify parity), so clear it.
69
+ monkeypatch.delenv('APIFY_META_ORIGIN', raising=False)
70
+ monkeypatch.delenv('ACTOR_META_ORIGIN', raising=False)
68
71
  original = Actor._config
69
72
  try:
70
73
  Actor._config = None
74
+ scrapely.Configuration.reset_global_state()
71
75
  assert Actor.is_standby() is False
72
76
  finally:
73
77
  Actor._config = original
78
+ scrapely.Configuration.reset_global_state()
74
79
 
75
80
 
76
81
  def test_parity_helpers_exported_from_package_root() -> None:
@@ -277,7 +277,7 @@ async def test_rq_reclaim_still_raises_other_errors(transport, mock_api, monkeyp
277
277
  # A persistent 500 is raised once the server-error retries are spent (none here, to keep the test fast).
278
278
  from scrapely.storage.api_client import ApiClient
279
279
 
280
- monkeypatch.setattr(ApiClient, '_max_server_retries', 0)
280
+ monkeypatch.setattr(ApiClient, 'retry_window_secs', 0.0)
281
281
  client = ScrapelyStorageClient(
282
282
  api_url='https://api.test.scrape.ly',
283
283
  token='t',
@@ -26,7 +26,7 @@ class _FakeClient:
26
26
  self.fail = fail
27
27
  self.hang = hang
28
28
 
29
- async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None) -> _FakeResponse:
29
+ async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None, **_kwargs: Any) -> _FakeResponse:
30
30
  if self.hang:
31
31
  await asyncio.sleep(60)
32
32
  if self.fail: