Skip to content

Commit de6583d

Browse files
DevonFulcherclaude
andauthored
Classify ToolCallErrors as client/server and catch COMPILED timeouts (#608)
## Why dbt-mcp errors currently have no classification for whether they represent client-side (bad input) or server-side failures. Downstream consumers like ACA need this distinction to return correct HTTP status codes and log at appropriate levels. Additionally, semantic layer queries that timeout after reaching COMPILED status indicate the query is too complex for an agent context, but this wasn't surfaced as a distinct error. ## What - Added typed union aliases `ClientToolCallError` and `ServerToolCallError` in `errors/__init__.py` — `isinstance()` works at runtime - Added `SemanticLayerQueryTimeoutError` for queries that timeout with COMPILED status, indicating the query compiled successfully but is too complex to execute in an agent context - Catch `RetryTimeoutError` with COMPILED status in `SemanticLayerFetcher.query_metrics` and raise `SemanticLayerQueryTimeoutError` (other timeout statuses fall through to the existing error handling) - Bumped `dbt-sl-sdk` from `==0.13.1` to `>=0.13.2` (needed for `RetryTimeoutError.status`) - Added missing `NotFoundError` and `SemanticLayerQueryTimeoutError` exports - Added exhaustiveness tests ensuring every `ToolCallError` subclass is classified in exactly one union - Added unit tests for COMPILED timeout behavior Drafted by Claude Opus 4.6 under the direction of @DevonFulcher --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7090fc commit de6583d

9 files changed

Lines changed: 197 additions & 9 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: Classify ToolCallErrors as client/server via TypeAlias unions and catch COMPILED timeouts as SemanticLayerQueryTimeoutError
3+
time: 2026-02-23T13:17:53.310801-06:00

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ classifiers = [
3030
dependencies = [
3131
"authlib==1.6.6",
3232
"dbt-protos==1.0.382",
33-
"dbt-sl-sdk[sync]==0.13.1",
33+
"dbt-sl-sdk[sync]>=0.13.2",
3434
"dbtlabs-vortex==0.2.0",
3535
"fastapi>=0.116.1",
3636
"uvicorn>=0.31.1",

src/dbt_mcp/errors/__init__.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,48 @@
55
)
66
from dbt_mcp.errors.base import ToolCallError
77
from dbt_mcp.errors.cli import BinaryExecutionError, CLIToolCallError
8-
from dbt_mcp.errors.common import InvalidParameterError
8+
from dbt_mcp.errors.common import InvalidParameterError, NotFoundError
99
from dbt_mcp.errors.discovery import DiscoveryToolCallError, GraphQLError
10-
from dbt_mcp.errors.semantic_layer import SemanticLayerToolCallError
10+
from dbt_mcp.errors.semantic_layer import (
11+
SemanticLayerQueryTimeoutError,
12+
SemanticLayerToolCallError,
13+
)
1114
from dbt_mcp.errors.sql import RemoteToolError, SQLToolCallError
1215

16+
ClientToolCallError = (
17+
InvalidParameterError
18+
| NotFoundError
19+
| SemanticLayerQueryTimeoutError
20+
| GraphQLError
21+
)
22+
23+
ServerToolCallError = (
24+
SemanticLayerToolCallError
25+
| CLIToolCallError
26+
| BinaryExecutionError
27+
| SQLToolCallError
28+
| RemoteToolError
29+
| DiscoveryToolCallError
30+
| AdminAPIToolCallError
31+
| AdminAPIError
32+
| ArtifactRetrievalError
33+
)
34+
1335
__all__ = [
1436
"AdminAPIError",
1537
"AdminAPIToolCallError",
1638
"ArtifactRetrievalError",
1739
"BinaryExecutionError",
1840
"CLIToolCallError",
41+
"ClientToolCallError",
1942
"DiscoveryToolCallError",
2043
"GraphQLError",
2144
"InvalidParameterError",
45+
"NotFoundError",
2246
"RemoteToolError",
2347
"SQLToolCallError",
48+
"SemanticLayerQueryTimeoutError",
2449
"SemanticLayerToolCallError",
50+
"ServerToolCallError",
2551
"ToolCallError",
2652
]

src/dbt_mcp/errors/semantic_layer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,14 @@ class SemanticLayerToolCallError(ToolCallError):
77
"""Base exception for Semantic Layer tool errors."""
88

99
pass
10+
11+
12+
class SemanticLayerQueryTimeoutError(SemanticLayerToolCallError):
13+
"""Exception raised when a semantic layer query times out with COMPILED status.
14+
15+
A COMPILED timeout means the query finished SQL compilation and is executing
16+
against the data platform. In agent contexts this indicates the query is too
17+
complex or returns too much data, and the client should simplify the request.
18+
"""
19+
20+
pass

src/dbt_mcp/semantic_layer/client.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
OrderBySpec,
1616
)
1717
from dbtsl.client.sync import SyncSemanticLayerClient
18-
from dbtsl.error import QueryFailedError
18+
from dbtsl.error import QueryFailedError, RetryTimeoutError
19+
from dbtsl.models.query import QueryStatus
1920

2021
from dbt_mcp.config.config_providers import ConfigProvider, SemanticLayerConfig
2122
from dbt_mcp.errors import InvalidParameterError
23+
from dbt_mcp.errors.semantic_layer import SemanticLayerQueryTimeoutError
2224
from dbt_mcp.semantic_layer.gql.gql import GRAPHQL_QUERIES
2325
from dbt_mcp.semantic_layer.gql.gql_request import submit_request
2426
from dbt_mcp.semantic_layer.types import (
@@ -341,7 +343,7 @@ async def query_metrics(
341343
result_formatter: Callable[[pa.Table], str] | None = None,
342344
) -> QueryMetricsResult:
343345
try:
344-
query_error = None
346+
query_error: Exception | None = None
345347
sl_client = await self.client_provider.get_client()
346348
with sl_client.session():
347349
# Catching any exception within the session
@@ -358,12 +360,28 @@ async def query_metrics(
358360
where=[where] if where else None,
359361
limit=limit,
360362
)
363+
except RetryTimeoutError as e:
364+
# Queries that timeout with COMPILED status have finished SQL
365+
# compilation and are executing against the data platform. In
366+
# agent contexts, this indicates the query is too complex and
367+
# the client should request a simpler query.
368+
if e.status == QueryStatus.COMPILED.value:
369+
raise SemanticLayerQueryTimeoutError(
370+
f"The semantic layer query timed out after {e.timeout_s}s while "
371+
f"executing against the data platform (status: COMPILED). This "
372+
f"indicates the query is too complex or returns too much data "
373+
f"for an agent context. Please simplify the query by adding "
374+
f"filters, reducing dimensions, or limiting results."
375+
) from e
376+
query_error = e
361377
except Exception as e:
362378
query_error = e
363379
if query_error:
364380
return self._format_query_failed_error(query_error)
365381
formatter = result_formatter or DEFAULT_RESULT_FORMATTER
366382
json_result = formatter(query_result)
367383
return QueryMetricsSuccess(result=json_result or "")
384+
except SemanticLayerQueryTimeoutError:
385+
raise
368386
except Exception as e:
369387
return self._format_query_failed_error(e)

tests/unit/errors/__init__.py

Whitespace-only changes.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for typed union exhaustiveness of ToolCallError classification."""
2+
3+
from typing import get_args
4+
5+
from dbt_mcp.errors import (
6+
ClientToolCallError,
7+
ServerToolCallError,
8+
)
9+
from dbt_mcp.errors.base import ToolCallError as BaseToolCallError
10+
11+
12+
def _get_all_subclasses(cls: type) -> set[type]:
13+
"""Recursively collect all subclasses of a class."""
14+
result = set()
15+
for subclass in cls.__subclasses__():
16+
result.add(subclass)
17+
result.update(_get_all_subclasses(subclass))
18+
return result
19+
20+
21+
class TestUnionExhaustiveness:
22+
"""Ensure every ToolCallError subclass is classified in exactly one union."""
23+
24+
def test_all_subclasses_are_classified(self) -> None:
25+
client_types = set(get_args(ClientToolCallError))
26+
server_types = set(get_args(ServerToolCallError))
27+
all_classified = client_types | server_types
28+
29+
all_subclasses = _get_all_subclasses(BaseToolCallError)
30+
31+
unclassified = set()
32+
for subclass in all_subclasses:
33+
# A subclass is classified if it is in a union OR if any of its
34+
# parent classes (other than ToolCallError) is in a union.
35+
classified = False
36+
for cls in subclass.__mro__:
37+
if cls in all_classified:
38+
classified = True
39+
break
40+
if not classified:
41+
unclassified.add(subclass)
42+
43+
assert unclassified == set(), (
44+
f"Unclassified ToolCallError subclasses: {unclassified}. "
45+
"Add them to ClientToolCallError or ServerToolCallError in errors/__init__.py."
46+
)
47+
48+
def test_no_overlap_between_client_and_server(self) -> None:
49+
client_types = set(get_args(ClientToolCallError))
50+
server_types = set(get_args(ServerToolCallError))
51+
overlap = client_types & server_types
52+
assert overlap == set(), (
53+
f"Types appear in both ClientToolCallError and ServerToolCallError: {overlap}"
54+
)
55+
56+
57+
class TestIsInstanceWorks:
58+
"""Verify isinstance checks work with the typed unions at runtime."""
59+
60+
def test_isinstance_client_error(self) -> None:
61+
for error_type in get_args(ClientToolCallError):
62+
instance = error_type("test")
63+
assert isinstance(instance, ClientToolCallError)
64+
65+
def test_isinstance_server_error(self) -> None:
66+
for error_type in get_args(ServerToolCallError):
67+
instance = error_type("test")
68+
assert isinstance(instance, ServerToolCallError)

tests/unit/semantic_layer/test_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66

77
import pyarrow as pa
88
import pytest
9+
from dbtsl.error import RetryTimeoutError
910

11+
from dbt_mcp.errors.semantic_layer import SemanticLayerQueryTimeoutError
1012
from dbt_mcp.semantic_layer.client import DEFAULT_RESULT_FORMATTER, SemanticLayerFetcher
13+
from dbt_mcp.semantic_layer.types import QueryMetricsError
1114

1215

1316
def test_default_result_formatter_outputs_iso_dates() -> None:
@@ -376,3 +379,62 @@ async def test_get_dimensions_includes_metadata(
376379
assert result[0].metadata == {"display_name": "Order Date"}
377380
assert result[1].metadata is None
378381
assert result[2].metadata is None
382+
383+
384+
class TestQueryMetricsCompiledTimeout:
385+
"""Tests for COMPILED timeout handling in query_metrics."""
386+
387+
@pytest.fixture
388+
def mock_sl_client(self):
389+
client = MagicMock()
390+
session_ctx = MagicMock()
391+
client.session.return_value = session_ctx
392+
session_ctx.__enter__ = MagicMock(return_value=client)
393+
session_ctx.__exit__ = MagicMock(return_value=False)
394+
return client
395+
396+
@pytest.fixture
397+
def compiled_fetcher(self, mock_config_provider, mock_sl_client):
398+
client_provider = AsyncMock()
399+
client_provider.get_client.return_value = mock_sl_client
400+
return SemanticLayerFetcher(
401+
config_provider=mock_config_provider,
402+
client_provider=client_provider,
403+
)
404+
405+
async def test_compiled_timeout_raises_client_error(
406+
self, compiled_fetcher, mock_sl_client
407+
):
408+
"""COMPILED status timeout should raise SemanticLayerQueryTimeoutError."""
409+
mock_sl_client.query.side_effect = RetryTimeoutError(
410+
timeout_s=60, status="COMPILED"
411+
)
412+
413+
with pytest.raises(SemanticLayerQueryTimeoutError) as exc_info:
414+
await compiled_fetcher.query_metrics(metrics=["revenue"])
415+
416+
assert "COMPILED" in str(exc_info.value)
417+
418+
async def test_running_timeout_returns_error_result(
419+
self, compiled_fetcher, mock_sl_client
420+
):
421+
"""RUNNING status timeout should return QueryMetricsError, not raise."""
422+
mock_sl_client.query.side_effect = RetryTimeoutError(
423+
timeout_s=60, status="RUNNING"
424+
)
425+
426+
result = await compiled_fetcher.query_metrics(metrics=["revenue"])
427+
428+
assert isinstance(result, QueryMetricsError)
429+
assert result.error is not None
430+
431+
async def test_none_status_timeout_returns_error_result(
432+
self, compiled_fetcher, mock_sl_client
433+
):
434+
"""None status timeout should return QueryMetricsError, not raise."""
435+
mock_sl_client.query.side_effect = RetryTimeoutError(timeout_s=60)
436+
437+
result = await compiled_fetcher.query_metrics(metrics=["revenue"])
438+
439+
assert isinstance(result, QueryMetricsError)
440+
assert result.error is not None

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)