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.
- {scrapely_python-0.1.11/src/scrapely_python.egg-info → scrapely_python-0.1.13}/PKG-INFO +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/pyproject.toml +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/actor.py +14 -5
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/charging.py +58 -25
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/configuration.py +16 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/api_client.py +137 -99
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/client.py +9 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_api_client_retries.py +80 -9
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_apify_parity.py +50 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charge_retries.py +74 -20
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_parity.py +6 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_storage.py +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_terminal_status_message.py +1 -1
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/LICENSE +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/NOTICE +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/README.md +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/setup.cfg +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charge_limit_guard.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_input_proxy_aliases.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.11 → scrapely_python-0.1.13}/tests/test_secrets.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
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"
|
|
@@ -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 ~
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 /
|
|
53
|
-
# client itself retries 5xx answers and refused or reset
|
|
54
|
-
# POSTs with
|
|
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),
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
|
678
|
-
|
|
679
|
-
errors
|
|
680
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
92
|
+
value = float(raw)
|
|
93
|
+
if value >= 0:
|
|
94
|
+
return value
|
|
79
95
|
except ValueError:
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
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
|
|
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)
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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.
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
#
|
|
301
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
90
|
-
assert len(backoff) == 2 and 0.
|
|
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.
|
|
100
|
+
assert any(3.0 <= x < 3.8 for x in no_sleep)
|
|
97
101
|
|
|
98
102
|
|
|
99
|
-
async def
|
|
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
|
-
|
|
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
|
|
168
|
-
assert
|
|
169
|
-
assert server_retry_delay(
|
|
170
|
-
assert
|
|
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
|
-
|
|
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, '
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_kvs_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_rq_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely/storage/scrapely_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.11 → scrapely_python-0.1.13}/src/scrapely_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|