Skip to content

Commit 155433b

Browse files
authored
Merge branch 'main' into dependabot/uv/hotdata-0.6.0
2 parents baaaaa8 + 200be36 commit 155433b

6 files changed

Lines changed: 180 additions & 16 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `ManagedDatabaseClient.fetch_table` now carries the `X-Database-Id` scope header on the result poll, the query-run poll, and the Arrow fetch — not only on the query submit. Results of database-scoped queries are themselves database-scoped, so every read against an existing synced table (merge/append loads, dlt state restore) failed with `400: Bad Request` once the table had data (dlthubworker#70).
13+
- API error messages now include the response body (flattened, truncated to 500 chars). `400: Bad Request` alone hid the server's actual explanation.
1014

1115
## [0.6.0] - 2026-06-30
1216

hotdata_framework/databases.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,9 @@ def managed_database_from_detail(detail: Any) -> ManagedDatabase:
5858

5959

6060
def api_error_message(exc: ApiException) -> str:
61-
return exc.reason or str(exc)
61+
reason = exc.reason or str(exc)
62+
# Keep the response body: it carries the API's actual explanation.
63+
body = getattr(exc, "body", None)
64+
if body:
65+
return f"{reason}: {' '.join(str(body).split())[:500]}"
66+
return reason

hotdata_framework/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ def classify_sdk_error(error: Exception) -> HotdataError:
2323
if isinstance(error, ApiException):
2424
status_code = int(error.status or 0)
2525
message = f"{status_code}: {error.reason or 'unknown error'}"
26+
# The response body is where the API explains itself (e.g. which
27+
# header is missing) — without it "400: Bad Request" is undebuggable.
28+
body = getattr(error, "body", None)
29+
if body:
30+
message = f"{message}{' '.join(str(body).split())[:500]}"
2631
if status_code in (408, 409, 425, 429):
2732
return HotdataTransientError(message)
2833
if 500 <= status_code <= 599:

hotdata_framework/managed_client.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,26 @@ def operation() -> pa.Table | None:
110110
result_id = self._query_database_scoped(sql, database_id=db.id)
111111
if result_id is None:
112112
return None
113-
return ArrowResultsApi(self._runtime.api).get_result_arrow(result_id)
113+
return self._fetch_result_arrow(result_id, database_id=db.id)
114114

115115
return self._request_with_retry(operation)
116116

117+
def _fetch_result_arrow(self, result_id: str, *, database_id: str) -> pa.Table:
118+
"""Fetch a ready result as Arrow, carrying the database scope.
119+
120+
Results of database-scoped queries are themselves database-scoped —
121+
the results endpoints reject requests without an ``X-Database-Id``
122+
header. The Arrow helper takes no per-call headers, so the header is
123+
set as a client default for the duration of the call and removed
124+
after (this client is not shared across threads).
125+
"""
126+
api = self._runtime.api
127+
api.set_default_header("X-Database-Id", database_id)
128+
try:
129+
return ArrowResultsApi(api).get_result_arrow(result_id)
130+
finally:
131+
api.default_headers.pop("X-Database-Id", None)
132+
117133
def _poll(
118134
self,
119135
fetch: Callable[[], S],
@@ -146,26 +162,34 @@ def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
146162
# under ``result_id``; that result may be ``processing`` when the
147163
# inline preview returns, so wait for ``ready`` before the caller
148164
# fetches it as Arrow.
149-
return self._wait_result_ready(raw.result_id)
165+
return self._wait_result_ready(raw.result_id, database_id=database_id)
150166
if isinstance(raw, AsyncQueryResponse):
151-
return self._wait_result_ready(self._await_query_run(raw.query_run_id))
167+
run_result = self._await_query_run(raw.query_run_id, database_id=database_id)
168+
return self._wait_result_ready(run_result, database_id=database_id)
152169
return None
153170

154-
def _await_query_run(self, query_run_id: str) -> str | None:
171+
def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None:
155172
runs = QueryRunsApi(self._runtime.api)
156173
run = self._poll(
157-
lambda: runs.get_query_run(query_run_id),
174+
# Runs (like results) of database-scoped queries are database-scoped.
175+
lambda: runs.get_query_run(
176+
query_run_id, _headers={"X-Database-Id": database_id}
177+
),
158178
is_ready=lambda r: r.status == "succeeded",
159179
describe="Query",
160180
)
161181
return run.result_id
162182

163-
def _wait_result_ready(self, result_id: str | None) -> str | None:
183+
def _wait_result_ready(self, result_id: str | None, *, database_id: str) -> str | None:
164184
if result_id is None:
165185
return None
166186
results = ResultsApi(self._runtime.api)
167187
self._poll(
168-
lambda: results.get_result(result_id),
188+
# The stored result of a database-scoped query 400s without the
189+
# database scope header.
190+
lambda: results.get_result(
191+
result_id, _headers={"X-Database-Id": database_id}
192+
),
169193
is_ready=lambda r: r.status == "ready",
170194
describe=f"Result {result_id}",
171195
)

tests/test_errors.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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). Regression for the
5+
opaque load failures in dlthubworker#70.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from hotdata.rest import ApiException
11+
12+
from hotdata_framework.databases import api_error_message
13+
from hotdata_framework.errors import (
14+
HotdataTerminalError,
15+
HotdataTransientError,
16+
classify_sdk_error,
17+
)
18+
19+
BODY = '{"error":{"code":"BAD_REQUEST","message":"X-Database-Id header is required"}}'
20+
21+
22+
def test_classify_sdk_error_includes_response_body() -> None:
23+
err = classify_sdk_error(ApiException(status=400, reason="Bad Request", body=BODY))
24+
assert isinstance(err, HotdataTerminalError)
25+
assert "400: Bad Request" in str(err)
26+
assert "X-Database-Id header is required" in str(err)
27+
28+
29+
def test_classify_sdk_error_without_body_keeps_short_form() -> None:
30+
err = classify_sdk_error(ApiException(status=409, reason="Conflict"))
31+
assert isinstance(err, HotdataTransientError)
32+
assert str(err) == "409: Conflict"
33+
34+
35+
def test_classify_sdk_error_truncates_and_flattens_body() -> None:
36+
noisy = "x\n" * 1000
37+
err = classify_sdk_error(ApiException(status=500, reason="ISE", body=noisy))
38+
assert "\n" not in str(err)
39+
assert len(str(err)) < 600
40+
41+
42+
def test_api_error_message_includes_body() -> None:
43+
msg = api_error_message(ApiException(status=400, reason="Bad Request", body=BODY))
44+
assert msg.startswith("Bad Request: ")
45+
assert "X-Database-Id header is required" in msg
46+
47+
48+
def test_api_error_message_without_body() -> None:
49+
assert api_error_message(ApiException(status=404, reason="Not Found")) == "Not Found"

tests/test_managed_client.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class FakeResultsApi:
5050
def __init__(self, api: object) -> None:
5151
pass
5252

53-
def get_result(self, result_id: str) -> Any:
53+
def get_result(self, result_id: str, **kwargs: Any) -> Any:
5454
status = next(statuses)
5555
calls.append(f"get_result:{status}")
5656
return SimpleNamespace(status=status, result_id=result_id, error_message=None)
@@ -75,13 +75,7 @@ def get_result_arrow(self, result_id: str) -> pa.Table:
7575
max_retries=1,
7676
retry_backoff_seconds=0.0,
7777
)
78-
client._runtime = SimpleNamespace( # type: ignore[assignment]
79-
api=object(),
80-
resolve_managed_database=lambda name: SimpleNamespace(id="db1", default_connection_id="c"),
81-
list_managed_tables=lambda database, schema=None: [
82-
SimpleNamespace(table="orders", synced=True)
83-
],
84-
)
78+
client._runtime = _fake_runtime()
8579

