scrapely-python 0.1.4__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.4 → scrapely_python-0.1.5}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/api_client.py +109 -10
  5. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  6. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
  7. scrapely_python-0.1.5/tests/test_api_client_retries.py +170 -0
  8. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_storage.py +5 -1
  9. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/README.md +0 -0
  10. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/setup.cfg +0 -0
  11. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/_browser_locations.py +0 -0
  12. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/actor.py +0 -0
  13. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/browser_configuration.py +0 -0
  14. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/charging.py +0 -0
  15. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/charging_client.py +0 -0
  16. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/configuration.py +0 -0
  17. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/crypto/__init__.py +0 -0
  18. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/crypto/_secrets.py +0 -0
  19. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/errors.py +0 -0
  20. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/__init__.py +0 -0
  21. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/_types.py +0 -0
  22. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/event_manager.py +0 -0
  23. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/log.py +0 -0
  24. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/patch_browser_pool.py +0 -0
  25. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/pricing_types.py +0 -0
  26. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/proxy_configuration.py +0 -0
  27. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/py.typed +0 -0
  28. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/request_loaders/__init__.py +0 -0
  29. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  30. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/__init__.py +0 -0
  31. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/__init__.py +0 -0
  32. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  33. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  34. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_metadata.py +0 -0
  35. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/client.py +0 -0
  36. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/patch_storage.py +0 -0
  37. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  38. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  39. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  40. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  41. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  42. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/types.py +0 -0
  43. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/utils.py +0 -0
  44. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage_clients/__init__.py +0 -0
  45. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storages/__init__.py +0 -0
  46. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/types.py +0 -0
  47. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/webhook.py +0 -0
  48. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  49. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/requires.txt +0 -0
  50. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/top_level.txt +0 -0
  51. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_apify_parity.py +0 -0
  52. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_browser_configuration.py +0 -0
  53. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_charging_parity.py +0 -0
  54. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_charging_pending.py +0 -0
  55. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_parity.py +0 -0
  56. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_patch_browser_pool.py +0 -0
  57. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_proxy_configuration.py +0 -0
  58. {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_run_control.py +0 -0
  59. {scrapely_python-0.1.4 → 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.4
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.4"
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.4'
135
+ __version__ = '0.1.5'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -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.4
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
@@ -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',