Skip to content

Commit 7a2785f

Browse files
DevonFulcherclaude
andcommitted
Add status to RetryTimeoutError for query status visibility on timeout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 865ba41 commit 7a2785f

8 files changed

Lines changed: 99 additions & 5 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Under the Hood
2+
body: Add status to RetryTimeoutError
3+
time: 2026-02-17T10:22:38.733159-06:00

dbtsl/api/graphql/client/asyncio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ async def _poll_until_complete(
142142

143143
elapsed_s = time.time() - start_s
144144
if elapsed_s > total_timeout_s:
145-
raise RetryTimeoutError(timeout_s=total_timeout_s)
145+
raise RetryTimeoutError(timeout_s=total_timeout_s, status=qr.status.value)
146146

147147
await asyncio.sleep(sleep_ms / 1000)
148148

dbtsl/api/graphql/client/sync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def _poll_until_complete(
130130

131131
elapsed_s = time.time() - start_s
132132
if elapsed_s > total_timeout_s:
133-
raise RetryTimeoutError(timeout_s=total_timeout_s)
133+
raise RetryTimeoutError(timeout_s=total_timeout_s, status=qr.status.value)
134134

135135
time.sleep(sleep_ms / 1000)
136136

dbtsl/error.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ class ExecuteTimeoutError(TimeoutError):
4343
class RetryTimeoutError(TimeoutError):
4444
"""Raise whenever a timeout occurred while retrying an operation against the servers."""
4545

46+
def __init__(self, *, timeout_s: float, status: Optional[str] = None) -> None:
47+
"""Initialize the retry timeout error.
48+
49+
Args:
50+
timeout_s: The maximum time limit that got exceeded, in seconds
51+
status: The last known query status before the timeout occurred
52+
**_kwargs: any other exception kwargs
53+
"""
54+
super().__init__(timeout_s=timeout_s)
55+
self.status = status
56+
57+
def __str__(self) -> str: # noqa: D105
58+
if self.status is not None:
59+
return f"{self.__class__.__name__}(timeout_s={self.timeout_s}, status={self.status})"
60+
return f"{self.__class__.__name__}(timeout_s={self.timeout_s})"
61+
4662

4763
class QueryFailedError(SemanticLayerError):
4864
"""Raise whenever a query has failed."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async = ["gql[aiohttp]>=3.5.0,<4.0.0"]
2929
sync = ["gql[requests]>=3.5.0,<4.0.0"]
3030
dev = [
3131
"pyarrow-stubs",
32-
"ruff",
32+
"ruff>=0.15",
3333
"basedpyright",
3434
"mypy",
3535
"uv",

tests/api/graphql/test_client.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from dbtsl.api.graphql.client.asyncio import AsyncGraphQLClient
1111
from dbtsl.api.graphql.client.sync import SyncGraphQLClient
1212
from dbtsl.api.graphql.protocol import GetQueryResultVariables, GraphQLProtocol, ProtocolOperation
13+
from dbtsl.error import RetryTimeoutError
1314
from dbtsl.models.query import QueryId, QueryResult, QueryStatus
1415

1516
# The following 2 tests are copies of each other since testing the same sync/async functionality is
@@ -145,3 +146,63 @@ def run_behavior(op: ProtocolOperation[Any, Any], raw_variables: GetQueryResultV
145146
)
146147

147148
assert result_table.equals(table, check_metadata=True)
149+
150+
151+
# avoid raising mock warning related to mocking a context manager
152+
@pytest.mark.filterwarnings("ignore::pytest_mock.PytestMockWarning")
153+
def test_sync_poll_timeout_includes_status(mocker: MockerFixture) -> None:
154+
"""Test that RetryTimeoutError includes the last known query status."""
155+
client = SyncGraphQLClient(server_host="test", environment_id=0, auth_token="test", timeout=0.001, lazy=False)
156+
157+
compiled_result = QueryResult(
158+
query_id=QueryId("test-query-id"),
159+
status=QueryStatus.COMPILED,
160+
sql=None,
161+
error=None,
162+
total_pages=None,
163+
arrow_result=None,
164+
)
165+
166+
run_mock = MagicMock(return_value=compiled_result)
167+
mocker.patch.object(client, "_run", new=run_mock)
168+
169+
mocker.patch.object(client, "create_query", return_value=QueryId("test-query-id"))
170+
171+
gql_mock = mocker.patch.object(client, "_gql")
172+
mocker.patch.object(gql_mock, "__aenter__")
173+
mocker.patch("dbtsl.api.graphql.client.sync.isinstance", return_value=True)
174+
175+
with client.session():
176+
with pytest.raises(RetryTimeoutError) as exc_info:
177+
client.query(metrics=["m1"])
178+
179+
assert exc_info.value.status == "COMPILED"
180+
181+
182+
async def test_async_poll_timeout_includes_status(mocker: MockerFixture) -> None:
183+
"""Test that RetryTimeoutError includes the last known query status (async)."""
184+
client = AsyncGraphQLClient(server_host="test", environment_id=0, auth_token="test", timeout=0.001, lazy=False)
185+
186+
compiled_result = QueryResult(
187+
query_id=QueryId("test-query-id"),
188+
status=QueryStatus.COMPILED,
189+
sql=None,
190+
error=None,
191+
total_pages=None,
192+
arrow_result=None,
193+
)
194+
195+
run_mock = AsyncMock(return_value=compiled_result)
196+
mocker.patch.object(client, "_run", new=run_mock)
197+
198+
mocker.patch.object(client, "create_query", return_value=QueryId("test-query-id"), new_callable=AsyncMock)
199+
200+
gql_mock = mocker.patch.object(client, "_gql")
201+
mocker.patch.object(gql_mock, "__aenter__", new_callable=AsyncMock)
202+
mocker.patch("dbtsl.api.graphql.client.asyncio.isinstance", return_value=True)
203+
204+
async with client.session():
205+
with pytest.raises(RetryTimeoutError) as exc_info:
206+
await client.query(metrics=["m1"])
207+
208+
assert exc_info.value.status == "COMPILED"

tests/test_error.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from dbtsl.error import SemanticLayerError, TimeoutError
1+
from dbtsl.error import RetryTimeoutError, SemanticLayerError, TimeoutError
22

33

44
def test_error_str_calls_repr() -> None:
@@ -15,3 +15,17 @@ def test_error_repr_with_args() -> None:
1515

1616
def test_timeout_error_str() -> None:
1717
assert str(TimeoutError(timeout_s=1000)) == "TimeoutError(timeout_s=1000)"
18+
19+
20+
def test_retry_timeout_error_without_status() -> None:
21+
err = RetryTimeoutError(timeout_s=60)
22+
assert err.timeout_s == 60
23+
assert err.status is None
24+
assert str(err) == "RetryTimeoutError(timeout_s=60)"
25+
26+
27+
def test_retry_timeout_error_with_status() -> None:
28+
err = RetryTimeoutError(timeout_s=30, status="COMPILED")
29+
assert err.timeout_s == 30
30+
assert err.status == "COMPILED"
31+
assert str(err) == "RetryTimeoutError(timeout_s=30, status=COMPILED)"

tests/test_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class EnumTestInvalidUnknown(Enum, metaclass=FlexibleEnumMeta):
6767
def test_all_enum_models_are_flexible() -> None:
6868
"""Make sure we didn't forget to make any enum type flexible."""
6969
exported_enum_classes = inspect.getmembers(
70-
ALL_EXPORTED_MODELS, lambda member: (inspect.isclass(member) and issubclass(member, Enum))
70+
ALL_EXPORTED_MODELS, lambda member: inspect.isclass(member) and issubclass(member, Enum)
7171
)
7272
for enum_class_name, _ in exported_enum_classes:
7373
msg = f"Enum {enum_class_name} needs to have FlexibleEnumMeta metaclass."

0 commit comments

Comments
 (0)