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
20 changes: 20 additions & 0 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from urllib import request as urllib_request
from urllib.parse import urlparse

from databricks.sql.exc import ServerOperationError

from ucode.config_io import APP_DIR
from ucode.ui import (
err_console,
Expand Down Expand Up @@ -1037,12 +1039,30 @@ def run_usage_query(
cursor.execute(query)
columns = [desc[0] for desc in (cursor.description or [])]
rows = cast(list[tuple], cursor.fetchall())
except ServerOperationError as exc:
if _is_usage_table_access_error(exc):
raise RuntimeError(
"Unable to read `system.ai_gateway.usage`. Ask your workspace admin "
"to enable READ access to `system.ai_gateway.usage` for your account."
) from exc
raise RuntimeError(f"Usage query failed: {exc}") from exc
except Exception as exc:
raise RuntimeError(f"Usage query failed: {exc}") from exc

return columns, rows


def _is_usage_table_access_error(exc: BaseException) -> bool:
"""Return True when a `ServerOperationError` blocks reads of
`system.ai_gateway.usage` — gated on one of the bracketed error codes
`INSUFFICIENT_PERMISSIONS` plus a `system.ai_gateway` substring (identifier quoting
stripped first)."""
normalized = str(exc).lower().translate(str.maketrans("", "", """`[]"'"""))
if "system.ai_gateway" not in normalized:
return False
return "insufficient_permissions" in normalized


# ---------------------------------------------------------------------------
# URL builders (AI Gateway v2 only — no fallback to /serving-endpoints)
# ---------------------------------------------------------------------------
Expand Down
107 changes: 107 additions & 0 deletions tests/test_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,110 @@ def test_raises_when_version_unparseable(self, tmp_path, monkeypatch):
monkeypatch.setattr("os.environ", env)
with pytest.raises(RuntimeError, match="Could not parse"):
ensure_databricks_cli_version()


class TestIsUsageTableAccessError:
"""Pin which `ServerOperationError` strings trigger the friendly
`system.ai_gateway.usage` permissions hint vs. fall through to the
generic `Usage query failed: ...` arm."""

@staticmethod
def _err(msg: str):
from databricks.sql.exc import ServerOperationError

return ServerOperationError(msg)

def test_table_level_select_denial_matches(self):
msg = (
"[INSUFFICIENT_PERMISSIONS] Insufficient privileges: "
"User does not have SELECT on Table 'system.ai_gateway.usage'. "
"SQLSTATE: 42501"
)
assert db_mod._is_usage_table_access_error(self._err(msg)) is True

def test_schema_level_use_schema_denial_matches(self):
msg = (
"[INSUFFICIENT_PERMISSIONS] Insufficient privileges: "
"User does not have USE SCHEMA on Schema 'system.ai_gateway'. "
"SQLSTATE: 42501"
)
assert db_mod._is_usage_table_access_error(self._err(msg)) is True

def test_unrelated_catalog_denial_falls_through(self):
msg = (
"[INSUFFICIENT_PERMISSIONS] Insufficient privileges: "
"User does not have USE CATALOG on Catalog 'aarushi'. "
"SQLSTATE: 42501"
)
assert db_mod._is_usage_table_access_error(self._err(msg)) is False

def test_other_error_code_on_same_table_falls_through(self):
"""Different code on the right table must not trip the gate — the
helper requires INSUFFICIENT_PERMISSIONS specifically so we don't
mask e.g. missing-table failures with a permissions-shaped hint."""
msg = (
"[TABLE_OR_VIEW_NOT_FOUND] The table or view "
"`system`.`ai_gateway`.`usage` cannot be found. SQLSTATE: 42P01"
)
assert db_mod._is_usage_table_access_error(self._err(msg)) is False

@pytest.mark.parametrize(
"quoted",
[
"`system`.`ai_gateway`.`usage`",
"[system].[ai_gateway].[usage]",
],
)
def test_identifier_quoting_variants_all_match(self, quoted):
msg = (
f"[INSUFFICIENT_PERMISSIONS] User does not have SELECT on Table "
f"{quoted}. SQLSTATE: 42501"
)
assert db_mod._is_usage_table_access_error(self._err(msg)) is True


class TestRunUsageQuery:
"""Cover the two control-flow arms `_is_usage_table_access_error` gates:
friendly RuntimeError for matching errors, raw-text fallback for the rest.
`from exc` chaining is also pinned so `--debug` still surfaces the
underlying connector error."""

@staticmethod
def _patch_connect_to_raise(monkeypatch, exc):
import databricks.sql as sql_mod

def fake_connect(*args, **kwargs):
raise exc

monkeypatch.setattr(sql_mod, "connect", fake_connect)

def test_raises_actionable_message_for_table_access_error(self, monkeypatch):
from databricks.sql.exc import ServerOperationError

original = ServerOperationError(
"[INSUFFICIENT_PERMISSIONS] Insufficient privileges: "
"User does not have SELECT on Table 'system.ai_gateway.usage'. "
"SQLSTATE: 42501"
)
self._patch_connect_to_raise(monkeypatch, original)

with pytest.raises(RuntimeError, match="Ask your workspace admin") as exc_info:
db_mod.run_usage_query(WS, "/sql/1.0/warehouses/abc", "tok", "SELECT 1")
assert "system.ai_gateway.usage" in str(exc_info.value)
# The original ServerOperationError must survive on __cause__ so
# `--debug` / stack traces still show the underlying connector error.
assert exc_info.value.__cause__ is original

def test_falls_through_for_unrelated_permission_error(self, monkeypatch):
from databricks.sql.exc import ServerOperationError

original = ServerOperationError(
"[INSUFFICIENT_PERMISSIONS] Insufficient privileges: "
"User does not have USE CATALOG on Catalog 'aarushi'. SQLSTATE: 42501"
)
self._patch_connect_to_raise(monkeypatch, original)

with pytest.raises(RuntimeError, match="aarushi") as exc_info:
db_mod.run_usage_query(WS, "/sql/1.0/warehouses/abc", "tok", "SELECT 1")
assert "Ask your workspace admin" not in str(exc_info.value)
assert str(exc_info.value).startswith("Usage query failed:")
Loading