You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(load): retry append loads instead of running them at most once (#75)
* 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.
* 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.
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
10
10
11
11
-**Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, and `HOTDATA_WORKSPACE`.
12
12
-**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.
14
14
-**SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
15
15
-**Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
16
16
-**History helpers** — list recent results and query run history with normalized dataclasses.
0 commit comments