scrapely-python 0.1.3__tar.gz → 0.1.5__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 (59) hide show
  1. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/charging.py +76 -16
  5. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/api_client.py +109 -10
  6. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  7. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
  8. scrapely_python-0.1.5/tests/test_api_client_retries.py +170 -0
  9. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_charging_pending.py +83 -0
  10. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_storage.py +5 -1
  11. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/README.md +0 -0
  12. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/setup.cfg +0 -0
  13. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/_browser_locations.py +0 -0
  14. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/actor.py +0 -0
  15. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/browser_configuration.py +0 -0
  16. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/charging_client.py +0 -0
  17. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/configuration.py +0 -0
  18. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/crypto/__init__.py +0 -0
  19. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/crypto/_secrets.py +0 -0
  20. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/errors.py +0 -0
  21. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/events/__init__.py +0 -0
  22. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/events/_types.py +0 -0
  23. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/events/event_manager.py +0 -0
  24. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/log.py +0 -0
  25. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/patch_browser_pool.py +0 -0
  26. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/pricing_types.py +0 -0
  27. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/proxy_configuration.py +0 -0
  28. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/py.typed +0 -0
  29. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/request_loaders/__init__.py +0 -0
  30. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  31. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/__init__.py +0 -0
  32. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/__init__.py +0 -0
  33. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  34. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  35. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/_metadata.py +0 -0
  36. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/client.py +0 -0
  37. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/patch_storage.py +0 -0
  38. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  39. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  40. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  41. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  42. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  43. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/types.py +0 -0
  44. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage/utils.py +0 -0
  45. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storage_clients/__init__.py +0 -0
  46. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/storages/__init__.py +0 -0
  47. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/types.py +0 -0
  48. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely/webhook.py +0 -0
  49. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  50. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/requires.txt +0 -0
  51. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/top_level.txt +0 -0
  52. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_apify_parity.py +0 -0
  53. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_browser_configuration.py +0 -0
  54. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_charging_parity.py +0 -0
  55. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_parity.py +0 -0
  56. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_patch_browser_pool.py +0 -0
  57. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_proxy_configuration.py +0 -0
  58. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_run_control.py +0 -0
  59. {scrapely_python-0.1.3 → scrapely_python-0.1.5}/tests/test_secrets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.3'
135
+ __version__ = '0.1.5'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -199,7 +199,7 @@ class ChargingManager:
199
199
  # (push_data_and_charge voids it when the push then fails).
200
200
  self._last_accepted_operation_id: Optional[str] = None
201
201
  # Voids not yet confirmed by the platform; retried before the next push and at exit.
202
- self._pending_voids: List[tuple[str, str, int]] = []
202
+ self._pending_voids: List[tuple[str, str, int, bool]] = []
203
203
 
204
204
  def close(self) -> None:
205
205
  """Mark the manager closed; subsequent charge/push operations are rejected."""
@@ -439,7 +439,18 @@ class ChargingManager:
439
439
  self._last_accepted_operation_id = None
440
440
  return op_id
441
441
 
442
- async def _void_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
442
+ async def _cancel_uncertain_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
443
+ """Cancel an unconfirmed charge for items that were then not stored.
444
+
445
+ Drops the pending intent (so the exit flush never posts it) and voids it in
446
+ case the original request did apply; a 404 means it never applied.
447
+ """
448
+ self._drop_pending(operation_id)
449
+ return await self._void_charge(event_name, operation_id, reserved, not_found_means_voided=True)
450
+
451
+ async def _void_charge(
452
+ self, event_name: str, operation_id: str, reserved: int, not_found_means_voided: bool = False
453
+ ) -> bool:
443
454
  """Reverse an accepted charge whose items were never stored, then unwind locally.
444
455
 
445
456
  POST /v2/actor-runs/{run}/charge/void with the charge's Idempotency-Key.
@@ -458,13 +469,19 @@ class ChargingManager:
458
469
  return True
459
470
  except Exception as err: # noqa: BLE001
460
471
  status = _http_status_of(err)
472
+ # Only the void route's own answer proves it never applied; a server
473
+ # without the route (older deployment) also answers 404.
474
+ if status == 404 and not_found_means_voided and getattr(err, 'type', None) == 'charge-not-found':
475
+ # The unconfirmed charge never reached the ledger: nothing billed.
476
+ self._unwind_reservation(event_name, reserved)
477
+ return True
461
478
  # 404: nothing to void; 409: already settled; 4xx auth: permanent.
462
479
  if status is not None and 400 <= status < 500:
463
480
  return False
464
481
  if attempt + 1 < self._POST_ATTEMPTS:
465
482
  await asyncio.sleep(0.25 * (attempt + 1))
466
483
  # Outcome unknown: keep the reservation and retry before the next push / at exit.
467
- self._pending_voids.append((event_name, operation_id, reserved))
484
+ self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
468
485
  logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
469
486
  return False
470
487
 
@@ -474,8 +491,8 @@ class ChargingManager:
474
491
  Returns False while any earlier void is still unconfirmed.
475
492
  """
