hotdata-framework 0.12.1__tar.gz → 0.13.0__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.
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/CHANGELOG.md +48 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/PKG-INFO +2 -2
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/README.md +1 -1
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/client.py +5 -3
- hotdata_framework-0.13.0/hotdata_framework/errors.py +134 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/managed_client.py +41 -9
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/pyproject.toml +1 -1
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_client.py +2 -3
- hotdata_framework-0.13.0/tests/test_errors.py +125 -0
- hotdata_framework-0.13.0/tests/test_managed_client.py +430 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_retry_policy.py +10 -4
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/uv.lock +1 -1
- hotdata_framework-0.12.1/hotdata_framework/errors.py +0 -40
- hotdata_framework-0.12.1/tests/test_errors.py +0 -48
- hotdata_framework-0.12.1/tests/test_managed_client.py +0 -248
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/CODEOWNERS +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/.gitignore +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/CONTRACT.md +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/RELEASING.md +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_databases.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_indexes.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.12.1 → hotdata_framework-0.13.0}/tests/test_version.py +0 -0
|
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-08-27
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- fix(load): retry an `append` load instead of running it at most once.
|
|
15
|
+
|
|
16
|
+
`append` was excluded from retries on the grounds that it is not idempotent:
|
|
17
|
+
if the server commits but the response is lost, a retry would duplicate rows.
|
|
18
|
+
That is not how the server behaves. It keys a receipt on `upload_id`, and a
|
|
19
|
+
re-POST of the same id replays the committed result instead of applying the
|
|
20
|
+
load again — so what makes a retry safe is re-sending the same upload, not
|
|
21
|
+
the mode. This client stages once, in `upload_parquet`, outside the retried
|
|
22
|
+
operation, so the invariant holds for every mode.
|
|
23
|
+
|
|
24
|
+
The exclusion cost real availability. The destination serialises writes per
|
|
25
|
+
table and refuses rather than queues, so concurrent writers to one table get
|
|
26
|
+
`409 RESOURCE_LOCKED` — and an append had no budget to wait it out, whatever
|
|
27
|
+
`max_retries` the caller had configured.
|
|
28
|
+
|
|
29
|
+
`HotdataClient.load_managed_table(file=...)` uploads inside the call and so
|
|
30
|
+
does not hold the invariant. It is unwrapped and unaffected.
|
|
31
|
+
|
|
32
|
+
- fix(errors): classify a 409 by its `error.code` rather than by the status alone.
|
|
33
|
+
|
|
34
|
+
`CONFLICT` is now terminal: it means the request cannot succeed as posted, so
|
|
35
|
+
the previous behaviour spent the entire retry budget arriving at the same
|
|
36
|
+
answer. `RESOURCE_LOCKED` stays transient. A 409 with no error envelope — a
|
|
37
|
+
failed query result, say — is classified as before.
|
|
38
|
+
|
|
39
|
+
- fix(retry): honour `Retry-After`, and jitter the backoff.
|
|
40
|
+
|
|
41
|
+
`Retry-After` is taken as a floor on the ramp, capped like the ramp so a bad
|
|
42
|
+
header cannot park an attempt for an hour. Jitter of up to +50% is added on
|
|
43
|
+
top and never subtracted, so a stated `Retry-After` is not undercut. Without
|
|
44
|
+
it, writers that collided on one table retry in lockstep and collide again.
|
|
45
|
+
|
|
46
|
+
This lengthens a 20-attempt budget from 285s to roughly 316-405s.
|
|
47
|
+
|
|
48
|
+
- docs: scope the "a load is not idempotent" claim in the README and in
|
|
49
|
+
`test_retry_policy` to the transport layer, which is where it is still true
|
|
50
|
+
and where those two were always talking about. Left unscoped they read as
|
|
51
|
+
repo-wide and contradict the call-layer retry above.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `HotdataError` carries `status_code`, `code` and `retry_after_seconds`. The
|
|
56
|
+
message is flattened and truncated for readability, so it could not serve as
|
|
57
|
+
a discriminator; these can.
|
|
10
58
|
|
|
11
59
|
## [0.12.1] - 2026-08-18
|
|
12
60
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Python framework for building Hotdata integrations: workspace runtime, query execution, and managed databases
|
|
5
5
|
Project-URL: Homepage, https://www.hotdata.dev
|
|
6
6
|
Project-URL: Documentation, https://www.hotdata.dev/docs
|
|
@@ -38,7 +38,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
|
|
|
38
38
|
|
|
39
39
|
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, and `HOTDATA_WORKSPACE`.
|
|
40
40
|
- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
|
|
41
|
-
- **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
|
|
41
|
+
- **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.
|
|
42
42
|
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
|
|
43
43
|
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
|
|
44
44
|
- **History helpers** — list recent results and query run history with normalized dataclasses.
|
|
@@ -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
|
|
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.
|
|
@@ -985,9 +985,11 @@ class HotdataClient:
|
|
|
985
985
|
durable state rather than from a connection that has to stay alive. That
|
|
986
986
|
also gives a caller a handle: the job id is returned on
|
|
987
987
|
`LoadManagedTableResult`, so "did it land?" is answerable after a lost
|
|
988
|
-
response.
|
|
989
|
-
|
|
990
|
-
the
|
|
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.
|
|
991
993
|
|
|
992
994
|
`partially_succeeded` is terminal and carries a message, so it is raised
|
|
993
995
|
rather than returned -- a caller asked for a table's contents to be
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
from hotdata.rest import ApiException
|
|
7
|
+
|
|
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
|
+
|
|
16
|
+
|
|
17
|
+
class HotdataError(RuntimeError):
|
|
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
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HotdataTransientError(HotdataError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HotdataTerminalError(HotdataError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
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
|
+
|
|
114
|
+
def classify_sdk_error(error: Exception) -> HotdataError:
|
|
115
|
+
if isinstance(error, TimeoutError):
|
|
116
|
+
return HotdataTransientError(str(error))
|
|
117
|
+
if isinstance(error, ConnectionError):
|
|
118
|
+
return HotdataTransientError(str(error))
|
|
119
|
+
if isinstance(error, ApiException):
|
|
120
|
+
status_code = int(error.status or 0)
|
|
121
|
+
message = f"{status_code}: {error.reason or 'unknown error'}"
|
|
122
|
+
# The response body is where the API explains itself (e.g. which
|
|
123
|
+
# header is missing) — without it "400: Bad Request" is undebuggable.
|
|
124
|
+
body: object = getattr(error, "body", None)
|
|
125
|
+
if body:
|
|
126
|
+
message = f"{message} — {' '.join(str(body).split())[:500]}"
|
|
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
|
+
)
|
|
134
|
+
return HotdataTerminalError(str(error))
|
|
@@ -7,6 +7,7 @@ in one place rather than being duplicated per adapter.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import random
|
|
10
11
|
import time
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from typing import Any, Protocol, TypeVar
|
|
@@ -53,6 +54,10 @@ class ManagedDatabaseClient:
|
|
|
53
54
|
_QUERY_TIMEOUT_SECONDS = 300.0
|
|
54
55
|
_POLL_INTERVAL_SECONDS = 0.4
|
|
55
56
|
_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
|
|
56
61
|
|
|
57
62
|
def __init__(
|
|
58
63
|
self,
|
|
@@ -207,9 +212,16 @@ class ManagedDatabaseClient:
|
|
|
207
212
|
mode: ManagedLoadMode = "replace",
|
|
208
213
|
key: list[str] | None = None,
|
|
209
214
|
) -> LoadManagedTableResult:
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
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.
|
|
213
225
|
#
|
|
214
226
|
# `key` is the merge key for delete/update/upsert loads: when set it is
|
|
215
227
|
# matched per-load instead of a key declared at table creation. Omit it
|
|
@@ -222,20 +234,40 @@ class ManagedDatabaseClient:
|
|
|
222
234
|
upload_id=upload_id,
|
|
223
235
|
mode=mode,
|
|
224
236
|
key=key,
|
|
225
|
-
)
|
|
226
|
-
retryable=(mode != "append"),
|
|
237
|
+
)
|
|
227
238
|
)
|
|
228
239
|
|
|
229
|
-
def _request_with_retry(self, operation: Callable[[], T]
|
|
230
|
-
max_attempts = self._max_retries
|
|
240
|
+
def _request_with_retry(self, operation: Callable[[], T]) -> T:
|
|
241
|
+
max_attempts = self._max_retries
|
|
231
242
|
for attempt in range(1, max_attempts + 1):
|
|
232
243
|
try:
|
|
233
244
|
return operation()
|
|
234
245
|
except Exception as error:
|
|
235
246
|
mapped_error = classify_sdk_error(error.__cause__ or error)
|
|
236
247
|
if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
|
|
237
|
-
|
|
238
|
-
time.sleep(backoff)
|
|
248
|
+
time.sleep(self._retry_delay(attempt, mapped_error.retry_after_seconds))
|
|
239
249
|
continue
|
|
240
250
|
raise mapped_error from error
|
|
241
251
|
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)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hotdata-framework"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.0"
|
|
8
8
|
description = "Python framework for building Hotdata integrations: workspace runtime, query execution, and managed databases"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -841,9 +841,8 @@ def test_a_failed_load_job_names_the_job_alongside_the_server_message():
|
|
|
841
841
|
|
|
842
842
|
|
|
843
843
|
def test_a_deferred_load_returns_the_job_id_to_the_caller():
|
|
844
|
-
"""
|
|
845
|
-
|
|
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."""
|
|
847
846
|
from hotdata.models.submit_job_response import SubmitJobResponse
|
|
848
847
|
|
|
849
848
|
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Error message construction: the API's response body must survive.
|
|
2
|
+
|
|
3
|
+
"400: Bad Request" alone is undebuggable; the body carries the server's
|
|
4
|
+
actual explanation (e.g. which header was missing).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from hotdata.rest import ApiException
|
|
10
|
+
|
|
11
|
+
from hotdata_framework.databases import api_error_message
|
|
12
|
+
from hotdata_framework.errors import (
|
|
13
|
+
HotdataTerminalError,
|
|
14
|
+
HotdataTransientError,
|
|
15
|
+
classify_sdk_error,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
BODY = '{"error":{"code":"BAD_REQUEST","message":"X-Database-Id header is required"}}'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_classify_sdk_error_includes_response_body() -> None:
|
|
22
|
+
err = classify_sdk_error(ApiException(status=400, reason="Bad Request", body=BODY))
|
|
23
|
+
assert isinstance(err, HotdataTerminalError)
|
|
24
|
+
assert "400: Bad Request" in str(err)
|
|
25
|
+
assert "X-Database-Id header is required" in str(err)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_classify_sdk_error_without_body_keeps_short_form() -> None:
|
|
29
|
+
err = classify_sdk_error(ApiException(status=409, reason="Conflict"))
|
|
30
|
+
assert isinstance(err, HotdataTransientError)
|
|
31
|
+
assert str(err) == "409: Conflict"
|
|
32
|
+
|
|
33
|
+
|
|
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
|
+
|
|
111
|
+
def test_classify_sdk_error_truncates_and_flattens_body() -> None:
|
|
112
|
+
noisy = "x\n" * 1000
|
|
113
|
+
err = classify_sdk_error(ApiException(status=500, reason="ISE", body=noisy))
|
|
114
|
+
assert "\n" not in str(err)
|
|
115
|
+
assert len(str(err)) < 600
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_api_error_message_includes_body() -> None:
|
|
119
|
+
msg = api_error_message(ApiException(status=400, reason="Bad Request", body=BODY))
|
|
120
|
+
assert msg.startswith("Bad Request: ")
|
|
121
|
+
assert "X-Database-Id header is required" in msg
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_api_error_message_without_body() -> None:
|
|
125
|
+
assert api_error_message(ApiException(status=404, reason="Not Found")) == "Not Found"
|