Skip to content

Commit 6e7ab0d

Browse files
committed
test(retry): cover the Retry-After wiring, and scope the transport claim
Three review points from #75. Nothing exercised the path that carries `Retry-After` off a classified error and into the sleep. `_retry_delay` was tested directly, and the retry-loop tests stub sleep with a lambda that discards its argument, so a regression passing `None` there — or swapping the two positional args — would have left every test green. Refuse a load the way the API refuses a contended table, record what sleep actually received, and assert the waits floor on the header. That also covers classify → transient → retry end to end, which was only covered per-piece, and the no-header case, so the floor is visibly the header's contribution rather than a hard-coded one. Also pin that a `CONFLICT` surfaces on the first attempt, which is the half of the 409 split that had no loop-level test. The README and `test_retry_policy`'s module docstring both stated "a load is not idempotent" without qualification. That remains true of the transport, which sees a method and a status and cannot know what it would be replaying — but unscoped it reads as repo-wide and contradicts the call-layer retry. Both now say which layer they mean.
1 parent 270487f commit 6e7ab0d

4 files changed

Lines changed: 117 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343

4444
This lengthens a 20-attempt budget from 285s to roughly 316-405s.
4545

46+
- docs: scope the "a load is not idempotent" claim in the README and in
47+
`test_retry_policy` to the transport layer, which is where it is still true
48+
and where those two were always talking about. Left unscoped they read as
49+
repo-wide and contradict the call-layer retry above.
50+
4651
### Added
4752

4853
- `HotdataError` carries `status_code`, `code` and `retry_after_seconds`. The

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
1010

1111
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, and `HOTDATA_WORKSPACE`.
1212
- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
13-
- **HTTP resilience** — retry SQL execution on stale pooled sockets. Transport-level retries are the SDK's own default, which this package leaves in place so a non-idempotent request is never replayed on a response status.
13+
- **HTTP resilience** — retry SQL execution on stale pooled sockets. Transport-level retries are the SDK's own default, which this package leaves in place so a request is never blindly replayed on a response status. That is a claim about the transport, which cannot know what it would be replaying. `ManagedDatabaseClient` retries at the call layer, which can: a managed load is safe to re-send because it carries the same `upload_id` and the API replays its receipt for that id rather than applying the load twice.
1414
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
1515
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
1616
- **History helpers** — list recent results and query run history with normalized dataclasses.

‎tests/test_managed_client.py‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
import pyarrow as pa
99
import pytest
1010
from hotdata.models.query_response import QueryResponse
11+
from hotdata.rest import ApiException
1112

1213
import hotdata_framework.managed_client as mc
14+
from hotdata_framework.errors import HotdataTerminalError
1315

1416

1517
def _query_response(result_id: str) -> QueryResponse:
@@ -158,9 +160,7 @@ def get_result_arrow(self, result_id: str, *, x_database_id: str) -> pa.Table:
158160
assert arrow_scopes == ["db1"]
159161

160162

