Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lightdash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from lightdash.client import Client
from lightdash.exceptions import (
LightdashError,
LightdashConnectionError,
LightdashAuthError,
QueryError,
QueryTimeout,
QueryCancelled,
Expand All @@ -20,6 +22,8 @@
__all__ = [
'Client',
'LightdashError',
'LightdashConnectionError',
'LightdashAuthError',
'QueryError',
'QueryTimeout',
'QueryCancelled',
Expand Down
46 changes: 37 additions & 9 deletions lightdash/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from urllib.parse import urljoin

from .models import Model, Models
from .exceptions import LightdashError
from .exceptions import (
LightdashError,
LightdashConnectionError,
LightdashAuthError,
)
from .sql_runner import SqlRunner, SqlResult


Expand Down Expand Up @@ -94,15 +98,39 @@ def _make_request(
},
timeout=self.timeout
) as client:
response = client.request(
method=method,
url=url,
params=params,
json=json,
)

try:
response = client.request(
method=method,
url=url,
params=params,
json=json,
)
except httpx.TimeoutException as e:
raise LightdashConnectionError(
f"Request to {self.instance_url} timed out after {self.timeout}s. "
"The instance may be unreachable or overloaded."
) from e
except httpx.ConnectError as e:
raise LightdashConnectionError(
f"Could not connect to Lightdash at {self.instance_url}. "
"Check that instance_url is correct and the instance is reachable."
) from e
except httpx.RequestError as e:
raise LightdashConnectionError(
f"Network error connecting to Lightdash at {self.instance_url}: {e}"
) from e

self._log_response(response)


# Authentication failures get a dedicated, actionable message.
if response.status_code in (401, 403):
raise LightdashAuthError(
f"Authentication failed (HTTP {response.status_code}) for "
f"{self.instance_url}. Check that your access_token is valid and "
"has access to this project.",
status_code=response.status_code,
)

# Raise HTTP errors
try:
response.raise_for_status()
Expand Down
20 changes: 20 additions & 0 deletions lightdash/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ def __init__(self, message: str, name: str = "LightdashError", status_code: int
super().__init__(f"{name} ({status_code}): {message}")


class LightdashConnectionError(LightdashError):
"""Raised when the SDK cannot reach the Lightdash instance.

Covers DNS failures, refused connections, and timeouts — typically an
incorrect ``instance_url`` or an instance that is down or unreachable.
"""
def __init__(self, message: str):
super().__init__(message, "LightdashConnectionError", 0)


class LightdashAuthError(LightdashError):
"""Raised when authentication fails (HTTP 401/403).

Typically an invalid or expired ``access_token``, or a token without
access to the requested project.
"""
def __init__(self, message: str, status_code: int = 401):
super().__init__(message, "LightdashAuthError", status_code)


class QueryError(LightdashError):
"""Raised when a query fails."""
def __init__(self, message: str, query_uuid: str = None):
Expand Down
101 changes: 101 additions & 0 deletions tests/test_client_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""
Tests for clear connection / auth error messages (issue #1).

A misconfigured client used to surface a raw ``httpx`` error that never
mentioned Lightdash. These tests verify the SDK now translates the two reported
cases — unreachable instance and bad credentials — into descriptive exceptions,
while leaving the existing API-error handling untouched.
"""

import httpx
import pytest
from lightdash import (
Client,
LightdashError,
LightdashConnectionError,
LightdashAuthError,
)


def _client() -> Client:
return Client(
instance_url="https://demo.lightdash.cloud",
access_token="ldpat_token",
project_uuid="proj",
)


def _response(status: int, json_body=None, text: str = "") -> httpx.Response:
request = httpx.Request("GET", "https://demo.lightdash.cloud/api/v1/x")
if json_body is not None:
return httpx.Response(status, json=json_body, request=request)
return httpx.Response(status, text=text, request=request)


class TestConnectionErrors:
def test_connect_error_is_wrapped(self, monkeypatch):
"""A DNS/connection failure names the instance and the likely cause."""
def boom(*args, **kwargs):
raise httpx.ConnectError("nodename nor servname provided, or not known")
monkeypatch.setattr(httpx.Client, "request", boom)

with pytest.raises(LightdashConnectionError, match="Could not connect to Lightdash") as ei:
_client()._make_request("GET", "/api/v1/x")
assert "demo.lightdash.cloud" in str(ei.value)
assert "instance_url" in str(ei.value)

def test_timeout_is_wrapped(self, monkeypatch):
def boom(*args, **kwargs):
raise httpx.ConnectTimeout("timed out")
monkeypatch.setattr(httpx.Client, "request", boom)

with pytest.raises(LightdashConnectionError, match="timed out"):
_client()._make_request("GET", "/api/v1/x")

def test_connection_error_subclasses_lightdash_error(self, monkeypatch):
"""Existing `except LightdashError` handlers keep working."""
def boom(*args, **kwargs):
raise httpx.ConnectError("x")
monkeypatch.setattr(httpx.Client, "request", boom)

with pytest.raises(LightdashError):
_client()._make_request("GET", "/api/v1/x")

def test_original_error_is_chained(self, monkeypatch):
"""The underlying httpx error is preserved as the cause for debugging."""
original = httpx.ConnectError("boom")
def boom(*args, **kwargs):
raise original
monkeypatch.setattr(httpx.Client, "request", boom)

with pytest.raises(LightdashConnectionError) as ei:
_client()._make_request("GET", "/api/v1/x")
assert ei.value.__cause__ is original


class TestAuthErrors:
@pytest.mark.parametrize("status", [401, 403])
def test_auth_failure_raises_auth_error(self, monkeypatch, status):
monkeypatch.setattr(
httpx.Client, "request", lambda *a, **k: _response(status, text="unauthorized")
)
with pytest.raises(LightdashAuthError, match="Authentication failed") as ei:
_client()._make_request("GET", "/api/v1/x")
assert ei.value.status_code == status
assert "access_token" in str(ei.value)


class TestExistingBehaviourPreserved:
"""The structured-error and success paths are unchanged by this fix."""

def test_api_error_in_ok_response_is_surfaced(self, monkeypatch):
body = {"status": "error", "error": {"message": "Bad query", "name": "QueryError", "statusCode": 400}}
monkeypatch.setattr(httpx.Client, "request", lambda *a, **k: _response(200, json_body=body))
with pytest.raises(LightdashError, match="Bad query") as ei:
_client()._make_request("GET", "/api/v1/x")
assert not isinstance(ei.value, LightdashAuthError)

def test_ok_response_returns_results(self, monkeypatch):
body = {"status": "ok", "results": [{"a": 1}]}
monkeypatch.setattr(httpx.Client, "request", lambda *a, **k: _response(200, json_body=body))
assert _client()._make_request("GET", "/api/v1/x") == [{"a": 1}]