Skip to content

Commit 1351bd7

Browse files
authored
fix(managed): wait on the query run, not on the result body (#79)
* fix(managed): wait on the query run, not on the result body Reading a managed table made three calls and used one. `POST /v1/query` returned an inline preview of the rows, `GET /v1/results/{id}` was polled until the result was `ready`, and the result was then fetched as Arrow. Only the Arrow copy was ever read. The readiness poll was the expensive one. `limit` on that endpoint defaults to unbounded, so polling a ready result downloads the whole result body to read one status field. It is also the wrong endpoint to lean on as a table grows: a JSON body over the per-fetch memory budget is refused with 413, and one that would fit alone but not alongside concurrent JSON fetches with 429, so the check fails on exactly the largest tables. The query is now submitted with `async`, so the server returns a run id instead of a preview, and readiness comes from `GET /v1/query-runs/{id}`, which carries no rows at any size. `result_id` is read off the run rather than off the query reply, because a run can succeed having saved nothing and the run is what reports that. Arrow stays the only path the data travels, so column types come from the server's schema rather than being inferred from a JSON preview that has none. That costs one extra round trip on a query that would have answered synchronously, against not transferring the result twice. Two status bugs surfaced while rewriting the poll. It treated `failed` and `cancelled` as the terminal failures: `cancelled` is not a status this API returns, and `interrupted` — which it does return, for a run whose server was replaced before finishing — matched neither branch, so the poll ran to its five-minute timeout rather than failing fast. An interrupted run is safe to re-run, so it is now raised as transient. `classify_sdk_error` grew a passthrough for already-classified errors, without which it demoted that transient error to terminal and cost the retry. `_wait_result_ready` and the generic `_poll` it shared go away, and the plain `ResultsApi` is no longer imported here at all — the only result endpoint this module can now reach is the streaming Arrow one. * fix(managed): enumerate in-flight statuses, not terminal ones Follow-up from review on the query-run wait. Both polls listed the statuses that mean finished and treated everything else as "keep waiting". That arrangement is what let `interrupted` be polled for the full five-minute timeout: a status absent from the list was indistinguishable from one still running. Listing the in-flight side instead -- `running` for a query run, `pending`/`processing` for a result -- makes an unrecognised status raise on the first pass with its own name in the message, so the next status the API adds costs one round trip rather than a timeout. That also fixes both status bugs in `HotdataClient`, which still had `cancelled` in its terminal sets and lacked `interrupted` entirely, so `execute_sql` kept the behaviour this branch documents as a bug. Doing both keeps the changelog entry true for the whole package rather than for one of its two clients. The async-submit test asserted the model attribute, which would still pass if the `async` alias were missing or misspelled -- the server would then ignore the field, answer synchronously with a full preview, and silently restore what this branch removes. It now asserts the serialised wire key. * fix(managed): keep waiting on an unknown status, and name it Reverses the status inversion from the previous commit, and takes the belt-and-braces route on the run-readiness question rather than arguing it a third time. The inversion traded one failure mode for a worse one. Enumerating the in-flight statuses does make a missing status surface in one round trip instead of a timeout -- but it also means a status added upstream fails every query the moment it appears, where the arrangement it replaced cost one slow call. That asymmetry was not worth the diagnosis, and the actual defect in the `interrupted` case was never the waiting: it was that the timeout said only "did not finish", never which status it had been waiting on. Both timeouts now carry it, in both clients, so the next missing status costs one slow call and names itself in the error. Separately, `_fetch_result_arrow` now waits out a result that reports itself not ready. A run reports `succeeded` only once its result is saved and ready, so this is unreachable, and the review was right that nothing in this package demonstrated that. Tolerating it is cheap and settles the question in code: the Arrow endpoint answers a result which is not ready with a small refusal rather than with data, so waiting there costs one tiny request -- which is the whole difference from waiting on the JSON result body, and the reason this is not a return to what was removed. * fix(managed): raise when a succeeded run saved no result A run succeeds with no result id when its rows came back inline but the result could not be saved for later retrieval. `fetch_table` answered `None` for that, `fetch_table_rows` turns `None` into `[]`, and `[]` is also the answer both give for a table that is not synced -- so the two states were indistinguishable to a caller. A read-modify-write load would therefore read no existing rows and write only its new batch, dropping every row already in the table. Silent data loss is the worst outcome on offer, and raising costs nothing if the state never occurs. Terminal rather than transient: re-running the query cannot save a result that was already discarded. The run's `warning_message` goes into the error, since it is the only thing that explains why. The test this replaces asserted the old behaviour -- it pinned `fetch_table` returning `None` for exactly this case, which committed the data-loss path to the suite rather than catching it. * fix(managed): read the run's warning defensively, and pin the fields `warning_message` is read while building an error, which is the worst place to assume an attribute: if an SDK release dropped it, the `AttributeError` would replace a message naming the problem with one naming nothing. `getattr` keeps the raise intact and loses only the explanation. Pinning the assumption is the better half of the fix, though. Every fake in the managed-client tests is a `SimpleNamespace`, so nothing there would notice a field being renamed or dropped -- the fakes would keep answering and the suite would keep passing, with the cost landing at runtime. A test now asserts the generated `QueryRunInfo` carries all four fields this client reads off a run, not just the one that prompted this. * fix(managed): raise on a query reply shape this client cannot read The last route by which "I do not know" could read as "no rows". If an SDK release adds a third reply model for the query endpoint, neither `isinstance` branch matches, `_query_database_scoped` returns `None`, `fetch_table` returns `None`, and `fetch_table_rows` turns that into `[]` -- the same answer it gives for a table that is not synced. A merge or append load would then write only its new batch over rows it believed were absent. Same collapse the previous commit closed for a succeeded run that saved nothing, by the other path into it. `HotdataClient` already raised here, so this also settles a disagreement between the two clients. A `None` from `fetch_table` now carries exactly one meaning: the table is not synced.
1 parent 490bf75 commit 1351bd7

6 files changed

Lines changed: 809 additions & 106 deletions

File tree

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- fix(managed): wait on the query run instead of downloading the result to check it.
13+
14+
Reading a managed table made three calls and used one. `POST /v1/query` returned
15+
an inline preview of the rows, `GET /v1/results/{id}` was polled until the
16+
result was `ready`, and the result was then fetched as Arrow. Only the Arrow
17+
copy was used.
18+
19+
The readiness poll was the expensive one. `limit` on that endpoint defaults to
20+
unbounded, so polling a ready result downloads the entire result body to read
21+
one status field. It is also the wrong endpoint to lean on as a table grows:
22+
a JSON body over the instance's per-fetch memory budget is refused with 413,
23+
and one that would fit alone but not alongside concurrent JSON fetches with
24+
429 — so the readiness check starts failing on exactly the largest tables.
25+
26+
The query is now submitted with `async`, so the server returns a run id rather
27+
than a preview, and readiness comes from `GET /v1/query-runs/{id}`, which
28+
carries no rows at any size. `result_id` is read off the run rather than off
29+
the query reply, because a run can succeed having saved nothing and the run is
30+
what reports that — and that case now raises rather than reading as an empty
31+
table. `fetch_table` answered `None` for it, which `fetch_table_rows` turns
32+
into `[]`, the same answer both give for a table that is not synced. A
33+
read-modify-write load would have read no existing rows and written only its
34+
new batch, dropping every row already there. A reply shape this client does not
35+
recognise raises for the same reason, as `HotdataClient` already did — so a
36+
`None` from `fetch_table` now means one thing only: the table is not synced.
37+
Arrow stays the only path the data travels, so column types come from the
38+
server's schema rather than being inferred from JSON.
39+
40+
Costs one extra round trip on a query that would have answered synchronously,
41+
in exchange for not transferring the result twice.
42+
43+
The Arrow fetch now also waits out a result that reports itself not ready, in
44+
case that ordering ever stops holding. It should be unreachable, and it is
45+
cheap to keep: that endpoint answers a result which is not ready with a small
46+
refusal rather than with data, which is exactly what made waiting on the JSON
47+
result body expensive and waiting here not.
48+
49+
- fix(managed): recognise `interrupted`, and drop a run status the API never sends.
50+
51+
Both `ManagedDatabaseClient` and `HotdataClient` treated `failed` and
52+
`cancelled` as the terminal run failures. `cancelled` is not a status this API
53+
returns. `interrupted` is — a run whose server was replaced before it finished
54+
— and it matched neither, so an interrupted run was polled for the full
55+
five-minute timeout and then raised `TimeoutError`: a retryable condition
56+
hidden behind a long wait and an error naming the wrong problem.
57+
58+
On `ManagedDatabaseClient` an interrupted run is now raised as transient, so
59+
the surrounding retry re-submits the query. That needed `classify_sdk_error` to
60+
pass an already-classified error through unchanged rather than demoting a
61+
caller-raised transient error to terminal. `HotdataClient.execute_sql` now
62+
fails fast on it with the run's own message.
63+
64+
Both polls keep enumerating the statuses that mean *finished*, and an
65+
unrecognised status still waits. Calling an unknown status terminal would make
66+
the omission easier to diagnose and much worse to live with: one status added
67+
upstream would fail every read at once, where waiting costs a single slow call.
68+
What made `interrupted` expensive was not the waiting — it was that the
69+
timeout never said which status it had been waiting on. Both timeouts now name
70+
it.
71+
1072
## [0.13.0] - 2026-08-27
1173

1274
### Fixed

‎hotdata_framework/client.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,17 @@
7777
_INDEX_TYPES = frozenset(get_args(IndexType))
7878
_VECTOR_METRICS = frozenset(get_args(VectorMetric))
7979

80-
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
81-
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
80+
# Query-run statuses that mean the run is over. `interrupted` belongs here --
81+
# omitting it is what made an interrupted run wait out the full timeout -- and
82+
# `cancelled`, listed here for a long time, is not a status this API sends.
83+
#
84+
# Enumerating the terminal side rather than the in-flight side is deliberate. An
85+
# unrecognised status then keeps polling and costs one slow call, where treating
86+
# it as terminal would fail every query the moment a status is added upstream.
87+
# The timeout names the status it last saw, so a missing one is diagnosable
88+
# without being dangerous.
89+
_RUN_TERMINAL = frozenset({"succeeded", "failed", "interrupted"})
90+
_RESULT_FAILURE = frozenset({"failed"})
8291
# Jobs have no "cancelled" state; "partially_succeeded" carries an error_message.
8392
_JOB_TERMINAL = frozenset({"succeeded", "partially_succeeded", "failed"})
8493

@@ -918,7 +927,7 @@ def _poll_query_run(
918927
last = None
919928
while time.monotonic() < deadline:
920929
last = runs.get_query_run(query_run_id)
921-
if last.status in _TERMINAL:
930+
if last.status in _RUN_TERMINAL:
922931
return last
923932
time.sleep(interval_s)
924933
raise TimeoutError(

‎hotdata_framework/errors.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ def _error_class(status_code: int, code: str | None) -> type[HotdataError]:
112112

113113

114114
def classify_sdk_error(error: Exception) -> HotdataError:
115+
if isinstance(error, HotdataError):
116+
# Already classified. A caller that read transience off a typed status
117+
# -- an interrupted query run, say -- knows more than this function can
118+
# recover from the exception, and the fallback below would demote it to
119+
# terminal and cost the retry.
120+
return error
115121
if isinstance(error, TimeoutError):
116122
return HotdataTransientError(str(error))
117123
if isinstance(error, ConnectionError):

‎hotdata_framework/managed_client.py‎

Lines changed: 105 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
import random
1111
import time
1212
from collections.abc import Callable
13-
from typing import Any, Protocol, TypeVar
13+
from typing import Any, TypeVar
1414

1515
import pyarrow as pa
1616
from hotdata.api.query_api import QueryApi
1717
from hotdata.api.query_runs_api import QueryRunsApi
18-
from hotdata.api.results_api import ResultsApi
18+
from hotdata.arrow import ResultNotReadyError
1919
from hotdata.arrow import ResultsApi as ArrowResultsApi
2020
from hotdata.models.async_query_response import AsyncQueryResponse
2121
from hotdata.models.query_request import QueryRequest
@@ -32,16 +32,6 @@
3232
T = TypeVar("T")
3333

3434

35-
class _StatusResponse(Protocol):
36-
"""Async resources (query runs, results) expose a status and error message."""
37-
38-
status: str
39-
error_message: str | None
40-
41-
42-
S = TypeVar("S", bound=_StatusResponse)
43-
44-
4535
class ManagedDatabaseClient:
4636
"""Managed-database client with bounded retries over hotdata-framework.
4737
@@ -130,70 +120,120 @@ def _fetch_result_arrow(self, result_id: str, *, database_id: str) -> pa.Table:
130120
0.6.0 SDK exposes (and requires) ``x_database_id`` on the Arrow
131121
helper directly.
132122
"""
133-
return ArrowResultsApi(self._runtime.api).get_result_arrow(
134-
result_id, x_database_id=database_id
135-
)
136-
137-
def _poll(
138-
self,
139-
fetch: Callable[[], S],
140-
*,
141-
is_ready: Callable[[S], bool],
142-
describe: str,
143-
) -> S:
144-
"""Poll ``fetch`` until ``is_ready`` is satisfied, or raise on failure/timeout.
145-
146-
``failed``/``cancelled`` statuses raise ``RuntimeError``; exceeding
147-
:attr:`_QUERY_TIMEOUT_SECONDS` raises ``TimeoutError``.
148-
"""
123+
arrow = ArrowResultsApi(self._runtime.api)
149124
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
150-
while time.monotonic() < deadline:
151-
obj = fetch()
152-
if obj.status in ("failed", "cancelled"):
153-
raise RuntimeError(obj.error_message or f"{describe} {obj.status}")
154-
if is_ready(obj):
155-
return obj
156-
time.sleep(self._POLL_INTERVAL_SECONDS)
157-
raise TimeoutError(f"{describe} timed out after {self._QUERY_TIMEOUT_SECONDS}s")
125+
while True:
126+
try:
127+
return arrow.get_result_arrow(result_id, x_database_id=database_id)
128+
except ResultNotReadyError:
129+
# Waiting on the run should already have made this unreachable:
130+
# a run reports `succeeded` only once its result is saved and
131+
# ready. Tolerating it anyway costs nothing and removes the need
132+
# to take that ordering on trust. The Arrow endpoint answers a
133+
# result that is not ready with a small refusal rather than with
134+
# data, so waiting here is cheap in the way waiting on the JSON
135+
# result body -- which is what this change removed -- is not.
136+
if time.monotonic() >= deadline:
137+
raise
138+
time.sleep(self._POLL_INTERVAL_SECONDS)
158139

159140
def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
160141
raw = QueryApi(self._runtime.api).query(
161-
QueryRequest(sql=sql),
142+
# Asked asynchronously because this caller wants a result id, not
143+
# rows. A synchronous submit always builds an inline preview of the
144+
# result and sends it -- megabytes, on a path that then reads the
145+
# whole result as Arrow anyway and never looks at the preview. The
146+
# async reply carries a run id and nothing else, and there is no way
147+
# to suppress the preview on a synchronous one.
148+
#
149+
# It also settles the types: the preview is JSON, which has no Arrow
150+
# schema and renders non-finite floats as null, so it could not have
151+
# substituted for the Arrow fetch even when it holds every row.
152+
#
153+
# `var_async` is the generated SDK's spelling of the wire field
154+
# `async`, which is a Python keyword and so cannot be the attribute
155+
# name.
156+
QueryRequest(sql=sql, var_async=True),
162157
x_database_id=database_id,
163158
)
164-
if isinstance(raw, QueryResponse):
165-
# A synchronous response still persists its full result out-of-band
166-
# under ``result_id``; that result may be ``processing`` when the
167-
# inline preview returns, so wait for ``ready`` before the caller
168-
# fetches it as Arrow.
169-
return self._wait_result_ready(raw.result_id, database_id=database_id)
170-
if isinstance(raw, AsyncQueryResponse):
171-
run_result = self._await_query_run(raw.query_run_id, database_id=database_id)
172-
return self._wait_result_ready(run_result, database_id=database_id)
173-
return None
159+
# Both reply shapes carry `query_run_id`, and the run is the readiness
160+
# signal for either -- a synchronous reply (which `async_after_ms` can
161+
# still produce) returns rows inline but goes on saving the full result
162+
# in the background, so it is not the finish line either.
163+
if isinstance(raw, (QueryResponse, AsyncQueryResponse)):
164+
return self._await_query_run(raw.query_run_id, database_id=database_id)
165+
# Returning nothing here would read as an empty table: `fetch_table`
166+
# answers `None`, `fetch_table_rows` turns that into `[]`, and a
167+
# read-modify-write load would write only its new batch over rows it
168+
# believed were not there. A reply shape this client does not know is a
169+
# reason to stop, not to report emptiness. `HotdataClient` raises on the
170+
# same condition.
171+
raise RuntimeError(f"Unexpected query response type: {type(raw)!r}")
174172

175173
def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None:
174+
"""Wait for a query run to finish; return the result id it produced.
175+
176+
The run is the whole wait. A run turns `succeeded` only after its result
177+
has been saved and is `ready`, so `succeeded` needs no second check
178+
against the result -- and asking the result endpoint instead would mean
179+
downloading the entire result to read one field, which the server
180+
refuses outright (413/429) once the result is large enough.
181+
182+
`result_id` comes off the run rather than off the query reply because a
183+
`succeeded` run reports none when every row came back inline but the
184+
result could not be saved for later retrieval.
185+
"""
176186
runs = QueryRunsApi(self._runtime.api)
177-
run = self._poll(
187+
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
188+
last_status: str | None = None
189+
while time.monotonic() < deadline:
178190
# Runs (like results) of database-scoped queries are database-scoped.
179-
lambda: runs.get_query_run(query_run_id, x_database_id=database_id),
180-
is_ready=lambda r: r.status == "succeeded",
181-
describe="Query",
182-
)
183-
return run.result_id
184-
185-
def _wait_result_ready(self, result_id: str | None, *, database_id: str) -> str | None:
186-
if result_id is None:
187-
return None
188-
results = ResultsApi(self._runtime.api)
189-
self._poll(
190-
# The stored result of a database-scoped query 400s without the
191-
# database scope.
192-
lambda: results.get_result(result_id, x_database_id=database_id),
193-
is_ready=lambda r: r.status == "ready",
194-
describe=f"Result {result_id}",
191+
run = runs.get_query_run(query_run_id, x_database_id=database_id)
192+
last_status = run.status
193+
if run.status == "succeeded":
194+
if run.result_id is None:
195+
# A run succeeds with no result id when its rows were
196+
# returned inline but the result could not be saved for
197+
# later retrieval. Returning nothing here would surface as
198+
# an empty table -- `fetch_table` answers `None`, and
199+
# `fetch_table_rows` turns that into `[]`, which is the same
200+
# answer it gives for a table that does not exist. A
201+
# read-modify-write load would then read no existing rows
202+
# and write only its new batch, dropping what was there.
203+
# Terminal rather than transient: re-running the query
204+
# cannot save a result that was already discarded.
205+
# `getattr` because this runs while building an error: if
206+
# the field ever goes away, losing the explanation is a far
207+
# better outcome than an AttributeError replacing the raise.
208+
warning = getattr(run, "warning_message", None)
209+
raise RuntimeError(
210+
f"Query run {query_run_id} succeeded but its result was not "
211+
f"saved, so the table cannot be read"
212+
+ (f": {warning}" if warning else "")
213+
)
214+
return run.result_id
215+
if run.status == "interrupted":
216+
# Terminal, but the server lost the run rather than rejecting
217+
# the query, so it is the one failure here worth re-running.
218+
# Raised pre-classified: `classify_sdk_error` cannot tell this
219+
# apart from an ordinary RuntimeError and would call it terminal.
220+
raise HotdataTransientError(
221+
run.error_message or f"Query run {query_run_id} was interrupted"
222+
)
223+
if run.status == "failed":
224+
raise RuntimeError(run.error_message or f"Query run {query_run_id} failed")
225+
# Any other status keeps polling, including one this client has never
226+
# seen. Treating an unrecognised status as terminal is the cheaper
227+
# failure to diagnose and by far the more expensive one to suffer: a
228+
# single status added upstream would then fail every query at once,
229+
# where waiting costs one slow call. What made `interrupted`
230+
# expensive was not the waiting, it was that the timeout never said
231+
# which status it had waited on -- so the message now carries it.
232+
time.sleep(self._POLL_INTERVAL_SECONDS)
233+
raise TimeoutError(
234+
f"Query run {query_run_id} did not finish within "
235+
f"{self._QUERY_TIMEOUT_SECONDS}s (last status: {last_status})"
195236
)
196-
return result_id
197237

198238
def fetch_table_rows(self, *, database: str, schema: str, table: str) -> list[dict[str, Any]]:
199239
result = self.fetch_table(database=database, schema=schema, table=table)

‎tests/test_client.py‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,20 +245,67 @@ def test_list_qualified_table_names_passes_connection_id():
245245
assert it.call_args.kwargs["connection_id"] == "conn_a"
246246

247247

248-
def test_wait_result_ready_raises_on_cancelled():
248+
def test_wait_result_ready_raises_on_a_failed_result():
249249
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
250250

251251
class FakeResultsApi:
252252
def get_result(self, result_id: str):
253-
return SimpleNamespace(status="cancelled", error_message=None)
253+
return SimpleNamespace(status="failed", error_message="out of memory")
254254

255255
with (
256256
patch.object(client, "_results_api", return_value=FakeResultsApi()),
257-
pytest.raises(RuntimeError, match="cancelled"),
257+
pytest.raises(RuntimeError, match="out of memory"),
258258
):
259259
client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0)
260260

261261

262+
def test_unknown_result_status_times_out_and_names_the_status():
263+
"""An unrecognised status keeps polling rather than being called terminal.
264+
265+
Failing fast on an unknown status would be easier to debug, and far worse to
266+
live with: one status added upstream would fail every read at once, where
267+
waiting costs a single slow call. The timeout names what it waited on, which
268+
is what makes the omission findable -- and what was missing when
269+
`interrupted` went unrecognised.
270+
"""
271+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
272+
273+
class FakeResultsApi:
274+
def get_result(self, result_id: str):
275+
return SimpleNamespace(status="something_new", error_message=None)
276+
277+
with (
278+
patch.object(client, "_results_api", return_value=FakeResultsApi()),
279+
pytest.raises(TimeoutError, match="something_new"),
280+
):
281+
client._wait_result_ready("res_1", timeout_s=0.05, interval_s=0)
282+
283+
284+
def test_poll_query_run_returns_promptly_on_interrupted():
285+
"""`interrupted` is terminal, so the poll must stop on it.
286+
287+
It was absent from the terminal set, so an interrupted run was polled for the
288+
full timeout and then raised `TimeoutError` -- hiding a retryable condition
289+
behind a five-minute wait, behind an error naming the wrong problem.
290+
"""
291+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
292+
calls: list[str] = []
293+
294+
class FakeQueryRunsApi:
295+
def get_query_run(self, query_run_id: str):
296+
calls.append(query_run_id)
297+
return SimpleNamespace(
298+
status="interrupted", error_message="instance lost", result_id=None
299+
)
300+
301+
with patch.object(client, "_query_runs_api", return_value=FakeQueryRunsApi()):
302+
run = client._poll_query_run("qrun_1", timeout_s=30.0, interval_s=0)
303+
304+
assert run.status == "interrupted"
305+
# One request, not a timeout's worth.
306+
assert len(calls) == 1
307+
308+
262309
def test_connection_id_by_name_raises_on_duplicate_names():
263310
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
264311
listing = SimpleNamespace(

0 commit comments

Comments
 (0)