Skip to content

Commit 270487f

Browse files
committed
fix(load): retry append loads instead of running them at most once
`append` was excluded from retries on the grounds that it is not idempotent: if the server commits the load but the response is lost, a retry would re-append the same rows. That is not how the API behaves. It keys a receipt on `upload_id`, and a re-POST of the same id replays the committed result rather than applying the load again. So the invariant that makes a retry safe is re-sending the same upload, not the mode. `ManagedDatabaseClient` stages once, in `upload_parquet`, outside the operation it retries, so that invariant holds for every mode — and a test now pins it, since a refactor moving the upload inside the retry would break it silently. The exclusion cost availability. The destination serialises writes per table and refuses rather than queues, so concurrent writers to one table are answered `409 RESOURCE_LOCKED`. An append had no budget to wait that out, whatever `max_retries` the caller had configured — the one shape that most needs patience was the one shape that had none. `HotdataClient.load_managed_table(file=...)` uploads inside the call and so does not hold the invariant. It is unwrapped and unaffected. Two supporting changes the above needs: Classify a 409 by its `error.code` rather than by the status alone. `CONFLICT` is terminal — the request cannot succeed as posted, so the previous behaviour spent the whole budget reaching the same answer. `RESOURCE_LOCKED` stays transient. A 409 carrying no error envelope is classified exactly as before. `HotdataError` now carries `status_code`, `code` and `retry_after_seconds`, because the message is flattened and truncated for readability and so cannot serve as a discriminator. Honour `Retry-After` and jitter the backoff. `Retry-After` is a floor on the ramp, capped like the ramp so a mistaken header cannot park an attempt for an hour; jitter of up to +50% is added on top and never subtracted, so a stated `Retry-After` is not undercut. Writers that collide on one table started together and would otherwise retry in lockstep and collide again. This lengthens a 20-attempt budget from 285s to roughly 316-405s.
1 parent d6b6e83 commit 270487f

7 files changed

Lines changed: 363 additions & 34 deletions

File tree

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- fix(load): retry an `append` load instead of running it at most once.
13+
14+
`append` was excluded from retries on the grounds that it is not idempotent:
15+
if the server commits but the response is lost, a retry would duplicate rows.
16+
That is not how the server behaves. It keys a receipt on `upload_id`, and a
17+
re-POST of the same id replays the committed result instead of applying the
18+
load again — so what makes a retry safe is re-sending the same upload, not
19+
the mode. This client stages once, in `upload_parquet`, outside the retried
20+
operation, so the invariant holds for every mode.
21+
22+
The exclusion cost real availability. The destination serialises writes per
23+
table and refuses rather than queues, so concurrent writers to one table get
24+
`409 RESOURCE_LOCKED` — and an append had no budget to wait it out, whatever
25+
`max_retries` the caller had configured.
26+
27+
`HotdataClient.load_managed_table(file=...)` uploads inside the call and so
28+
does not hold the invariant. It is unwrapped and unaffected.
29+
30+
- fix(errors): classify a 409 by its `error.code` rather than by the status alone.
31+
32+
`CONFLICT` is now terminal: it means the request cannot succeed as posted, so
33+
the previous behaviour spent the entire retry budget arriving at the same
34+
answer. `RESOURCE_LOCKED` stays transient. A 409 with no error envelope — a
35+
failed query result, say — is classified as before.
36+
37+
- fix(retry): honour `Retry-After`, and jitter the backoff.
38+
39+
`Retry-After` is taken as a floor on the ramp, capped like the ramp so a bad
40+
header cannot park an attempt for an hour. Jitter of up to +50% is added on
41+
top and never subtracted, so a stated `Retry-After` is not undercut. Without
42+
it, writers that collided on one table retry in lockstep and collide again.
43+
44+
This lengthens a 20-attempt budget from 285s to roughly 316-405s.
45+
46+
### Added
47+
48+
- `HotdataError` carries `status_code`, `code` and `retry_after_seconds`. The
49+
message is flattened and truncated for readability, so it could not serve as
50+
a discriminator; these can.
1051

