scrapely-python 0.1.14__tar.gz → 0.1.15__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.14/src/scrapely_python.egg-info → scrapely_python-0.1.15}/PKG-INFO +1 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/pyproject.toml +1 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/actor.py +13 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/charging.py +13 -3
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/api_client.py +125 -26
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/client.py +5 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
- scrapely_python-0.1.15/tests/test_push_idempotency.py +242 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_terminal_status_message.py +19 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/LICENSE +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/NOTICE +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/README.md +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/setup.cfg +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_charge_limit_guard.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_charge_retries.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_input_proxy_aliases.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.14 → scrapely_python-0.1.15}/tests/test_storage.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.15"
|
|
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"
|
|
@@ -1054,7 +1054,19 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1054
1054
|
else:
|
|
1055
1055
|
logger.info(logged)
|
|
1056
1056
|
|
|
1057
|
-
|
|
1057
|
+
if is_terminal:
|
|
1058
|
+
result = await cls._put_run_status_message(message, is_terminal=True)
|
|
1059
|
+
else:
|
|
1060
|
+
# A progress update is best effort: if the API still fails after the
|
|
1061
|
+
# retry window (e.g. a long 503), log it and carry on instead of
|
|
1062
|
+
# crashing the run. Terminal updates keep raising.
|
|
1063
|
+
try:
|
|
1064
|
+
result = await cls._put_run_status_message(message, is_terminal=False)
|
|
1065
|
+
except asyncio.CancelledError:
|
|
1066
|
+
raise
|
|
1067
|
+
except Exception as e: # noqa: BLE001
|
|
1068
|
+
logger.warning('Failed to update the status message (continuing): %s', e)
|
|
1069
|
+
return ActorRun(id='', actor_id='', status='')
|
|
1058
1070
|
if result is None:
|
|
1059
1071
|
return ActorRun(id='', actor_id='', status='')
|
|
1060
1072
|
run_id, data = result
|
|
@@ -52,7 +52,7 @@ _PENDING_MAX_ENTRIES = 1024
|
|
|
52
52
|
# Same-key POST attempts before leaving an intent pending / re-queueing a void.
|
|
53
53
|
# The API client itself retries 429 and 5xx answers and refused or reset
|
|
54
54
|
# connections of these POSTs with exponential backoff (honouring Retry-After)
|
|
55
|
-
# for its retry window (
|
|
55
|
+
# for its retry window (5 minutes, SCRAPELY_API_RETRY_WINDOW_SECS): re-sending
|
|
56
56
|
# is safe because every attempt carries the same Idempotency-Key. These outer
|
|
57
57
|
# attempts cover what the client gives up on quickly (timeouts, other
|
|
58
58
|
# transport errors), within _RETRY_BUDGET_SECS. Then the charge stays pending
|
|
@@ -62,7 +62,7 @@ _POST_ATTEMPTS = 8
|
|
|
62
62
|
|
|
63
63
|
# Outer retries stop once this much time has passed since the first attempt
|
|
64
64
|
# (the API retry window; JS SDK parity).
|
|
65
|
-
_RETRY_BUDGET_SECS =
|
|
65
|
+
_RETRY_BUDGET_SECS = 300.0
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def _is_transient_status(status: Optional[int]) -> bool:
|
|
@@ -1567,10 +1567,20 @@ def _push_certainly_failed(err: BaseException) -> bool:
|
|
|
1567
1567
|
"""True when a dataset push certainly stored nothing.
|
|
1568
1568
|
|
|
1569
1569
|
The storage API answered with an error status, or the connection was never
|
|
1570
|
-
made. Timeouts and resets after sending are not proof: the write may have landed
|
|
1570
|
+
made. Timeouts and resets after sending are not proof: the write may have landed,
|
|
1571
|
+
and neither is any error of a push one of whose earlier attempts may have been
|
|
1572
|
+
applied (``outcome_unknown``, set by the storage client's retries).
|
|
1571
1573
|
"""
|
|
1572
1574
|
import httpx
|
|
1573
1575
|
|
|
1576
|
+
c: Optional[BaseException] = err
|
|
1577
|
+
for _ in range(4):
|
|
1578
|
+
if c is None:
|
|
1579
|
+
break
|
|
1580
|
+
if getattr(c, 'outcome_unknown', False) is True:
|
|
1581
|
+
return False
|
|
1582
|
+
c = c.__cause__ or c.__context__
|
|
1583
|
+
|
|
1574
1584
|
e: Optional[BaseException] = err
|
|
1575
1585
|
for _ in range(4):
|
|
1576
1586
|
if e is None:
|
|
@@ -76,15 +76,73 @@ def _error_type(response: httpx.Response) -> Optional[str]:
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
# Default time budget for retrying one API call (429, 503, network errors).
|
|
79
|
-
DEFAULT_API_RETRY_WINDOW_SECS =
|
|
79
|
+
DEFAULT_API_RETRY_WINDOW_SECS = 300.0
|
|
80
80
|
_MAX_BACKOFF_SECS = 30.0
|
|
81
81
|
|
|
82
|
+
# Response header the API sets on every dataset push that sent an
|
|
83
|
+
# Idempotency-Key, once it de-duplicates such pushes. An older API ignores the
|
|
84
|
+
# key, and re-sending a push there could store the rows twice, so ambiguous
|
|
85
|
+
# failures of keyed writes are retried only while the latest keyed answer from
|
|
86
|
+
# that API origin carried this header. A 2xx without it (an older instance,
|
|
87
|
+
# e.g. mid-deploy or after a rollback) switches the retries off again.
|
|
88
|
+
IDEMPOTENCY_SUPPORT_HEADER = 'X-Scrapely-Idempotency'
|
|
89
|
+
_KEY_IN_PROGRESS = 'idempotency-key-in-progress'
|
|
90
|
+
_OUTCOME_UNKNOWN = 'push-outcome-unknown'
|
|
91
|
+
_keyed_writes_deduped: Dict[str, bool] = {}
|
|
92
|
+
_keyed_writes_override: Optional[bool] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _origin_of(url: str) -> str:
|
|
96
|
+
try:
|
|
97
|
+
u = httpx.URL(url)
|
|
98
|
+
return f'{u.scheme}://{u.netloc.decode()}'
|
|
99
|
+
except Exception:
|
|
100
|
+
return url
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def api_dedupes_keyed_writes(url: Optional[str] = None) -> bool:
|
|
104
|
+
"""Whether the API at ``url`` (default: the last one seen) de-duplicates keyed writes."""
|
|
105
|
+
if _keyed_writes_override is not None:
|
|
106
|
+
return _keyed_writes_override
|
|
107
|
+
if url is not None:
|
|
108
|
+
return _keyed_writes_deduped.get(_origin_of(url)) is True
|
|
109
|
+
return bool(_keyed_writes_deduped) and list(_keyed_writes_deduped.values())[-1] is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _reset_keyed_write_support(value: Optional[bool] = None) -> None:
|
|
113
|
+
"""Test hook: forget what was learned about the API, or force True for every origin."""
|
|
114
|
+
global _keyed_writes_override
|
|
115
|
+
_keyed_writes_deduped.clear()
|
|
116
|
+
_keyed_writes_override = True if value is True else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _note_keyed_answer(url: str, response: httpx.Response) -> None:
|
|
120
|
+
global _keyed_writes_override
|
|
121
|
+
origin = _origin_of(url)
|
|
122
|
+
if IDEMPOTENCY_SUPPORT_HEADER in response.headers:
|
|
123
|
+
_keyed_writes_deduped.pop(origin, None)
|
|
124
|
+
_keyed_writes_deduped[origin] = True
|
|
125
|
+
elif response.is_success:
|
|
126
|
+
# Stored by a handler that ignored the key: this API (instance) does not de-duplicate.
|
|
127
|
+
_keyed_writes_deduped.pop(origin, None)
|
|
128
|
+
_keyed_writes_deduped[origin] = False
|
|
129
|
+
_keyed_writes_override = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _mark_outcome_unknown(exc: BaseException) -> BaseException:
|
|
133
|
+
"""Mark an error whose request an earlier attempt may have applied."""
|
|
134
|
+
try:
|
|
135
|
+
setattr(exc, 'outcome_unknown', True)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
return exc
|
|
139
|
+
|
|
82
140
|
|
|
83
141
|
def api_retry_window_secs() -> float:
|
|
84
142
|
"""How long one API call keeps retrying before it gives up.
|
|
85
143
|
|
|
86
144
|
``SCRAPELY_API_RETRY_WINDOW_SECS`` when set, else ``ApiClient.retry_window_secs``
|
|
87
|
-
(
|
|
145
|
+
(5 minutes by default).
|
|
88
146
|
"""
|
|
89
147
|
raw = os.environ.get('SCRAPELY_API_RETRY_WINDOW_SECS')
|
|
90
148
|
if raw:
|
|
@@ -198,7 +256,7 @@ class ApiClient:
|
|
|
198
256
|
_rate_limiter = AdaptiveRateLimiter()
|
|
199
257
|
# How long one call keeps retrying 429/503/network errors before it gives
|
|
200
258
|
# up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
|
|
201
|
-
# throttled run slows down instead of failing, and covers
|
|
259
|
+
# throttled run slows down instead of failing, and covers an API redeploy.
|
|
202
260
|
retry_window_secs = DEFAULT_API_RETRY_WINDOW_SECS
|
|
203
261
|
|
|
204
262
|
def __init__(
|
|
@@ -284,22 +342,26 @@ class ApiClient:
|
|
|
284
342
|
do_not_retry_timeouts: bool = False,
|
|
285
343
|
idempotent: Optional[bool] = None,
|
|
286
344
|
retry_deadline: Optional[float] = None,
|
|
345
|
+
idempotency_key: Optional[str] = None,
|
|
287
346
|
) -> httpx.Response:
|
|
288
347
|
"""Make an HTTP request with the API retry policy.
|
|
289
348
|
|
|
290
349
|
Retries, with exponential backoff and jitter that honours Retry-After,
|
|
291
|
-
until the retry window (
|
|
350
|
+
until the retry window (5 minutes by default) runs out:
|
|
292
351
|
|
|
293
352
|
- 429 (run API rate limit, dataset backpressure): always; nothing was applied.
|
|
294
353
|
- 503 from the limiter or HAProxy: always; nothing was forwarded.
|
|
295
354
|
- 500/502/503/504, read/write timeouts and dropped connections:
|
|
296
|
-
idempotent calls
|
|
355
|
+
idempotent calls, and writes with an ``idempotency_key`` once the API
|
|
356
|
+
is known to de-duplicate them (a re-send returns the original answer).
|
|
297
357
|
- Refused connections and connect/pool timeouts: always; the request
|
|
298
358
|
never left.
|
|
299
359
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
360
|
+
Any other write (KV PUT, a keyed push to an API that did not say it
|
|
361
|
+
de-duplicates) whose request may have reached the API is never re-sent:
|
|
362
|
+
a re-send could apply it twice. Such failures surface to the caller
|
|
363
|
+
instead, with ``outcome_unknown = True`` when an attempt may have been
|
|
364
|
+
applied.
|
|
303
365
|
|
|
304
366
|
``retry_deadline`` (a ``time.monotonic()`` value) ends the retries
|
|
305
367
|
earlier, so a caller with a time budget (e.g. the exit flush) gets an
|
|
@@ -319,6 +381,21 @@ class ApiClient:
|
|
|
319
381
|
|
|
320
382
|
if idempotent is None:
|
|
321
383
|
idempotent = method.upper() in IDEMPOTENT_METHODS
|
|
384
|
+
if idempotency_key is not None:
|
|
385
|
+
request_headers['Idempotency-Key'] = idempotency_key
|
|
386
|
+
|
|
387
|
+
def safe_to_resend() -> bool:
|
|
388
|
+
# Idempotent calls, and keyed writes once the API showed it
|
|
389
|
+
# de-duplicates them (learned from any keyed answer).
|
|
390
|
+
return bool(idempotent) or (idempotency_key is not None and api_dedupes_keyed_writes(url))
|
|
391
|
+
|
|
392
|
+
# An attempt of a non-idempotent call may have been applied (timeout,
|
|
393
|
+
# dropped connection, 5xx): the final error must not claim "nothing applied".
|
|
394
|
+
maybe_applied = False
|
|
395
|
+
|
|
396
|
+
def fail(exc: BaseException) -> BaseException:
|
|
397
|
+
return _mark_outcome_unknown(exc) if maybe_applied and not idempotent else exc
|
|
398
|
+
|
|
322
399
|
deadline = time.monotonic() + max(0.0, api_retry_window_secs())
|
|
323
400
|
if retry_deadline is not None:
|
|
324
401
|
deadline = min(deadline, retry_deadline)
|
|
@@ -355,36 +432,53 @@ class ApiClient:
|
|
|
355
432
|
except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ConnectError) as exc:
|
|
356
433
|
# Never reached the API: safe for every method.
|
|
357
434
|
if isinstance(exc, (httpx.ConnectTimeout, httpx.PoolTimeout)) and do_not_retry_timeouts:
|
|
358
|
-
raise
|
|
435
|
+
raise fail(exc)
|
|
359
436
|
if await backoff(f'failed ({type(exc).__name__})', None):
|
|
360
437
|
continue
|
|
361
|
-
raise
|
|
438
|
+
raise fail(exc)
|
|
362
439
|
except httpx.TimeoutException as exc:
|
|
363
440
|
# Read/write timeout: the request may have been applied.
|
|
364
|
-
|
|
441
|
+
maybe_applied = True
|
|
442
|
+
if safe_to_resend() and not do_not_retry_timeouts and await backoff(f'timed out ({type(exc).__name__})', None):
|
|
365
443
|
continue
|
|
366
|
-
raise
|
|
444
|
+
raise fail(exc)
|
|
367
445
|
except httpx.TransportError as exc:
|
|
368
|
-
# Reset mid-request: only
|
|
369
|
-
|
|
446
|
+
# Reset mid-request: re-sent only when that is safe.
|
|
447
|
+
maybe_applied = True
|
|
448
|
+
if safe_to_resend() and await backoff(f'failed ({type(exc).__name__})', None):
|
|
370
449
|
continue
|
|
371
|
-
raise
|
|
450
|
+
raise fail(exc)
|
|
372
451
|
|
|
373
452
|
self._rate_limiter.update_from_headers(response.headers)
|
|
374
453
|
if response.status_code == 429:
|
|
375
454
|
self._rate_limiter.record_rate_limited()
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
response
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
455
|
+
if idempotency_key is not None:
|
|
456
|
+
_note_keyed_answer(url, response)
|
|
457
|
+
|
|
458
|
+
if not response.is_success:
|
|
459
|
+
status = response.status_code
|
|
460
|
+
error_type = _error_type(response) if status == 409 or status >= 500 else None
|
|
461
|
+
body_text = response.text if status == 503 else None
|
|
462
|
+
# A 5xx other than the limiter's / HAProxy's 503 may come after the write was applied.
|
|
463
|
+
if not idempotent and status >= 500 and not is_retryable_response(status, False, error_type, body_text):
|
|
464
|
+
maybe_applied = True
|
|
465
|
+
if idempotency_key is not None and error_type == _OUTCOME_UNKNOWN:
|
|
466
|
+
# Stored or not, nobody knows: the API refuses repeats of this key. Final.
|
|
467
|
+
maybe_applied = True
|
|
468
|
+
else:
|
|
469
|
+
# 409 for a keyed write: an earlier attempt is still being stored.
|
|
470
|
+
in_progress = idempotency_key is not None and status == 409 and error_type == _KEY_IN_PROGRESS
|
|
471
|
+
if in_progress or is_retryable_response(status, safe_to_resend(), error_type, body_text):
|
|
472
|
+
if await backoff(f'answered {status}', response.headers.get('Retry-After')):
|
|
473
|
+
continue
|
|
474
|
+
if in_progress:
|
|
475
|
+
maybe_applied = True
|
|
385
476
|
|
|
386
477
|
if raise_for_status:
|
|
387
|
-
|
|
478
|
+
try:
|
|
479
|
+
await self._raise_for_status(response)
|
|
480
|
+
except ApiClientError as exc:
|
|
481
|
+
raise fail(exc)
|
|
388
482
|
return response
|
|
389
483
|
|
|
390
484
|
async def get(
|
|
@@ -416,6 +510,7 @@ class ApiClient:
|
|
|
416
510
|
raise_for_status: bool = True,
|
|
417
511
|
idempotent: Optional[bool] = None,
|
|
418
512
|
retry_deadline: Optional[float] = None,
|
|
513
|
+
idempotency_key: Optional[str] = None,
|
|
419
514
|
) -> httpx.Response:
|
|
420
515
|
return await self.request(
|
|
421
516
|
'POST',
|
|
@@ -427,6 +522,7 @@ class ApiClient:
|
|
|
427
522
|
raise_for_status=raise_for_status,
|
|
428
523
|
idempotent=idempotent,
|
|
429
524
|
retry_deadline=retry_deadline,
|
|
525
|
+
idempotency_key=idempotency_key,
|
|
430
526
|
)
|
|
431
527
|
|
|
432
528
|
async def put(
|
|
@@ -479,8 +575,11 @@ class ApiClient:
|
|
|
479
575
|
headers: Optional[Dict[str, str]] = None,
|
|
480
576
|
*,
|
|
481
577
|
idempotent: Optional[bool] = None,
|
|
578
|
+
idempotency_key: Optional[str] = None,
|
|
482
579
|
) -> Any:
|
|
483
|
-
response = await self.post(
|
|
580
|
+
response = await self.post(
|
|
581
|
+
path, json_data=data, params=params, headers=headers, idempotent=idempotent, idempotency_key=idempotency_key
|
|
582
|
+
)
|
|
484
583
|
if not response.content:
|
|
485
584
|
return {}
|
|
486
585
|
try:
|
|
@@ -182,7 +182,11 @@ class ApiStorageClient:
|
|
|
182
182
|
dataset_id: str,
|
|
183
183
|
items: Sequence[Mapping[str, Any]],
|
|
184
184
|
) -> None:
|
|
185
|
-
|
|
185
|
+
# One key per push, the same on every attempt: the API stores a re-sent
|
|
186
|
+
# push once, so a push whose connection dropped can be retried.
|
|
187
|
+
await self.api_client.post_json(
|
|
188
|
+
f'/v2/datasets/{dataset_id}/items', list(items), idempotency_key=str(uuid.uuid4())
|
|
189
|
+
)
|
|
186
190
|
|
|
187
191
|
async def dataset_list_items(
|
|
188
192
|
self,
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Dataset pushes carry an Idempotency-Key, so a push whose connection drops
|
|
2
|
+
after sending (socket closed, timeout, 5xx) is re-sent with the same key and
|
|
3
|
+
the API stores it once. The SDK re-sends such a push only after the API showed
|
|
4
|
+
it de-duplicates (X-Scrapely-Idempotency), so an older API never gets a blind
|
|
5
|
+
re-send. A charged push charges once, and its charge is not voided when an
|
|
6
|
+
attempt may have stored the rows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from scrapely.charging import _push_certainly_failed, push_data_and_charge
|
|
18
|
+
from scrapely.storage import api_client as mod
|
|
19
|
+
from scrapely.storage.api_client import (
|
|
20
|
+
DEFAULT_API_RETRY_WINDOW_SECS,
|
|
21
|
+
ApiClient,
|
|
22
|
+
ApiClientError,
|
|
23
|
+
_reset_keyed_write_support,
|
|
24
|
+
api_dedupes_keyed_writes,
|
|
25
|
+
api_retry_window_secs,
|
|
26
|
+
)
|
|
27
|
+
from scrapely.storage.client import ApiStorageClient
|
|
28
|
+
|
|
29
|
+
from test_charging_pending import FakeClient, _make_manager
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def no_sleep(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
34
|
+
clock = [1000.0]
|
|
35
|
+
|
|
36
|
+
async def fake_sleep(secs: float) -> None:
|
|
37
|
+
clock[0] += secs
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr(mod.asyncio, 'sleep', fake_sleep)
|
|
40
|
+
monkeypatch.setattr(mod.time, 'monotonic', lambda: clock[0])
|
|
41
|
+
_reset_keyed_write_support()
|
|
42
|
+
yield
|
|
43
|
+
_reset_keyed_write_support()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeApi:
|
|
47
|
+
"""Stores pushes once per Idempotency-Key, like the server; scripted failures first."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, script: List[str]) -> None:
|
|
50
|
+
self.script = list(script)
|
|
51
|
+
self.seen: List[Dict[str, Any]] = []
|
|
52
|
+
self.stored: List[Any] = []
|
|
53
|
+
self.keys: set = set()
|
|
54
|
+
|
|
55
|
+
def _store(self, key: Optional[str], body: bytes) -> bool:
|
|
56
|
+
if key and key in self.keys:
|
|
57
|
+
return False
|
|
58
|
+
if key:
|
|
59
|
+
self.keys.add(key)
|
|
60
|
+
self.stored.append(json.loads(body))
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
64
|
+
key = request.headers.get('Idempotency-Key')
|
|
65
|
+
body = request.read()
|
|
66
|
+
self.seen.append({'key': key, 'body': body})
|
|
67
|
+
step = self.script.pop(0) if len(self.script) > 1 else self.script[0]
|
|
68
|
+
if step == 'drop': # stored, then the connection closed before the answer
|
|
69
|
+
self._store(key, body)
|
|
70
|
+
raise httpx.RemoteProtocolError('Server disconnected without sending a response.', request=request)
|
|
71
|
+
if step == 'timeout': # stored, answer never arrived
|
|
72
|
+
self._store(key, body)
|
|
73
|
+
raise httpx.ReadTimeout('timed out', request=request)
|
|
74
|
+
if step == 'plain201': # an API without idempotency
|
|
75
|
+
self._store(key, body)
|
|
76
|
+
return httpx.Response(201, json={})
|
|
77
|
+
if step == '502':
|
|
78
|
+
return httpx.Response(502, json={'error': {'type': 'bad-gateway'}})
|
|
79
|
+
if step == '409':
|
|
80
|
+
return httpx.Response(409, headers={'Retry-After': '0'}, json={'error': {'type': 'idempotency-key-in-progress'}})
|
|
81
|
+
if step == 'unknown':
|
|
82
|
+
return httpx.Response(503, headers={'X-Scrapely-Idempotency': 'refused'}, json={'error': {'type': 'push-outcome-unknown', 'message': 'unknown'}})
|
|
83
|
+
if step == '400':
|
|
84
|
+
return httpx.Response(400, json={'error': {'type': 'invalid-input', 'message': 'bad'}})
|
|
85
|
+
assert step == 'dedupe'
|
|
86
|
+
fresh = self._store(key, body)
|
|
87
|
+
headers = {'X-Scrapely-Idempotency': 'stored' if fresh else 'replayed'} if key else {}
|
|
88
|
+
return httpx.Response(201, headers=headers, json={})
|
|
89
|
+
|
|
90
|
+
def storage(self) -> ApiStorageClient:
|
|
91
|
+
return ApiStorageClient('http://api.test', 'st_x', 'run1', transport=httpx.MockTransport(self.handler))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_default_retry_window_is_five_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
95
|
+
monkeypatch.delenv('SCRAPELY_API_RETRY_WINDOW_SECS', raising=False)
|
|
96
|
+
assert DEFAULT_API_RETRY_WINDOW_SECS == 300.0
|
|
97
|
+
assert ApiClient.retry_window_secs == 300.0
|
|
98
|
+
assert api_retry_window_secs() == 300.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_each_push_has_its_own_key_and_the_header_teaches_support() -> None:
|
|
102
|
+
api = FakeApi(['dedupe'])
|
|
103
|
+
s = api.storage()
|
|
104
|
+
await s.dataset_push_items('ds', [{'a': 1}])
|
|
105
|
+
await s.dataset_push_items('ds', [{'a': 2}])
|
|
106
|
+
keys = [x['key'] for x in api.seen]
|
|
107
|
+
assert all(keys) and keys[0] != keys[1]
|
|
108
|
+
assert len(keys[0]) == 36
|
|
109
|
+
assert api_dedupes_keyed_writes() is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def test_api_without_idempotency_does_not_get_a_resend() -> None:
|
|
113
|
+
api = FakeApi(['plain201', 'drop', 'plain201'])
|
|
114
|
+
s = api.storage()
|
|
115
|
+
await s.dataset_push_items('ds', [{'warmup': True}])
|
|
116
|
+
assert api_dedupes_keyed_writes() is False
|
|
117
|
+
with pytest.raises(httpx.RemoteProtocolError) as e:
|
|
118
|
+
await s.dataset_push_items('ds', [{'a': 1}])
|
|
119
|
+
assert len(api.seen) == 2, 'not re-sent'
|
|
120
|
+
assert getattr(e.value, 'outcome_unknown', False) is True
|
|
121
|
+
assert _push_certainly_failed(e.value) is False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_support_is_per_origin_and_revoked_by_a_plain_2xx() -> None:
|
|
125
|
+
api_a = FakeApi(['dedupe'])
|
|
126
|
+
await api_a.storage().dataset_push_items('ds', [{'a': 1}])
|
|
127
|
+
assert api_dedupes_keyed_writes('http://api.test/v2/x') is True
|
|
128
|
+
api_b = FakeApi(['drop'])
|
|
129
|
+
b = ApiStorageClient('http://other.test', 'st_x', 'run1', transport=httpx.MockTransport(api_b.handler))
|
|
130
|
+
assert api_dedupes_keyed_writes('http://other.test/v2/x') is False
|
|
131
|
+
with pytest.raises(httpx.RemoteProtocolError):
|
|
132
|
+
await b.dataset_push_items('ds', [{'b': 1}])
|
|
133
|
+
assert len(api_b.seen) == 1, 'another origin is not trusted'
|
|
134
|
+
|
|
135
|
+
# Mixed versions behind one origin: a 2xx without the header turns retries off.
|
|
136
|
+
_reset_keyed_write_support()
|
|
137
|
+
api = FakeApi(['dedupe', 'plain201', 'drop', 'dedupe'])
|
|
138
|
+
s = api.storage()
|
|
139
|
+
await s.dataset_push_items('ds', [{'a': 1}])
|
|
140
|
+
assert api_dedupes_keyed_writes('http://api.test') is True
|
|
141
|
+
await s.dataset_push_items('ds', [{'a': 2}])
|
|
142
|
+
assert api_dedupes_keyed_writes('http://api.test') is False
|
|
143
|
+
with pytest.raises(httpx.RemoteProtocolError):
|
|
144
|
+
await s.dataset_push_items('ds', [{'a': 3}])
|
|
145
|
+
assert len(api.seen) == 3, 'the dropped push was not re-sent'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize('failure', ['drop', 'timeout'])
|
|
149
|
+
async def test_dropped_push_is_resent_with_the_same_key_and_stored_once(failure: str) -> None:
|
|
150
|
+
_reset_keyed_write_support(True)
|
|
151
|
+
api = FakeApi([failure, 'dedupe'])
|
|
152
|
+
await api.storage().dataset_push_items('ds', [{'a': 1}, {'a': 2}])
|
|
153
|
+
assert len(api.seen) == 2
|
|
154
|
+
assert api.seen[0]['key'] == api.seen[1]['key']
|
|
155
|
+
assert api.seen[0]['body'] == api.seen[1]['body']
|
|
156
|
+
assert api.stored == [[{'a': 1}, {'a': 2}]]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def test_5xx_and_409_are_retried_outcome_unknown_is_final() -> None:
|
|
160
|
+
_reset_keyed_write_support(True)
|
|
161
|
+
api = FakeApi(['502', '409', 'dedupe'])
|
|
162
|
+
await api.storage().dataset_push_items('ds', [{'a': 1}])
|
|
163
|
+
assert len(api.seen) == 3
|
|
164
|
+
assert len({x['key'] for x in api.seen}) == 1
|
|
165
|
+
assert len(api.stored) == 1
|
|
166
|
+
|
|
167
|
+
api = FakeApi(['unknown'])
|
|
168
|
+
with pytest.raises(ApiClientError) as e:
|
|
169
|
+
await api.storage().dataset_push_items('ds', [{'a': 1}])
|
|
170
|
+
assert e.value.status_code == 503
|
|
171
|
+
assert len(api.seen) == 1
|
|
172
|
+
assert _push_certainly_failed(e.value) is False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_final_4xx_after_a_maybe_applied_attempt_is_not_certain() -> None:
|
|
176
|
+
_reset_keyed_write_support(True)
|
|
177
|
+
api = FakeApi(['drop', '400'])
|
|
178
|
+
with pytest.raises(ApiClientError) as e:
|
|
179
|
+
await api.storage().dataset_push_items('ds', [{'a': 1}])
|
|
180
|
+
assert len(api.seen) == 2
|
|
181
|
+
assert _push_certainly_failed(e.value) is False
|
|
182
|
+
|
|
183
|
+
api = FakeApi(['400'])
|
|
184
|
+
with pytest.raises(ApiClientError) as e2:
|
|
185
|
+
await api.storage().dataset_push_items('ds', [{'a': 1}])
|
|
186
|
+
assert _push_certainly_failed(e2.value) is True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def test_kv_writes_send_no_key_and_are_not_resent() -> None:
|
|
190
|
+
_reset_keyed_write_support(True)
|
|
191
|
+
api = FakeApi(['drop', 'plain201'])
|
|
192
|
+
client = api.storage().api_client
|
|
193
|
+
with pytest.raises(httpx.RemoteProtocolError):
|
|
194
|
+
await client.put('/v2/key-value-stores/kv/records/k', json_data={'a': 1})
|
|
195
|
+
assert len(api.seen) == 1
|
|
196
|
+
assert api.seen[0]['key'] is None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_charged_push_resent_after_drop_charges_once_and_stores_once() -> None:
|
|
200
|
+
_reset_keyed_write_support(True)
|
|
201
|
+
client = FakeClient()
|
|
202
|
+
mgr = _make_manager(client)
|
|
203
|
+
api = FakeApi(['drop', 'dedupe'])
|
|
204
|
+
storage = api.storage()
|
|
205
|
+
|
|
206
|
+
async def push(items: Any) -> None:
|
|
207
|
+
await storage.dataset_push_items('ds', items)
|
|
208
|
+
|
|
209
|
+
result = await push_data_and_charge(mgr, [{'a': 1}, {'a': 2}], 'page-open', False, push)
|
|
210
|
+
assert result.charged_count == 2
|
|
211
|
+
assert len(api.seen) == 2
|
|
212
|
+
assert len(api.stored) == 1
|
|
213
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1
|
|
214
|
+
assert not [p for p in client.posts if p['path'].endswith('/charge/void')]
|
|
215
|
+
assert mgr.get_charged_event_count('page-open') == 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def test_charged_push_that_may_have_stored_keeps_its_charge() -> None:
|
|
219
|
+
_reset_keyed_write_support(True)
|
|
220
|
+
client = FakeClient()
|
|
221
|
+
mgr = _make_manager(client)
|
|
222
|
+
api = FakeApi(['drop', 'unknown'])
|
|
223
|
+
storage = api.storage()
|
|
224
|
+
|
|
225
|
+
async def push(items: Any) -> None:
|
|
226
|
+
await storage.dataset_push_items('ds', items)
|
|
227
|
+
|
|
228
|
+
with pytest.raises(ApiClientError):
|
|
229
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, push)
|
|
230
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1
|
|
231
|
+
assert not [p for p in client.posts if p['path'].endswith('/charge/void')], 'rows may be stored: charge stands'
|
|
232
|
+
|
|
233
|
+
# Certainly not stored (a 4xx on the only attempt): voided as before.
|
|
234
|
+
api2 = FakeApi(['400'])
|
|
235
|
+
storage2 = api2.storage()
|
|
236
|
+
|
|
237
|
+
async def push2(items: Any) -> None:
|
|
238
|
+
await storage2.dataset_push_items('ds', items)
|
|
239
|
+
|
|
240
|
+
with pytest.raises(ApiClientError):
|
|
241
|
+
await push_data_and_charge(mgr, [{'a': 2}], 'page-open', False, push2)
|
|
242
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge/void')]) == 1
|
|
@@ -128,3 +128,22 @@ async def test_context_manager_status_message_is_terminal(lifecycle, monkeypatch
|
|
|
128
128
|
('/v2/actor-runs/run123', {'statusMessage': 'Crashed while parsing', 'isStatusMessageTerminal': True}),
|
|
129
129
|
)
|
|
130
130
|
assert events[1][0] == 'shutdown'
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_progress_status_message_is_best_effort(lifecycle, caplog: pytest.LogCaptureFixture) -> None:
|
|
134
|
+
"""A non-terminal update that still fails after the retry window (e.g. a long 503)
|
|
135
|
+
logs a warning and returns instead of raising and crashing the run."""
|
|
136
|
+
events, exit_codes, client = lifecycle
|
|
137
|
+
client.fail = True
|
|
138
|
+
with caplog.at_level('WARNING'):
|
|
139
|
+
run = await Actor.set_status_message('Scraped 10 pages')
|
|
140
|
+
assert run.id == ''
|
|
141
|
+
assert 'Failed to update the status message' in caplog.text
|
|
142
|
+
assert exit_codes == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_terminal_status_message_still_raises(lifecycle) -> None:
|
|
146
|
+
_, _, client = lifecycle
|
|
147
|
+
client.fail = True
|
|
148
|
+
with pytest.raises(RuntimeError, match='API down'):
|
|
149
|
+
await Actor.set_status_message('Done', is_terminal=True)
|
|
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
|
|
File without changes
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/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.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_kvs_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_rq_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/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
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.14 → scrapely_python-0.1.15}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|