476
493
  pending, self._pending_voids = self._pending_voids, []
477
- for event_name, operation_id, reserved in pending:
478
- await self._void_charge(event_name, operation_id, reserved)
494
+ for event_name, operation_id, reserved, not_found_means_voided in pending:
495
+ await self._void_charge(event_name, operation_id, reserved, not_found_means_voided)
479
496
  return not self._pending_voids
480
497
 
481
498
  def _quarantine_stale_pending(self) -> None:
@@ -1158,19 +1175,39 @@ async def push_data_and_charge(
1158
1175
  # Synthetic storage event first (local-only); then explicit billable event.
1159
1176
  reserved_in_op: list[tuple[str, int]] = []
1160
1177
  operation_ids: list[Optional[str]] = []
1178
+ uncertain_ops: set[str] = set()
1179
+
1180
+ async def charge_one(name: str) -> None:
1181
+ charging_manager._take_last_accepted_operation_id()
1182
+ try:
1183
+ r = await charging_manager._charge_exclusive(name, len(limited_items))
1184
+ op_id = charging_manager._take_last_accepted_operation_id()
1185
+ except ChargeUncertainError as unconfirmed:
1186
+ # The platform did not confirm the charge (5xx / timeouts after
1187
+ # retries). Never drop the items for that: the intent stays
1188
+ # pending with its idempotency key and is re-posted by the exit
1189
+ # flush, so the items are stored now and billed once.
1190
+ logger.warning(
1191
+ 'Charge for %r not confirmed yet (%s); storing the items and retrying the charge later',
1192
+ name,
1193
+ unconfirmed.operation_id,
1194
+ )
1195
+ r = ChargeResult(
1196
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1197
+ charged_count=unconfirmed.count,
1198
+ event_charge_limit_reached=False,
1199
+ )
1200
+ op_id = unconfirmed.operation_id
1201
+ uncertain_ops.add(op_id)
1202
+ results.append(r)
1203
+ reserved_in_op.append((name, r.charged_count))
1204
+ operation_ids.append(op_id)
1205
+
1161
1206
  try:
1162
1207
  if default_event:
1163
- charging_manager._take_last_accepted_operation_id()
1164
- r = await charging_manager._charge_exclusive(default_event, len(limited_items))
1165
- results.append(r)
1166
- reserved_in_op.append((default_event, r.charged_count))
1167
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1208
+ await charge_one(default_event)
1168
1209
  if event_name and event_name != default_event:
1169
- charging_manager._take_last_accepted_operation_id()
1170
- r = await charging_manager._charge_exclusive(event_name, len(limited_items))
1171
- results.append(r)
1172
- reserved_in_op.append((event_name, r.charged_count))
1173
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1210
+ await charge_one(event_name)
1174
1211
  except Exception:
1175
1212
  for name, reserved in reserved_in_op:
1176
1213
  if reserved > 0:
@@ -1192,11 +1229,31 @@ async def push_data_and_charge(
1192
1229
  )
1193
1230
  # Unwind any event that reserved more than we will persist
1194
1231
  # (synthetic default-dataset is local-only and otherwise sticks).
1232
+ platform_op_names = {
1233
+ name for (name, _), op_id in zip(reserved_in_op, operation_ids) if op_id
1234
+ }
1195
1235
  for name, result in charged_events:
1196
1236
  if result.charged_count > accepted_for_items:
1237
+ # Nothing stored and a platform charge was made: the void
1238
+ # below releases its local count once the platform confirms.
1239
+ if accepted_for_items <= 0 and name in platform_op_names:
1240
+ continue
1197
1241
  charging_manager._unwind_reservation(
1198
1242
  name, result.charged_count - accepted_for_items
1199
1243
  )
1244
+ if accepted_for_items <= 0:
1245
+ # Nothing will be stored (another event of this push was capped
1246
+ # to zero): undo every platform charge this operation made, so
1247
+ # no result is billed for items that are never saved. Local
1248
+ # counts were unwound just above.
1249
+ for (name, reserved), op_id in zip(reserved_in_op, operation_ids):
1250
+ if reserved <= 0 or not op_id:
1251
+ continue
1252
+ # Original count: released only when the platform confirms.
1253
+ if op_id in uncertain_ops:
1254
+ await charging_manager._cancel_uncertain_charge(name, op_id, reserved)
1255
+ else:
1256
+ await charging_manager._void_charge(name, op_id, reserved)
1200
1257
  if accepted_for_items > 0:
1201
1258
  try:
1202
1259
  await push_fn(limited_items[:accepted_for_items])
@@ -1218,7 +1275,10 @@ async def push_data_and_charge(
1218
1275
  kept = min(accepted_for_items, next(r.charged_count for n, r in charged_events if n == name))
1219
1276
  if kept <= 0:
1220
1277
  continue
1221
- if op_id:
1278
+ if op_id and op_id in uncertain_ops:
1279
+ # Never re-post a charge for items that were not stored.
1280
+ await charging_manager._cancel_uncertain_charge(name, op_id, kept)
1281
+ elif op_id:
1222
1282
  await charging_manager._void_charge(name, op_id, kept)
1223
1283
  elif is_default_dataset_item_event(name):
1224
1284
  charging_manager._unwind_reservation(name, kept)
@@ -24,6 +24,32 @@ class ApiClientError(Exception):
24
24
  self.body = body
25
25
 
26
26
 
27
+ # PUT is left out: a KV record PUT is counted (and billed) as a write each time
28
+ # it is applied, so a lost response must not be re-sent blindly.
29
+ IDEMPOTENT_METHODS = frozenset({'GET', 'HEAD', 'DELETE', 'OPTIONS'})
30
+ # HAProxy's own 503 page: no backend was available, so nothing was forwarded.
31
+ NO_BACKEND_MARKER = 'No server is available to handle this request'
32
+
33
+
34
+ def is_retryable_response(
35
+ status: int, idempotent: bool, error_type: Optional[str] = None, body_text: Optional[str] = None
36
+ ) -> bool:
37
+ """Whether a non-2xx answer is retried.
38
+
39
+ Idempotent methods: 500/502/503/504. Others (dataset push, KV PUT) only on
40
+ a 503 that certainly was not processed: the rate limiter refused it
41
+ ("rate-limit-unavailable", before the handler), or HAProxy had no backend
42
+ (its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
43
+ (a direct dataset write whose commit was not confirmed), and re-sending
44
+ could store the items twice.
45
+ """
46
+ if idempotent:
47
+ return status in (500, 502, 503, 504)
48
+ if status != 503:
49
+ return False
50
+ return error_type == 'rate-limit-unavailable' or NO_BACKEND_MARKER in (body_text or '')
51
+
52
+
27
53
  def unwrap_data(value: Any) -> Any:
28
54
  """Unwrap Apify-style v2 response envelopes ({ data: ... })."""
29
55
  if isinstance(value, list):
@@ -33,6 +59,35 @@ def unwrap_data(value: Any) -> Any:
33
59
  return value
34
60
 
35
61
 
62
+ def _error_type(response: httpx.Response) -> Optional[str]:
63
+ try:
64
+ body = response.json()
65
+ except Exception:
66
+ return None
67
+ if isinstance(body, dict) and isinstance(body.get('error'), dict):
68
+ t = body['error'].get('type')
69
+ return t if isinstance(t, str) else None
70
+ return None
71
+
72
+
73
+ def server_retry_delay(server_attempt: int, retry_after: Optional[str], now: Optional[float] = None) -> float:
74
+ """Exponential backoff with jitter, in seconds; honours a Retry-After (seconds or HTTP date) of up to 30 s."""
75
+ if retry_after is not None and retry_after.strip():
76
+ secs: Optional[float] = None
77
+ try:
78
+ secs = float(retry_after)
79
+ except ValueError:
80
+ try:
81
+ from email.utils import parsedate_to_datetime
82
+
83
+ secs = max(0.0, parsedate_to_datetime(retry_after).timestamp() - (time.time() if now is None else now))
84
+ except Exception:
85
+ secs = None
86
+ if secs is not None and secs >= 0:
87
+ return min(secs, 30.0) + random.random() * 0.1
88
+ return min(8.0, 0.5 * 2 ** server_attempt) + random.random() * 0.25
89
+
90
+
36
91
  class AdaptiveRateLimiter:
37
92
  """Adaptive throttling based on X-RateLimit-* headers (TS AdaptiveRateLimiter)."""
38
93
 
@@ -116,6 +171,10 @@ class ApiClient:
116
171
 
117
172
  _rate_limiter = AdaptiveRateLimiter()
118
173
  _max_retries = 3
174
+ # Retries while the API is briefly unavailable (503/5xx, connection
175
+ # refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
176
+ # covers a main redeploy.
177
+ _max_server_retries = 8
119
178
 
120
179
  def __init__(
121
180
  self,
@@ -198,6 +257,7 @@ class ApiClient:
198
257
  headers: Optional[Dict[str, str]] = None,
199
258
  raise_for_status: bool = True,
200
259
  do_not_retry_timeouts: bool = False,
260
+ idempotent: Optional[bool] = None,
201
261
  ) -> httpx.Response:
202
262
  """Make an HTTP request with adaptive rate limiting and 429 retries."""
203
263
  client = await self._ensure_client()
@@ -208,8 +268,11 @@ class ApiClient:
208
268
  if json_data is not None and 'Content-Type' not in request_headers:
209
269
  request_headers['Content-Type'] = 'application/json'
210
270
 
211
- last_error: Optional[Exception] = None
212
- for attempt in range(self._max_retries + 1):
271
+ if idempotent is None:
272
+ idempotent = method.upper() in IDEMPOTENT_METHODS
273
+ attempt = 0 # timeouts and 429s
274
+ server_attempt = 0 # 503/5xx and connection failures (API briefly unavailable)
275
+ while True:
213
276
  await self._rate_limiter.wait_for_slot()
214
277
  try:
215
278
  response = await client.request(
@@ -220,27 +283,59 @@ class ApiClient:
220
283
  content=content,
221
284
  headers=request_headers or None,
222
285
  )
223
- except httpx.TimeoutException as exc:
224
- last_error = exc
286
+ except httpx.TimeoutException:
225
287
  if do_not_retry_timeouts or attempt >= self._max_retries:
226
288
  raise
227
- await asyncio.sleep(0.25 * (attempt + 1))
289
+ attempt += 1
290
+ await asyncio.sleep(0.25 * attempt)
228
291
  continue
292
+ except httpx.TransportError as exc:
293
+ # No response. A refused/unresolvable connection never reached
294
+ # the API, so it is safe for every method; anything else (reset
295
+ # mid-request) only for idempotent ones.
296
+ if (idempotent or isinstance(exc, httpx.ConnectError)) and server_attempt < self._max_server_retries:
297
+ wait = server_retry_delay(server_attempt, None)
298
+ server_attempt += 1
299
+ logger.warning(
300
+ '%s %s failed (%s); retry %d/%d in %.1fs',
301
+ method, path, type(exc).__name__, server_attempt, self._max_server_retries, wait,
302
+ )
303
+ await asyncio.sleep(wait)
304
+ continue
305
+ raise
229
306
 
230
307
  self._rate_limiter.update_from_headers(response.headers)
231
308
 
232
309
  if response.status_code == 429 and attempt < self._max_retries:
310
+ attempt += 1
233
311
  await self._rate_limiter.handle_rate_limited(response)
234
312
  continue
235
313
 
314
+ # API briefly unavailable (main redeploy, limiter store blip); see
315
+ # is_retryable_response for which answers are safe to re-send.
316
+ if (
317
+ not response.is_success
318
+ and server_attempt < self._max_server_retries
319
+ and is_retryable_response(
320
+ response.status_code,
321
+ idempotent,
322
+ _error_type(response) if response.status_code == 503 else None,
323
+ response.text if response.status_code == 503 else None,
324
+ )
325
+ ):
326
+ wait = server_retry_delay(server_attempt, response.headers.get('Retry-After'))
327
+ server_attempt += 1
328
+ logger.warning(
329
+ '%s %s answered %d; retry %d/%d in %.1fs',
330
+ method, path, response.status_code, server_attempt, self._max_server_retries, wait,
331
+ )
332
+ await asyncio.sleep(wait)
333
+ continue
334
+
236
335
  if raise_for_status:
237
336
  await self._raise_for_status(response)
238
337
  return response
239
338
 
240
- if last_error is not None:
241
- raise last_error
242
- raise ApiClientError('Request failed after retries', 429)
243
-
244
339
  async def get(
245
340
  self,
246
341
  path: str,
@@ -268,6 +363,7 @@ class ApiClient:
268
363
  params: Optional[Dict[str, Any]] = None,
269
364
  *,
270
365
  raise_for_status: bool = True,
366
+ idempotent: Optional[bool] = None,
271
367
  ) -> httpx.Response:
272
368
  return await self.request(
273
369
  'POST',
@@ -277,6 +373,7 @@ class ApiClient:
277
373
  content=content,
278
374
  headers=headers,
279
375
  raise_for_status=raise_for_status,
376
+ idempotent=idempotent,
280
377
  )
281
378
 
282
379
  async def put(
@@ -325,8 +422,10 @@ class ApiClient:
325
422
  data: Any = None,
326
423
  params: Optional[Dict[str, Any]] = None,
327
424
  headers: Optional[Dict[str, str]] = None,
425
+ *,
426
+ idempotent: Optional[bool] = None,
328
427
  ) -> Any:
329
- response = await self.post(path, json_data=data, params=params, headers=headers)
428
+ response = await self.post(path, json_data=data, params=params, headers=headers, idempotent=idempotent)
330
429
  if not response.content:
331
430
  return {}
332
431
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -44,6 +44,7 @@ src/scrapely_python.egg-info/SOURCES.txt
44
44
  src/scrapely_python.egg-info/dependency_links.txt
45
45
  src/scrapely_python.egg-info/requires.txt
46
46
  src/scrapely_python.egg-info/top_level.txt
47
+ tests/test_api_client_retries.py
47
48
  tests/test_apify_parity.py
48
49
  tests/test_browser_configuration.py
49
50
  tests/test_charging_parity.py
@@ -0,0 +1,170 @@
1
+ """Storage calls ride out a briefly unavailable API.
2
+
3
+ Staging 2026-10-15: while main redeployed, storage calls got 503 "Rate limiter
4
+ is temporarily unavailable" (and refused connections) and runs failed, because
5
+ the client retried only 429s. 503 and refused connections now retry with
6
+ backoff for every method; 500/502/504 and mid-request transport errors only for
7
+ idempotent methods.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List
13
+
14
+ import httpx
15
+ import pytest
16
+
17
+ from scrapely.storage import api_client as mod
18
+ from scrapely.storage.api_client import ApiClient, ApiClientError, is_retryable_response, server_retry_delay
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
23
+ slept: List[float] = []
24
+
25
+ async def fake_sleep(secs: float) -> None:
26
+ slept.append(secs)
27
+
28
+ monkeypatch.setattr(mod.asyncio, 'sleep', fake_sleep)
29
+ return slept
30
+
31
+
32
+ def client_for(responses: list) -> tuple[ApiClient, list]:
33
+ calls: list = []
34
+
35
+ def handler(request: httpx.Request) -> httpx.Response:
36
+ calls.append(request.method)
37
+ r = responses.pop(0) if len(responses) > 1 else responses[0]
38
+ if isinstance(r, Exception):
39
+ raise r
40
+ return r
41
+
42
+ return ApiClient('http://api.test', 'st_x', 'run1', transport=httpx.MockTransport(handler)), calls
43
+
44
+
45
+ def unavailable() -> httpx.Response:
46
+ return httpx.Response(503, json={'error': {'type': 'rate-limit-unavailable', 'message': 'Rate limiter is temporarily unavailable'}})
47
+
48
+
49
+ def outcome_unknown() -> httpx.Response:
50
+ return httpx.Response(503, json={'error': {'type': 'service-unavailable', 'message': 'Dataset storage is temporarily unavailable, try again'}})
51
+
52
+
53
+ async def test_post_not_resent_after_outcome_unknown_503() -> None:
54
+ c, calls = client_for([outcome_unknown(), httpx.Response(201, json={})])
55
+ with pytest.raises(ApiClientError) as e:
56
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
57
+ assert e.value.status_code == 503
58
+ assert calls == ['POST'], 'the items may already be stored'
59
+
60
+ c, calls = client_for([outcome_unknown(), httpx.Response(200, json={})])
61
+ assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
62
+ assert len(calls) == 2
63
+
64
+
65
+ async def test_post_resent_after_proxy_503() -> None:
66
+ proxy = httpx.Response(503, text='<html>No server is available to handle this request.</html>', headers={'content-type': 'text/html'})
67
+ other = httpx.Response(503, text='<html>Service Unavailable</html>', headers={'content-type': 'text/html'})
68
+ c, calls = client_for([other, httpx.Response(201, json={})])
69
+ with pytest.raises(ApiClientError):
70
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
71
+ assert len(calls) == 1, 'a non-JSON 503 without the no-backend marker may have been forwarded'
72
+ c, calls = client_for([proxy, httpx.Response(201, json={})])
73
+ assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{}])).status_code == 201
74
+ assert len(calls) == 2
75
+
76
+
77
+ async def test_record_read_with_raise_for_status_false_still_retries() -> None:
78
+ c, calls = client_for([unavailable(), httpx.Response(404, json={'error': {'message': 'Record not found'}})])
79
+ r = await c.request('GET', '/v2/key-value-stores/k/records/STATE', raise_for_status=False)
80
+ assert r.status_code == 404
81
+ assert len(calls) == 2
82
+
83
+
84
+ async def test_post_retries_503_until_success(no_sleep: List[float]) -> None:
85
+ c, calls = client_for([unavailable(), unavailable(), httpx.Response(201, json={'data': {}})])
86
+ r = await c.request('POST', '/v2/datasets/d/items', json_data=[{'a': 1}])
87
+ assert r.status_code == 201
88
+ assert calls == ['POST'] * 3
89
+ backoff = [x for x in no_sleep if x >= 0.4] # the rate limiter's own pacing sleeps are shorter
90
+ assert len(backoff) == 2 and 0.5 <= backoff[0] < 0.8 and 1.0 <= backoff[1] < 1.3 # exponential
91
+
92
+
93
+ async def test_retry_after_is_honoured(no_sleep: List[float]) -> None:
94
+ c, _ = client_for([httpx.Response(503, headers={'Retry-After': '3'}), httpx.Response(200, json={})])
95
+ await c.request('GET', '/v2/key-value-stores/k/records/x')
96
+ assert any(3.0 <= x < 3.2 for x in no_sleep)
97
+
98
+
99
+ async def test_gives_up_after_budget() -> None:
100
+ c, calls = client_for([unavailable()])
101
+ with pytest.raises(ApiClientError) as e:
102
+ await c.request('GET', '/v2/datasets/d')
103
+ assert e.value.status_code == 503
104
+ assert len(calls) == ApiClient._max_server_retries + 1
105
+
106
+
107
+ async def test_502_retried_for_get_not_for_post() -> None:
108
+ c, calls = client_for([httpx.Response(502), httpx.Response(200, json={})])
109
+ assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
110
+ assert len(calls) == 2
111
+
112
+ c, calls = client_for([httpx.Response(502), httpx.Response(201, json={})])
113
+ with pytest.raises(ApiClientError) as e:
114
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
115
+ assert e.value.status_code == 502
116
+ assert calls == ['POST'], 'a POST that may have been processed must not be re-sent'
117
+
118
+
119
+ async def test_connection_refused_retried_for_every_method() -> None:
120
+ c, calls = client_for([httpx.ConnectError('refused'), httpx.Response(201, json={})])
121
+ assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{}])).status_code == 201
122
+ assert len(calls) == 2
123
+
124
+
125
+ async def test_mid_request_reset_only_retried_when_idempotent() -> None:
126
+ c, calls = client_for([httpx.ReadError('reset'), httpx.Response(200, json={})])
127
+ assert (await c.request('GET', '/v2/key-value-stores/k/records/x')).status_code == 200
128
+ assert len(calls) == 2
129
+
130
+ # PUT is a billed KV write: not re-sent after a reset or a 502 (may have been applied) ...
131
+ c, calls = client_for([httpx.ReadError('reset'), httpx.Response(200, json={})])
132
+ with pytest.raises(httpx.ReadError):
133
+ await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')
134
+ assert len(calls) == 1
135
+ c, calls = client_for([httpx.Response(502), httpx.Response(200, json={})])
136
+ with pytest.raises(ApiClientError):
137
+ await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')
138
+ assert len(calls) == 1
139
+ # ... but is after the limiter refused it.
140
+ c, calls = client_for([unavailable(), httpx.Response(200, json={})])
141
+ assert (await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')).status_code == 200
142
+ assert len(calls) == 2
143
+
144
+ c, calls = client_for([httpx.ReadError('reset'), httpx.Response(201, json={})])
145
+ with pytest.raises(httpx.ReadError):
146
+ await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
147
+ assert len(calls) == 1
148
+
149
+
150
+ async def test_client_errors_are_not_retried() -> None:
151
+ c, calls = client_for([httpx.Response(404, json={'error': {'message': 'nope'}})])
152
+ with pytest.raises(ApiClientError):
153
+ await c.request('GET', '/v2/datasets/missing')
154
+ assert len(calls) == 1
155
+
156
+
157
+ def test_helpers() -> None:
158
+ assert is_retryable_response(503, True, 'service-unavailable')
159
+ assert not is_retryable_response(503, False, 'service-unavailable')
160
+ assert is_retryable_response(503, False, 'rate-limit-unavailable')
161
+ assert is_retryable_response(503, False, None, '<h1>503</h1> No server is available to handle this request.')
162
+ assert not is_retryable_response(503, False, None, '<html>Service Unavailable</html>')
163
+ assert is_retryable_response(504, True) and not is_retryable_response(504, False)
164
+ assert not is_retryable_response(400, True) and not is_retryable_response(501, True)
165
+ from email.utils import parsedate_to_datetime
166
+ now = parsedate_to_datetime('Thu, 15 Oct 2026 00:00:00 GMT').timestamp()
167
+ assert 5.0 <= server_retry_delay(0, 'Thu, 15 Oct 2026 00:00:05 GMT', now=now) < 5.2
168
+ assert 4.0 <= server_retry_delay(3, 'garbage') < 4.3
169
+ assert server_retry_delay(10, None) < 8.3
170
+ assert 30.0 <= server_retry_delay(0, '120') < 30.2
@@ -659,3 +659,86 @@ async def test_unpriced_explicit_event_with_priced_default_is_gated() -> None:
659
659
 
660
660
  with pytest.raises(RuntimeError, match='could not be voided yet'):
661
661
  await push_data_and_charge(mgr, [{'b': 1}], 'not-priced', True, ok_push)
662
+
663
+
664
+ @pytest.mark.asyncio
665
+ async def test_charge_5xx_never_drops_items_and_flush_bills_once() -> None:
666
+ client = FakeClient(fail_times=1) # first charge POST fails (unconfirmed)
667
+ mgr = _make_manager(client)
668
+ pushed: List[Any] = []
669
+
670
+ async def ok_push(items: Any) -> None:
671
+ pushed.extend(items)
672
+
673
+ result = await push_data_and_charge(mgr, [{'a': 1}, {'a': 2}], 'page-open', False, ok_push)
674
+ assert pushed == [{'a': 1}, {'a': 2}], 'items must be stored despite the failed charge'
675
+ assert result.charged_count == 2
676
+ charge_keys = [p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge')]
677
+ await mgr.flush_pending()
678
+ charge_keys_after = [p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge')]
679
+ assert len(charge_keys) == 1 and charge_keys_after == charge_keys * 2, 'same key re-posted once by the flush'
680
+ assert mgr.get_charged_event_count('page-open') == 2
681
+
682
+
683
+ class Void404Client(FakeClient):
684
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
685
+ if path.endswith('/charge/void'):
686
+ self.posts.append({'path': path, 'json': json_data, 'headers': headers or {}})
687
+
688
+ class NotFound(Exception):
689
+ status_code = 404
690
+ type = 'charge-not-found'
691
+
692
+ raise NotFound('charge not found')
693
+ return await super().post(path, json_data=json_data, headers=headers, **kwargs)
694
+
695
+
696
+ @pytest.mark.asyncio
697
+ async def test_unconfirmed_charge_for_unsaved_items_is_cancelled() -> None:
698
+ client = Void404Client(fail_times=1)
699
+ mgr = _make_manager(client)
700
+
701
+ class PushHttpError(Exception):
702
+ status_code = 502
703
+
704
+ async def failing_push(_items: Any) -> None:
705
+ raise PushHttpError('bad gateway')
706
+
707
+ with pytest.raises(PushHttpError):
708
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
709
+ key = next(p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge'))
710
+ assert any(p['path'].endswith('/charge/void') and p['headers']['Idempotency-Key'] == key for p in client.posts)
711
+ await mgr.flush_pending()
712
+ assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1, 'never re-posted'
713
+ assert mgr.get_charged_event_count('page-open') == 0
714
+
715
+
716
+
717
+ @pytest.mark.asyncio
718
+ async def test_void_route_missing_404_is_not_never_applied() -> None:
719
+ class GenericNotFoundClient(FakeClient):
720
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
721
+ if path.endswith('/charge/void'):
722
+ self.posts.append({'path': path, 'json': json_data, 'headers': headers or {}})
723
+
724
+ class RouteNotFound(Exception):
725
+ status_code = 404
726
+ type = 'not-found'
727
+
728
+ raise RouteNotFound('route not found')
729
+ return await super().post(path, json_data=json_data, headers=headers, **kwargs)
730
+
731
+ client = GenericNotFoundClient(fail_times=1)
732
+ mgr = _make_manager(client)
733
+
734
+ class PushHttpError(Exception):
735
+ status_code = 502
736
+
737
+ async def failing_push(_items: Any) -> None:
738
+ raise PushHttpError('bad gateway')
739
+
740
+ with pytest.raises(PushHttpError):
741
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
742
+ await mgr.flush_pending()
743
+ assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1, 'never re-posted for unsaved items'
744
+ assert mgr.get_charged_event_count('page-open') == 1, 'local count kept: it may have applied'
@@ -256,7 +256,11 @@ async def test_rq_reclaim_after_unclaimed_lock_expiry_persists_retry_count(trans
256
256
 
257
257
 
258
258
  @pytest.mark.asyncio
259
- async def test_rq_reclaim_still_raises_other_errors(transport, mock_api):
259
+ async def test_rq_reclaim_still_raises_other_errors(transport, mock_api, monkeypatch):
260
+ # A persistent 500 is raised once the server-error retries are spent (none here, to keep the test fast).
261
+ from scrapely.storage.api_client import ApiClient
262
+
263
+ monkeypatch.setattr(ApiClient, '_max_server_retries', 0)
260
264
  client = ScrapelyStorageClient(
261
265
  api_url='https://api.test.scrape.ly',
262
266
  token='t',