Skip to content

Commit 2def22d

Browse files
jpetey75claude
andcommitted
fix: clear errors for unreachable instance and bad credentials (#1)
A misconfigured client surfaced a raw httpx error that never mentioned Lightdash (e.g. `httpx.ConnectError: nodename nor servname provided`). `_make_request` now translates the two reported failure modes into descriptive, catchable exceptions — without touching the existing API-error handling: - Transport failures (DNS, refused connection, timeout) -> LightdashConnectionError, naming instance_url and the likely cause. - HTTP 401/403 -> LightdashAuthError, pointing at the access_token. Both subclass LightdashError so existing handlers keep working, and the original httpx error is chained as __cause__. The raise_for_status path and structured error handling are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e1457cc commit 2def22d

4 files changed

Lines changed: 162 additions & 9 deletions

File tree

lightdash/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from lightdash.client import Client
77
from lightdash.exceptions import (
88
LightdashError,
9+
LightdashConnectionError,
10+
LightdashAuthError,
911
QueryError,
1012
QueryTimeout,
1113
QueryCancelled,
@@ -20,6 +22,8 @@
2022
__all__ = [
2123
'Client',
2224
'LightdashError',
25+
'LightdashConnectionError',
26+
'LightdashAuthError',
2327
'QueryError',
2428
'QueryTimeout',
2529
'QueryCancelled',

lightdash/client.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
from urllib.parse import urljoin
1010

1111
from .models import Model, Models
12-
from .exceptions import LightdashError
12+
from .exceptions import (
13+
LightdashError,
14+
LightdashConnectionError,
15+
LightdashAuthError,
16+
)
1317
from .sql_runner import SqlRunner, SqlResult
1418

1519

@@ -94,15 +98,39 @@ def _make_request(
9498
},
9599
timeout=self.timeout
96100
) as client:
97-
response = client.request(
98-
method=method,
99-
url=url,
100-
params=params,
101-
json=json,
102-
)
103-
101+
try:
102+
response = client.request(
103+
method=method,
104+
url=url,
105+
params=params,
106+
json=json,
107+
)
108+
except httpx.TimeoutException as e:
109+
raise LightdashConnectionError(
110+
f"Request to {self.instance_url} timed out after {self.timeout}s. "
111+
"The instance may be unreachable or overloaded."
112+
) from e
113+
except httpx.ConnectError as e:
114+
raise LightdashConnectionError(
115+
f"Could not connect to Lightdash at {self.instance_url}. "
116+
"Check that instance_url is correct and the instance is reachable."
117+
) from e
118+
except httpx.RequestError as e:
119+
raise LightdashConnectionError(
120+
f"Network error connecting to Lightdash at {self.instance_url}: {e}"
121+
) from e
122+
104123
self._log_response(response)
105-
124+
125+
# Authentication failures get a dedicated, actionable message.
126+
if response.status_code in (401, 403):
127+
raise LightdashAuthError(
128+
f"Authentication failed (HTTP {response.status_code}) for "
129+
f"{self.instance_url}. Check that your access_token is valid and "
130+
"has access to this project.",
131+
status_code=response.status_code,
132+
)
133+
106134
# Raise HTTP errors
107135
try:
108136
response.raise_for_status()

lightdash/exceptions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ def __init__(self, message: str, name: str = "LightdashError", status_code: int
1010
super().__init__(f"{name} ({status_code}): {message}")
1111

1212

13+
class LightdashConnectionError(LightdashError):
14+
"""Raised when the SDK cannot reach the Lightdash instance.
15+
16+
Covers DNS failures, refused connections, and timeouts — typically an
17+
incorrect ``instance_url`` or an instance that is down or unreachable.
18+
"""
19+
def __init__(self, message: str):
20+
super().__init__(message, "LightdashConnectionError", 0)
21+
22+
23+
class LightdashAuthError(LightdashError):
24+
"""Raised when authentication fails (HTTP 401/403).
25+
26+
Typically an invalid or expired ``access_token``, or a token without
27+
access to the requested project.
28+
"""
29+
def __init__(self, message: str, status_code: int = 401):
30+
super().__init__(message, "LightdashAuthError", status_code)
31+
32+
1333
class QueryError(LightdashError):
1434
"""Raised when a query fails."""
1535
def __init__(self, message: str, query_uuid: str = None):

tests/test_client_errors.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Tests for clear connection / auth error messages (issue #1).
3+
4+
A misconfigured client used to surface a raw ``httpx`` error that never
5+
mentioned Lightdash. These tests verify the SDK now translates the two reported
6+
cases — unreachable instance and bad credentials — into descriptive exceptions,
7+
while leaving the existing API-error handling untouched.
8+
"""
9+
10+
import httpx
11+
import pytest
12+
from lightdash import (
13+
Client,
14+
LightdashError,
15+
LightdashConnectionError,
16+
LightdashAuthError,
17+
)
18+
19+
20+
def _client() -> Client:
21+
return Client(
22+
instance_url="https://demo.lightdash.cloud",
23+
access_token="ldpat_token",
24+
project_uuid="proj",
25+
)
26+
27+
28+
def _response(status: int, json_body=None, text: str = "") -> httpx.Response:
29+
request = httpx.Request("GET", "https://demo.lightdash.cloud/api/v1/x")
30+
if json_body is not None:
31+
return httpx.Response(status, json=json_body, request=request)
32+
return httpx.Response(status, text=text, request=request)
33+
34+
35+
class TestConnectionErrors:
36+
def test_connect_error_is_wrapped(self, monkeypatch):
37+
"""A DNS/connection failure names the instance and the likely cause."""
38+
def boom(*args, **kwargs):
39+
raise httpx.ConnectError("nodename nor servname provided, or not known")
40+
monkeypatch.setattr(httpx.Client, "request", boom)
41+
42+
with pytest.raises(LightdashConnectionError, match="Could not connect to Lightdash") as ei:
43+
_client()._make_request("GET", "/api/v1/x")
44+
assert "demo.lightdash.cloud" in str(ei.value)
45+
assert "instance_url" in str(ei.value)
46+
47+
def test_timeout_is_wrapped(self, monkeypatch):
48+
def boom(*args, **kwargs):
49+
raise httpx.ConnectTimeout("timed out")
50+
monkeypatch.setattr(httpx.Client, "request", boom)
51+
52+
with pytest.raises(LightdashConnectionError, match="timed out"):
53+
_client()._make_request("GET", "/api/v1/x")
54+
55+
def test_connection_error_subclasses_lightdash_error(self, monkeypatch):
56+
"""Existing `except LightdashError` handlers keep working."""
57+
def boom(*args, **kwargs):
58+
raise httpx.ConnectError("x")
59+
monkeypatch.setattr(httpx.Client, "request", boom)
60+
61+
with pytest.raises(LightdashError):
62+
_client()._make_request("GET", "/api/v1/x")
63+
64+
def test_original_error_is_chained(self, monkeypatch):
65+
"""The underlying httpx error is preserved as the cause for debugging."""
66+
original = httpx.ConnectError("boom")
67+
def boom(*args, **kwargs):
68+
raise original
69+
monkeypatch.setattr(httpx.Client, "request", boom)
70+
71+
with pytest.raises(LightdashConnectionError) as ei:
72+
_client()._make_request("GET", "/api/v1/x")
73+
assert ei.value.__cause__ is original
74+
75+
76+
class TestAuthErrors:
77+
@pytest.mark.parametrize("status", [401, 403])
78+
def test_auth_failure_raises_auth_error(self, monkeypatch, status):
79+
monkeypatch.setattr(
80+
httpx.Client, "request", lambda *a, **k: _response(status, text="unauthorized")
81+
)
82+
with pytest.raises(LightdashAuthError, match="Authentication failed") as ei:
83+
_client()._make_request("GET", "/api/v1/x")
84+
assert ei.value.status_code == status
85+
assert "access_token" in str(ei.value)
86+
87+
88+
class TestExistingBehaviourPreserved:
89+
"""The structured-error and success paths are unchanged by this fix."""
90+
91+
def test_api_error_in_ok_response_is_surfaced(self, monkeypatch):
92+
body = {"status": "error", "error": {"message": "Bad query", "name": "QueryError", "statusCode": 400}}
93+
monkeypatch.setattr(httpx.Client, "request", lambda *a, **k: _response(200, json_body=body))
94+
with pytest.raises(LightdashError, match="Bad query") as ei:
95+
_client()._make_request("GET", "/api/v1/x")
96+
assert not isinstance(ei.value, LightdashAuthError)
97+
98+
def test_ok_response_returns_results(self, monkeypatch):
99+
body = {"status": "ok", "results": [{"a": 1}]}
100+
monkeypatch.setattr(httpx.Client, "request", lambda *a, **k: _response(200, json_body=body))
101+
assert _client()._make_request("GET", "/api/v1/x") == [{"a": 1}]

0 commit comments

Comments
 (0)