1152
## [0.12.1] - 2026-08-18
1253

‎hotdata_framework/client.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -985,9 +985,11 @@ def _load_response_from_job(self, job_id: str) -> LoadManagedTableResponse:
985985
durable state rather than from a connection that has to stay alive. That
986986
also gives a caller a handle: the job id is returned on
987987
`LoadManagedTableResult`, so "did it land?" is answerable after a lost
988-
response. `append` stays non-retryable -- knowing the id makes the question
989-
answerable, it does not make a blind re-submission safe, and that call is
990-
the caller's to make.
988+
response. That answer is a convenience rather than a precondition for
989+
retrying: re-sending the same upload_id replays the server's receipt
990+
instead of applying the load a second time, which is what makes a retry
991+
safe in every mode. It stops being safe for a caller that re-stages the
992+
upload, because a fresh upload id has no receipt to replay.
991993
992994
`partially_succeeded` is terminal and carries a message, so it is raised
993995
rather than returned -- a caller asked for a table's contents to be

‎hotdata_framework/errors.py‎

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
from __future__ import annotations
22

3+
import json
4+
from collections.abc import Mapping
5+
36
from hotdata.rest import ApiException
47

8+
# The API explains a 409 with a machine-readable code, and the two it sends
9+
# mean opposite things to a retry policy. RESOURCE_LOCKED is a refusal taken
10+
# before any work: the insert that would have created the unit of work lost a
11+
# unique-constraint race, so nothing was claimed and nothing was written.
12+
# CONFLICT is the opposite — the request cannot succeed as posted, so retrying
13+
# spends the whole budget arriving at the same answer.
14+
_TERMINAL_CONFLICT_CODE = "CONFLICT"
15+
516

617
class HotdataError(RuntimeError):
7-
pass
18+
"""An API failure, carrying what a retry policy needs to decide.
19+
20+
The message cannot be the discriminator: it is flattened and truncated for
21+
readability, so keying on it means substring-matching prose. ``status_code``
22+
and ``code`` are the machine-readable form of the same answer, and
23+
``retry_after_seconds`` is the server's own estimate of how long the
24+
condition it just refused will last.
25+
"""
26+
27+
def __init__(
28+
self,
29+
message: str,
30+
*,
31+
status_code: int | None = None,
32+
code: str | None = None,
33+
retry_after_seconds: float | None = None,
34+
) -> None:
35+
super().__init__(message)
36+
self.status_code = status_code
37+
self.code = code
38+
self.retry_after_seconds = retry_after_seconds
839

940

1041
class HotdataTransientError(HotdataError):
@@ -15,6 +46,71 @@ class HotdataTerminalError(HotdataError):
1546
pass
1647

1748