161-
def _load_recording_runtime(
162-
calls: list[str], uploads: list[str] | None = None
163-
) -> SimpleNamespace:
163+
def _load_recording_runtime(calls: list[str], uploads: list[str] | None = None) -> SimpleNamespace:
164164
"""A runtime whose ``load_managed_table`` records each mode and always fails
165165
with a transient error, so retry behaviour is observable via ``calls``.
166166
@@ -186,6 +186,32 @@ def load_managed_table(
186186
return runtime
187187

188188

189+
def _lock_refusing_runtime(retry_after: str | None) -> SimpleNamespace:
190+
"""A runtime whose loads are refused the way the API refuses a contended
191+
table: ``409 RESOURCE_LOCKED``, optionally carrying ``Retry-After``."""
192+
193+
def load_managed_table(
194+
database: str,
195+
table: str,
196+
*,
197+
schema: str,
198+
upload_id: str,
199+
mode: str,
200+
key: list[str] | None = None,
201+
) -> SimpleNamespace:
202+
error = ApiException(
203+
status=409,
204+
reason="Conflict",
205+
body='{"error":{"code":"RESOURCE_LOCKED","message":"retry shortly"}}',
206+
)
207+
error.headers = {"Retry-After": retry_after} if retry_after else {}
208+
raise error
209+
210+
runtime = _fake_runtime()
211+
runtime.load_managed_table = load_managed_table
212+
return runtime
213+
214+
189215
def _managed_client(max_retries: int) -> Any:
190216
return mc.ManagedDatabaseClient(
191217
api_key="k",
@@ -247,6 +273,78 @@ def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -
247273
assert calls == ["replace", "replace", "replace"] # retried up to max_retries
248274

249275

276+
def test_a_lock_refusal_is_retried_and_waits_the_header_out(
277+
monkeypatch: pytest.MonkeyPatch,
278+
) -> None:
279+
"""End to end for the contended-table case: a `409 RESOURCE_LOCKED` is
280+
classified transient, retried, and each wait honours the `Retry-After` the
281+
refusal carried.
282+
283+
Worth having as one test rather than three: `_retry_delay` is exercised
284+
directly elsewhere, but nothing else covers the wiring that carries the
285+
header off the error and into the sleep. The other retry tests stub sleep
286+
with a lambda that discards its argument, so a regression passing `None`
287+
here — or swapping the two positional arguments — would leave them green."""
288+
slept: list[float] = []
289+
monkeypatch.setattr(mc.time, "sleep", lambda seconds: slept.append(seconds))
290+
monkeypatch.setattr(mc.random, "random", lambda: 0.0)
291+
client = _managed_client(max_retries=4)
292+
client._retry_backoff_seconds = 1.5
293+
client._runtime = _lock_refusing_runtime(retry_after="5")
294+
295+
with pytest.raises(mc.HotdataTransientError):
296+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
297+
298+
# Four attempts, so three waits — each floored on the header rather than
299+
# taking the ramp's 1.5s / 3.0s / 4.5s.
300+
assert slept == [5.0, 5.0, 5.0]
301+
302+
303+
def test_a_lock_refusal_without_a_header_falls_back_to_the_ramp(
304+
monkeypatch: pytest.MonkeyPatch,
305+
) -> None:
306+
"""The floor is the header's contribution, not a hard-coded one: with no
307+
header the ramp decides, which keeps the refusal path working against a
308+
server that does not state a wait."""
309+
slept: list[float] = []
310+
monkeypatch.setattr(mc.time, "sleep", lambda seconds: slept.append(seconds))
311+
monkeypatch.setattr(mc.random, "random", lambda: 0.0)
312+
client = _managed_client(max_retries=4)
313+
client._retry_backoff_seconds = 1.5
314+
client._runtime = _lock_refusing_runtime(retry_after=None)
315+
316+
with pytest.raises(mc.HotdataTransientError):
317+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
318+
319+
assert slept == [1.5, 3.0, 4.5]
320+
321+
322+
def test_a_permanent_conflict_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
323+
"""A `CONFLICT` cannot succeed as posted, so it surfaces on the first
324+
attempt instead of spending the budget to reach the same 409."""
325+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
326+
attempts: list[int] = []
327+
328+
def load_managed_table(*_args: object, **_kwargs: object) -> SimpleNamespace:
329+
attempts.append(1)
330+
error = ApiException(
331+
status=409,
332+
reason="Conflict",
333+
body='{"error":{"code":"CONFLICT","message":"upload already consumed"}}',
334+
)
335+
error.headers = {}
336+
raise error
337+
338+
client = _managed_client(max_retries=8)
339+
client._runtime = _fake_runtime()
340+
client._runtime.load_managed_table = load_managed_table
341+
342+
with pytest.raises(HotdataTerminalError):
343+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
344+
345+
assert len(attempts) == 1
346+
347+
250348
def test_retry_delay_floors_on_the_servers_retry_after(monkeypatch: pytest.MonkeyPatch) -> None:
251349
"""``Retry-After`` states how long the refused condition lasts; the ramp only
252350
knows how many attempts are left. Taking the larger of the two respects both,

‎tests/test_retry_policy.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
"""A POST must never be replayed because of a response status.
1+
"""A POST must never be replayed *by the transport* because of a response status.
22
3-
A load is not idempotent: re-sending one that the server is still working on
4-
collides with the write lock the first attempt holds, and the duplicate is
5-
refused. The generated SDK already draws this line — ``hotdata._retry`` retries
3+
Re-sending a load the server is still working on collides with the write lock
4+
the first attempt holds, and the duplicate is refused — so a blind transport
5+
replay buys nothing and spends an attempt. This is a claim about the transport,
6+
which sees a method and a status and cannot know what it would be re-sending.
7+
It is not a claim that loads must never be retried: ``ManagedDatabaseClient``
8+
retries them at the call layer, where the same ``upload_id`` goes back out and
9+
the API replays its receipt for that id instead of applying the load twice.
10+
11+
The generated SDK already draws this line — ``hotdata._retry`` retries
612
a *pre-response* connection reset on any method (the stale pooled socket case,
713
where the server did no work) while leaving read timeouts and status retries
814
idempotent-only. This wrapper used to pass its own ``retries=`` into

0 commit comments

Comments
 (0)