scrapely-python 0.1.13__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.
Files changed (69) hide show
  1. {scrapely_python-0.1.13/src/scrapely_python.egg-info → scrapely_python-0.1.15}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/actor.py +30 -3
  5. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/charging.py +13 -3
  6. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/api_client.py +125 -26
  7. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/client.py +5 -1
  8. {scrapely_python-0.1.13 → scrapely_python-0.1.15/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  9. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
  10. scrapely_python-0.1.15/tests/test_push_idempotency.py +242 -0
  11. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_terminal_status_message.py +19 -0
  12. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/LICENSE +0 -0
  13. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/LICENSE-APACHE +0 -0
  14. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/NOTICE +0 -0
  15. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/README.md +0 -0
  16. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/setup.cfg +0 -0
  17. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/_browser_locations.py +0 -0
  18. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/browser_configuration.py +0 -0
  19. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/cdp_retry.py +0 -0
  20. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/charging_client.py +0 -0
  21. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/configuration.py +0 -0
  22. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/crypto/__init__.py +0 -0
  23. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/crypto/_secrets.py +0 -0
  24. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/errors.py +0 -0
  25. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/events/__init__.py +0 -0
  26. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/events/_types.py +0 -0
  27. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/events/event_manager.py +0 -0
  28. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/log.py +0 -0
  29. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/patch_browser_pool.py +0 -0
  30. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/pricing_types.py +0 -0
  31. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/proxy_configuration.py +0 -0
  32. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/py.typed +0 -0
  33. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/request_loaders/__init__.py +0 -0
  34. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  35. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/__init__.py +0 -0
  36. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/__init__.py +0 -0
  37. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  38. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  39. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/_metadata.py +0 -0
  40. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/patch_storage.py +0 -0
  41. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  42. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  43. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  44. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  46. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/types.py +0 -0
  47. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage/utils.py +0 -0
  48. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storage_clients/__init__.py +0 -0
  49. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/storages/__init__.py +0 -0
  50. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/types.py +0 -0
  51. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely/webhook.py +0 -0
  52. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  53. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/requires.txt +0 -0
  54. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/src/scrapely_python.egg-info/top_level.txt +0 -0
  55. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_api_client_retries.py +0 -0
  56. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_apify_parity.py +0 -0
  57. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_browser_configuration.py +0 -0
  58. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_cdp_retry.py +0 -0
  59. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_charge_limit_guard.py +0 -0
  60. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_charge_retries.py +0 -0
  61. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_charging_parity.py +0 -0
  62. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_charging_pending.py +0 -0
  63. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_input_proxy_aliases.py +0 -0
  64. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_parity.py +0 -0
  65. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_patch_browser_pool.py +0 -0
  66. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_proxy_configuration.py +0 -0
  67. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_run_control.py +0 -0
  68. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_secrets.py +0 -0
  69. {scrapely_python-0.1.13 → scrapely_python-0.1.15}/tests/test_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.13
3
+ Version: 0.1.15
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.13"
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"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.13'
135
+ __version__ = '0.1.15'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -213,6 +213,14 @@ class _ClassProperty:
213
213
  return self.func(owner)
214
214
 
215
215
 
216
+ def _system_exit_code(exc: Any) -> int:
217
+ """Process exit code carried by a SystemExit (sys.exit semantics)."""
218
+ code = getattr(exc, 'code', None)
219
+ if code is None:
220
+ return 0
221
+ return code if isinstance(code, int) else 1
222
+
223
+
216
224
  class _ActorMeta(type):
217
225
  """Metaclass enabling async with Actor: by delegating to classmethods."""
218
226
 
@@ -226,7 +234,11 @@ class _ActorMeta(type):
226
234
  return cls
227
235
 
228
236
  async def __aexit__(cls, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
229
- if exc_type is not None:
237
+ if exc_type is not None and issubclass(exc_type, SystemExit):
238
+ # Actor.exit()/fail()/sys.exit() inside `async with Actor:` is a deliberate
239
+ # exit, not a failure (Apify parity): keep its exit code, log nothing.
240
+ cls._exit_code = _system_exit_code(exc_val)
241
+ elif exc_type is not None:
230
242
  cls._exit_code = 1
231
243
  logger.error(f'Actor failed: {exc_val}')
232
244
  await cls._shutdown()
@@ -286,7 +298,10 @@ class Actor(metaclass=_ActorMeta):
286
298
 
287
299
  async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
288
300
  cls = type(self)
289
- if exc_type is not None:
301
+ if exc_type is not None and issubclass(exc_type, SystemExit):
302
+ # Deliberate exit via Actor.exit()/fail()/sys.exit(), not a failure.
303
+ cls._exit_code = _system_exit_code(exc_val)
304
+ elif exc_type is not None:
290
305
  cls._exit_code = 1
291
306
  logger.error(f'Actor failed: {exc_val}')
292
307
  else:
@@ -1039,7 +1054,19 @@ class Actor(metaclass=_ActorMeta):
1039
1054
  else:
1040
1055
  logger.info(logged)
1041
1056
 
1042
- result = await cls._put_run_status_message(message, is_terminal=is_terminal)
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='')
1043
1070
  if result is None:
1044
1071
  return ActorRun(id='', actor_id='', status='')
1045
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 (2 minutes, SCRAPELY_API_RETRY_WINDOW_SECS): re-sending
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 = 120.0
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 = 120.0
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
- (2 minutes by default).
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 a main redeploy.
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 (2 minutes by default) runs out:
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 only.
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
- 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.
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
- if idempotent and not do_not_retry_timeouts and await backoff(f'timed out ({type(exc).__name__})', None):
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 idempotent calls are re-sent.
369
- if idempotent and await backoff(f'failed ({type(exc).__name__})', None):
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
- 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,
382
- ):
383
- if await backoff(f'answered {response.status_code}', response.headers.get('Retry-After')):
384
- continue
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
- await self._raise_for_status(response)
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(path, json_data=data, params=params, headers=headers, idempotent=idempotent)
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
- await self.api_client.post_json(f'/v2/datasets/{dataset_id}/items', list(items))
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.13
3
+ Version: 0.1.15
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
@@ -60,6 +60,7 @@ tests/test_input_proxy_aliases.py
60
60
  tests/test_parity.py
61
61
  tests/test_patch_browser_pool.py
62
62
  tests/test_proxy_configuration.py
63
+ tests/test_push_idempotency.py
63
64
  tests/test_run_control.py
64
65
  tests/test_secrets.py
65
66
  tests/test_storage.py
@@ -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)