49+
def _error_code(body: object) -> str | None:
50+
"""The ``error.code`` an API error envelope carries, if this body is one.
51+
52+
Not every 409 comes from an endpoint that speaks the envelope — a failed
53+
query result is reported as one and carries a result document instead — so
54+
a missing code is ordinary, and callers fall back to the status.
55+
"""
56+
if not isinstance(body, (str, bytes, bytearray)):
57+
return None
58+
try:
59+
parsed: object = json.loads(body)
60+
except ValueError:
61+
return None
62+
if not isinstance(parsed, Mapping):
63+
return None
64+
error: object = parsed.get("error")
65+
if not isinstance(error, Mapping):
66+
return None
67+
code: object = error.get("code")
68+
return code if isinstance(code, str) else None
69+
70+
71+
def _retry_after_seconds(headers: object) -> float | None:
72+
"""``Retry-After`` as a number of seconds, when the response states one.
73+
74+
Only the delta-seconds form is read. That is what the API sends, and the
75+
HTTP-date form would need a comparison against a server clock we do not
76+
have to be worth anything.
77+
"""
78+
if not isinstance(headers, Mapping):
79+
return None
80+
raw: object = headers.get("Retry-After")
81+
if raw is None:
82+
# The SDK hands us urllib3's case-insensitive mapping and the API sends
83+
# the header lower-cased, so the direct hit is what normally answers.
84+
# Fall back for any plain dict that reaches us instead — a missed
85+
# header is silent, and silence here reads as "the server asked for
86+
# nothing".
87+
raw = next((v for k, v in headers.items() if str(k).lower() == "retry-after"), None)
88+
if raw is None:
89+
return None
90+
try:
91+
seconds = float(str(raw).strip())
92+
except ValueError:
93+
return None
94+
return seconds if seconds >= 0 else None
95+
96+
97+
def _error_class(status_code: int, code: str | None) -> type[HotdataError]:
98+
if status_code == 409 and code == _TERMINAL_CONFLICT_CODE:
99+
# The request cannot succeed as posted — an upload already consumed
100+
# with nothing to replay, a receipt naming a different target, an
101+
# incompatible column type. Every retry reaches the same 409.
102+
return HotdataTerminalError
103+
if status_code in (408, 409, 425, 429):
104+
return HotdataTransientError
105+
if status_code == 501:
106+
# Not Implemented is a permanent capability gap (e.g. the storage
107+
# backend cannot issue presigned URLs) — retrying cannot succeed.
108+
return HotdataTerminalError
109+
if 500 <= status_code <= 599:
110+
return HotdataTransientError
111+
return HotdataTerminalError
112+
113+
18114
def classify_sdk_error(error: Exception) -> HotdataError:
19115
if isinstance(error, TimeoutError):
20116
return HotdataTransientError(str(error))
@@ -25,16 +121,14 @@ def classify_sdk_error(error: Exception) -> HotdataError:
25121
message = f"{status_code}: {error.reason or 'unknown error'}"
26122
# The response body is where the API explains itself (e.g. which
27123
# header is missing) — without it "400: Bad Request" is undebuggable.
28-
body = getattr(error, "body", None)
124+
body: object = getattr(error, "body", None)
29125
if body:
30126
message = f"{message} — {' '.join(str(body).split())[:500]}"
31-
if status_code in (408, 409, 425, 429):
32-
return HotdataTransientError(message)
33-
if status_code == 501:
34-
# Not Implemented is a permanent capability gap (e.g. the storage
35-
# backend cannot issue presigned URLs) — retrying cannot succeed.
36-
return HotdataTerminalError(message)
37-
if 500 <= status_code <= 599:
38-
return HotdataTransientError(message)
39-
return HotdataTerminalError(message)
127+
code = _error_code(body)
128+
return _error_class(status_code, code)(
129+
message,
130+
status_code=status_code,
131+
code=code,
132+
retry_after_seconds=_retry_after_seconds(getattr(error, "headers", None)),
133+
)
40134
return HotdataTerminalError(str(error))

‎hotdata_framework/managed_client.py‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import random
1011
import time
1112
from collections.abc import Callable
1213
from typing import Any, Protocol, TypeVar
@@ -53,6 +54,10 @@ class ManagedDatabaseClient:
5354
_QUERY_TIMEOUT_SECONDS = 300.0
5455
_POLL_INTERVAL_SECONDS = 0.4
5556
_MAX_BACKOFF_SECONDS = 30.0
57+
# Spread as a fraction of the wait, added on top of it. Half an interval is
58+
# enough to decorrelate writers that started together without materially
59+
# changing how long the budget lasts.
60+
_RETRY_JITTER_FRACTION = 0.5
5661