8680
table = client.fetch_table(database="mydb", schema="public", table="orders")
8781

@@ -91,3 +85,86 @@ def get_result_arrow(self, result_id: str) -> pa.Table:
9185
assert "get_result:processing" in calls
9286
assert "get_result:ready" in calls
9387
assert calls.index("arrow") > calls.index("get_result:ready")
88+
89+
90+
class _FakeApiClient:
91+
"""Just enough of the generated ApiClient's default-header surface."""
92+
93+
def __init__(self) -> None:
94+
self.default_headers: dict[str, str] = {}
95+
96+
def set_default_header(self, name: str, value: str) -> None:
97+
self.default_headers[name] = value
98+
99+
100+
def _fake_runtime(api: object | None = None) -> SimpleNamespace:
101+
return SimpleNamespace(
102+
api=api if api is not None else _FakeApiClient(),
103+
resolve_managed_database=lambda name: SimpleNamespace(id="db1", default_connection_id="c"),
104+
list_managed_tables=lambda database, schema=None: [
105+
SimpleNamespace(table="orders", synced=True)
106+
],
107+
)
108+
109+
110+
def test_fetch_table_carries_database_scope_on_result_reads(
111+
monkeypatch: pytest.MonkeyPatch,
112+
) -> None:
113+
"""Results (and runs) of a database-scoped query are database-scoped:
114+
the results endpoints 400 with "X-Database-Id header is required" when
115+
the header is missing. ``fetch_table`` must carry the database id on the
116+
result poll and the Arrow fetch, not only on the query submit.
117+
118+
Regression: reruns/append loads against an existing synced table failed
119+
with an opaque ``400: Bad Request`` (dlthubworker#70) because both reads
120+
omitted the header.
121+
"""
122+
seen_headers: list[dict[str, Any] | None] = []
123+
arrow_headers: list[str | None] = []
124+
fake_api = _FakeApiClient()
125+
126+
class FakeQueryApi:
127+
def __init__(self, api: object) -> None:
128+
pass
129+
130+
def query(self, request: object, *, x_database_id: str) -> QueryResponse:
131+
assert x_database_id == "db1"
132+
return _query_response("rslt1")
133+
134+
class FakeResultsApi:
135+
def __init__(self, api: object) -> None:
136+
pass
137+
138+
def get_result(self, result_id: str, *, _headers: dict[str, Any] | None = None) -> Any:
139+
seen_headers.append(_headers)
140+
return SimpleNamespace(status="ready", result_id=result_id, error_message=None)
141+
142+
class FakeArrowResultsApi:
143+
def __init__(self, api: _FakeApiClient) -> None:
144+
self._api = api
145+
146+
def get_result_arrow(self, result_id: str) -> pa.Table:
147+
# The scoped default header must be present DURING the fetch.
148+
arrow_headers.append(self._api.default_headers.get("X-Database-Id"))
149+
return pa.table({"id": [1]})
150+
151+
monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
152+
monkeypatch.setattr(mc, "ResultsApi", FakeResultsApi)
153+
monkeypatch.setattr(mc, "ArrowResultsApi", FakeArrowResultsApi)
154+
155+
client = mc.ManagedDatabaseClient(
156+
api_key="k",
157+
workspace_id="w",
158+
api_base_url="https://example.test",
159+
max_retries=1,
160+
retry_backoff_seconds=0.0,
161+
)
162+
client._runtime = _fake_runtime(fake_api)
163+
164+
table = client.fetch_table(database="mydb", schema="public", table="orders")
165+
166+
assert table is not None
167+
assert seen_headers == [{"X-Database-Id": "db1"}]
168+
assert arrow_headers == ["db1"]
169+
# The scoped header is removed once the fetch completes.
170+
assert "X-Database-Id" not in fake_api.default_headers

0 commit comments

Comments
 (0)