5762
def __init__(
5863
self,
@@ -207,9 +212,16 @@ def load_managed_table(
207212
mode: ManagedLoadMode = "replace",
208213
key: list[str] | None = None,
209214
) -> LoadManagedTableResult:
210-
# append is the only non-idempotent mode: if the server commits the load
211-
# but the response is lost, a retry re-appends the same rows. Run it
212-
# at-most-once; every other mode is safe to retry.
215+
# Retryable in every mode, append included. A retry re-sends the SAME
216+
# upload_id, and the server keys a receipt on it: a replay returns the
217+
# committed result rather than applying the load a second time. So the
218+
# invariant that makes this safe is the upload id, not the mode — a
219+
# caller that re-stages the upload between attempts mints a new id,
220+
# loses the receipt, and a retried append would then duplicate rows.
221+
# This client stages once, in upload_parquet, outside the operation
222+
# retried here. `HotdataClient.load_managed_table(file=...)` uploads
223+
# inside the call and so does not hold the invariant; it is unwrapped,
224+
# and retrying an append through it is the caller's to justify.
213225
#
214226
# `key` is the merge key for delete/update/upsert loads: when set it is
215227
# matched per-load instead of a key declared at table creation. Omit it
@@ -222,20 +234,40 @@ def load_managed_table(
222234
upload_id=upload_id,
223235
mode=mode,
224236
key=key,
225-
),
226-
retryable=(mode != "append"),
237+
)
227238
)
228239

229-
def _request_with_retry(self, operation: Callable[[], T], *, retryable: bool = True) -> T:
230-
max_attempts = self._max_retries if retryable else 1
240+
def _request_with_retry(self, operation: Callable[[], T]) -> T:
241+
max_attempts = self._max_retries
231242
for attempt in range(1, max_attempts + 1):
232243
try:
233244
return operation()
234245
except Exception as error:
235246
mapped_error = classify_sdk_error(error.__cause__ or error)
236247
if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
237-
backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
238-
time.sleep(backoff)
248+
time.sleep(self._retry_delay(attempt, mapped_error.retry_after_seconds))
239249
continue
240250
raise mapped_error from error
241251
raise RuntimeError("No retry attempts configured")
252+
253+
def _retry_delay(self, attempt: int, retry_after_seconds: float | None) -> float:
254+
"""A linear ramp, floored by the server's Retry-After and spread by jitter.
255+
256+
Retry-After is a floor rather than a replacement: it says how long the
257+
condition just refused typically lasts, while the ramp is what gives up
258+
eventually, and taking the larger of the two honours both. It is capped
259+
like the ramp so a hostile or mistaken header cannot park an attempt for
260+
an hour.
261+
262+
Jitter is added on top and never subtracted, so a stated Retry-After is
263+
not undercut. It matters because the callers that collide are the ones
264+
that started together: writers refused by one table's lock would retry
265+
in lockstep on an identical ramp and re-collide every time.
266+
_MAX_BACKOFF_SECONDS caps the ramp, deliberately not the jitter above
267+
it — clamping the total would flatten every late attempt onto the same
268+
value and re-correlate exactly the waits that most need spreading.
269+
"""
270+
base = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
271+
if retry_after_seconds is not None:
272+
base = max(base, min(retry_after_seconds, self._MAX_BACKOFF_SECONDS))
273+
return base * (1.0 + random.random() * self._RETRY_JITTER_FRACTION)

‎tests/test_client.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -841,9 +841,8 @@ def test_a_failed_load_job_names_the_job_alongside_the_server_message():
841841

842842

843843
def test_a_deferred_load_returns_the_job_id_to_the_caller():
844-
"""`append` stays non-retryable, so the id is the only handle a caller has to
845-
answer "did it land?" after a lost response -- the same reason
846-
CreateIndexResult carries one."""
844+
"""The id is the handle a caller has to answer "did it land?" after a lost
845+
response -- the same reason CreateIndexResult carries one."""
847846
from hotdata.models.submit_job_response import SubmitJobResponse
848847

849848
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")

‎tests/test_errors.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,83 @@ def test_classify_sdk_error_without_body_keeps_short_form() -> None:
3131
assert str(err) == "409: Conflict"
3232

3333

34+
LOCKED = (
35+
'{"error":{"code":"RESOURCE_LOCKED","message":"another operation is already '
36+
'running for conn:c1:public:_dlt_pipeline_state; retry shortly"}}'
37+
)
38+
CONFLICT = '{"error":{"code":"CONFLICT","message":"upload already consumed"}}'
39+
40+
41+
def test_resource_locked_is_transient_and_names_itself() -> None:
42+
"""A lock refusal is taken before any work — the insert that would have
43+
created the unit of work lost a unique-constraint race — so nothing was
44+
claimed and a retry is safe."""
45+
err = classify_sdk_error(ApiException(status=409, reason="Conflict", body=LOCKED))
46+
assert isinstance(err, HotdataTransientError)
47+
assert err.status_code == 409
48+
assert err.code == "RESOURCE_LOCKED"
49+
50+
51+
def test_conflict_is_terminal_despite_being_a_409() -> None:
52+
"""A CONFLICT cannot succeed as posted, so retrying it spends the entire
53+
budget to arrive at the same 409. Classifying every 409 as transient meant
54+
permanent conflicts burned the full ramp before surfacing."""
55+
err = classify_sdk_error(ApiException(status=409, reason="Conflict", body=CONFLICT))
56+
assert isinstance(err, HotdataTerminalError)
57+
assert err.code == "CONFLICT"
58+
59+
60+
def test_a_409_that_is_not_an_error_envelope_stays_transient() -> None:
61+
"""Not every 409 comes from an endpoint that speaks the envelope: a failed
62+
query result is reported as one and carries a result document. With no code
63+
to read, the status decides, and the classification is unchanged."""
64+
body = '{"result_id":"rslt1","status":"failed","error_message":"query panicked"}'
65+
err = classify_sdk_error(ApiException(status=409, reason="Conflict", body=body))
66+
assert isinstance(err, HotdataTransientError)
67+
assert err.code is None
68+
69+
70+
def _locked(headers: object) -> ApiException:
71+
"""A lock refusal carrying response headers.
72+
73+
``ApiException`` only populates ``headers`` from a real ``http_resp``, so a
74+
hand-built one sets it after construction — the same attribute the SDK
75+
assigns."""
76+
err = ApiException(status=409, reason="Conflict", body=LOCKED)
77+
err.headers = headers
78+
return err
79+
80+
81+
def test_retry_after_is_read_from_the_response() -> None:
82+
assert classify_sdk_error(_locked({"Retry-After": "5"})).retry_after_seconds == 5.0
83+
84+
85+
def test_retry_after_is_found_however_the_header_is_cased() -> None:
86+
"""The API sends it lower-cased. urllib3's mapping is case-insensitive so
87+
the direct lookup normally answers, but a plain dict must not silently read
88+
as "the server asked for nothing"."""
89+
assert classify_sdk_error(_locked({"retry-after": "5"})).retry_after_seconds == 5.0
90+
91+
92+
def test_an_unparseable_retry_after_is_ignored_rather_than_fatal() -> None:
93+
"""Only the delta-seconds form is read. An HTTP-date would need a server
94+
clock to be worth anything, and a malformed header must not become an
95+
exception raised while classifying another exception."""
96+
stamp = "Wed, 21 Oct 2026 07:28:00 GMT"
97+
assert classify_sdk_error(_locked({"Retry-After": stamp})).retry_after_seconds is None
98+
99+
100+
def test_headers_that_are_not_a_mapping_are_ignored() -> None:
101+
assert classify_sdk_error(_locked(object())).retry_after_seconds is None
102+
103+
104+
def test_a_body_that_is_not_json_does_not_break_classification() -> None:
105+
"""A proxy or load balancer can answer with HTML the API never wrote."""
106+
err = classify_sdk_error(ApiException(status=409, reason="Conflict", body="<html>nope</html>"))
107+
assert isinstance(err, HotdataTransientError)
108+
assert err.code is None
109+
110+
34111
def test_classify_sdk_error_truncates_and_flattens_body() -> None:
35112
noisy = "x\n" * 1000
36113
err = classify_sdk_error(ApiException(status=500, reason="ISE", body=noisy))

0 commit comments

Comments
 (0)