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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ plus bug fixes and the first real HTTP-layer test coverage.
`retry_backoff`.
- New exceptions: `ArcNotSupportedError` (501) and `ArcLicenseError` (403 for a
missing enterprise-license feature; subclass of `ArcAuthenticationError`).
- `ArcPermissionError` (403: the token lacks the permission the endpoint
requires), `ArcScopedAccessError` (403 from Arc's per-database check: the
token may read, but not the database asked for) and
`ArcPermissionDataUnavailableError` (403 because Arc could not load the
token's grants and failed closed — a server-side fault, not a statement
about the token). `ArcScopedAccessError` carries the refused database on
`.database`, empty or `"*"` when the call asked for every database. The
latter two subclass `ArcPermissionError`, which subclasses
`ArcAuthenticationError`, so existing `except` blocks keep working.
- Real HTTP round-trip test coverage (via `pytest-httpx`) for headers, error
mapping, compression on the wire, retries, msgpack decode, imports, databases,
and enterprise wrappers — sync and async. Opt-in live integration tests under
Expand All @@ -46,6 +55,13 @@ plus bug fixes and the first real HTTP-layer test coverage.
from the payload's magic bytes.
- `User-Agent` is now derived from the package version instead of a hardcoded
string, and the package version is a single source of truth read by the build.
- HTTP 403 no longer collapses onto `ArcAuthenticationError`, which now means
only 401. The database listing endpoints need read permission and, where the
server restricts reads per database, a grant for the database — and Arc
refuses `databases.list()` for a tenant-scoped token rather than returning a
filtered list, matching `SHOW DATABASES`. That refusal is an expected state,
not a fault to retry: it raises `ArcScopedAccessError` naming the database,
while a token with no read permission raises `ArcPermissionError`.

### Fixed

Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,30 @@ measurements = client.databases.list_measurements("metrics")
client.databases.delete("metrics", confirm=True)
```

The three read methods need a token with read permission. On a server that
restricts reads per database they also need a grant for the database in
question, and `list()` needs one covering every database — Arc refuses rather
than returning a filtered list, the same way `SHOW DATABASES` does:

```python
from arc_client.exceptions import ArcScopedAccessError

try:
databases = client.databases.list().databases
except ArcScopedAccessError:
# Expected for a tenant-scoped token: it cannot enumerate every database,
# so ask for the one it is granted instead of retrying.
measurements = client.databases.list_measurements("metrics")
```

`ArcScopedAccessError.database` carries the database the call was refused for
(empty or `"*"` when the call asked for every database). A token with no read
permission at all raises `ArcPermissionError` instead; Arc failing to load the
token's grants raises `ArcPermissionDataUnavailableError`, a server-side fault
rather than a statement about the token; and a database that does not exist
still raises `ArcNotFoundError` — "you may not read it" and "it is not there"
stay apart.

## Querying Data

### JSON Response
Expand Down Expand Up @@ -445,7 +469,10 @@ the codec from the payload's magic bytes, so no `Content-Encoding` header is sen
from arc_client.exceptions import (
ArcError, # Base exception
ArcConnectionError, # Connection failures
ArcAuthenticationError,# Auth failures (401/403)
ArcAuthenticationError,# Token missing, invalid or expired (401)
ArcPermissionError, # Token lacks the required permission (403)
ArcScopedAccessError, # Token may read, but not this database (403)
ArcPermissionDataUnavailableError, # Arc could not load the grants (403)
ArcLicenseError, # Enterprise feature not licensed (403)
ArcQueryError, # Query execution errors
ArcIngestionError, # Write failures
Expand Down
6 changes: 6 additions & 0 deletions src/arc_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
ArcLicenseError,
ArcNotFoundError,
ArcNotSupportedError,
ArcPermissionDataUnavailableError,
ArcPermissionError,
ArcQueryError,
ArcRateLimitError,
ArcScopedAccessError,
ArcServerError,
ArcValidationError,
)
Expand All @@ -42,6 +45,9 @@
"ArcServerError",
"ArcNotSupportedError",
"ArcLicenseError",
"ArcPermissionError",
"ArcPermissionDataUnavailableError",
"ArcScopedAccessError",
# Version
"__version__",
]
60 changes: 60 additions & 0 deletions src/arc_client/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,63 @@ class ArcLicenseError(ArcAuthenticationError):
"""

pass


class ArcPermissionError(ArcAuthenticationError):
"""HTTP 403: the token does not carry the permission the endpoint needs.

Distinct from :class:`ArcAuthenticationError`, which now means only HTTP
401 — the token is missing, invalid or expired. A 401 is fixed by
re-authenticating; a 403 is not, and needs a token with different
permissions.

Subclasses :class:`ArcAuthenticationError` so existing ``except`` blocks
that caught 403 as an authentication failure keep working.
"""

pass


class ArcPermissionDataUnavailableError(ArcPermissionError):
"""HTTP 403 because Arc could not load the token's grants.

Arc fails closed when its permission data cannot be read, which arrives
as a 403 like a real refusal. Nothing about the token or the database
caused it, so it is raised separately: retrying with a different database
or a different token will not help, and the Arc server log is where the
cause is.
"""

pass


class ArcScopedAccessError(ArcPermissionError):
"""HTTP 403 from Arc's per-database permission check.

The token may read, but not the database the call asked about. Raised by
the database listing endpoints:

* :meth:`arc_client.management.databases.DatabaseClient.list` — Arc
refuses to list every database for a token restricted to particular
ones rather than returning a filtered list, matching ``SHOW
DATABASES``. ``database`` is then ``"*"``.
* :meth:`~arc_client.management.databases.DatabaseClient.get` and
:meth:`~arc_client.management.databases.DatabaseClient.list_measurements`
— the token holds no grant for the database named, which ``database``
carries.

This is a normal state for a tenant-scoped token, not a transient
failure: an identical retry is refused identically, and the way forward
is to name a database the token is granted. It is deliberately separate
from :class:`ArcPermissionError` so "you are scoped, name your database"
and "this token cannot read at all" can be handled differently.

Attributes:
database: The database the call was refused for, or ``"*"`` when it
asked for every database. ``None`` if Arc's message did not name
one.
"""

def __init__(self, message: str, database: Optional[str] = None) -> None:
super().__init__(message)
self.database = database
113 changes: 111 additions & 2 deletions src/arc_client/http/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
from typing import Any, Optional

import httpx
Expand All @@ -15,7 +16,10 @@
ArcLicenseError,
ArcNotFoundError,
ArcNotSupportedError,
ArcPermissionDataUnavailableError,
ArcPermissionError,
ArcRateLimitError,
ArcScopedAccessError,
ArcServerError,
)

Expand All @@ -38,8 +42,83 @@ def build_headers(
return headers


# Arc sends HTTP 403 for three different things and distinguishes them only
# by message, so the message is what these read. The shapes its read
# middleware and handler gates produce:
#
# no permission for read on database 'x' -> scoped
# access denied: no read permission for database 'x' -> scoped
# permission data unavailable -> grants unreadable
# token does not have 'read' permission -> no read permission
# Permission denied: read required -> no read permission
#
# Only the first two are resolved by naming a different database, so they
# must not collapse onto one exception. The patterns match the stable parts
# of each message rather than the whole string, so a change to the permission
# word or the surrounding punctuation cannot silently reclassify one as
# another, and the database name is captured where Arc sends one.
_SCOPED_DENIAL_RES = (
# The read middleware on the listing routes. The name is empty on the
# list-everything route, which sends no database.
re.compile(
r"^no permission for \w+ on database '(?P<database>[^']*)'",
re.IGNORECASE,
),
# The handler gates' own wording.
re.compile(
r"^access denied: no \w+ permission for database '(?P<database>[^']*)'",
re.IGNORECASE,
),
)

# Arc could not load the token's grants and refused rather than guess. A 403
# like the others, but it says nothing about the token or the database, so it
# must not be reported as a permission problem. Checked first.
_PERMISSION_DATA_UNAVAILABLE = "permission data unavailable"


def _scoped_denial_database(message: object) -> Optional[str]:
"""Return the database named by a per-database denial, else None.

An empty capture is returned as ``""`` — Arc sends an empty name on the
list-everything route — so callers must test against ``None``, not
truthiness, to tell "not a scoped denial" from "scoped, no name".
"""
if not isinstance(message, str):
return None
text = message.strip()
for pattern in _SCOPED_DENIAL_RES:
match = pattern.match(text)
if match is not None:
return match.group("database")
return None


def _is_permission_data_unavailable(message: object) -> bool:
"""True if Arc failed closed because it could not load the grants."""
return isinstance(message, str) and _PERMISSION_DATA_UNAVAILABLE in message.lower()


def handle_response_error(response: httpx.Response) -> None:
"""Handle HTTP error responses and raise appropriate exceptions."""
"""Handle HTTP error responses and raise appropriate exceptions.

Raises:
ArcAuthenticationError: HTTP 401 — the token is missing, invalid or
expired. Re-authenticate.
ArcLicenseError: HTTP 403 naming an enterprise license.
ArcScopedAccessError: HTTP 403 from Arc's per-database permission
check — the token may read, but not the database asked for. A
subclass of ``ArcPermissionError``; carries ``database``.
ArcPermissionDataUnavailableError: HTTP 403 because Arc could not
load the token's grants and failed closed. A server-side fault,
not a statement about the token.
ArcPermissionError: any other HTTP 403 — the token does not carry the
permission the endpoint requires.
ArcNotFoundError: HTTP 404.
ArcRateLimitError: HTTP 429.
ArcNotSupportedError: HTTP 501.
ArcServerError: HTTP 5xx and any other non-2xx status.
"""
if response.is_success:
return

Expand All @@ -58,7 +137,37 @@ def handle_response_error(response: httpx.Response) -> None:
# ArcAuthenticationError, so plain permission-denied handling still works).
if isinstance(message, str) and "enterprise license" in message.lower():
raise ArcLicenseError(f"Enterprise license required: {message}")
raise ArcAuthenticationError(f"Permission denied: {message}")
# Checked before the scoped shape: Arc failing to read its own
# permission data is a server fault, and reporting it as "your token
# is scoped" would send the caller after a database that was never
# the problem.
if _is_permission_data_unavailable(message):
raise ArcPermissionDataUnavailableError(
f"Arc could not read its own permission data and refused the request "
f"rather than guessing. This is a server-side fault, not a problem "
f"with this token — check the Arc server log. ({message})"
)
# Arc's per-database check refuses a token that may read but holds no
# grant for the database asked for. Naming a database it is granted is
# the only thing that resolves it, so it gets its own exception rather
# than being reported as "this token cannot read".
database = _scoped_denial_database(message)
if database is not None:
if database in ("", "*"):
detail = (
"this token is scoped to specific databases, so Arc will not list "
"them all. Name one with get() or list_measurements()."
)
else:
detail = (
f"this token is scoped to specific databases and holds no read "
f"grant for {database!r}. Name a database it is granted."
)
raise ArcScopedAccessError(
f"Access denied: {detail} ({message})",
database=database,
)
raise ArcPermissionError(f"Permission denied: {message}")
elif status_code == 404:
raise ArcNotFoundError(f"Resource not found: {message}")
elif status_code == 429:
Expand Down
40 changes: 37 additions & 3 deletions src/arc_client/management/async_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@ def __init__(self, http: AsyncHTTPClient, config: ClientConfig) -> None:
self._config = config

async def list(self) -> DatabaseListResponse:
"""List all databases."""
"""List all databases.

Needs a token with read permission. Where the server restricts reads
per database, it also needs a grant covering every database: Arc
refuses this call for a token scoped to particular databases rather
than returning a filtered list, the same way ``SHOW DATABASES`` does.

Raises:
ArcScopedAccessError: The token is scoped to specific databases
(``database`` is ``"*"``). Expected for a tenant-scoped
token — name a database with :meth:`get` or
:meth:`list_measurements` instead of retrying.
ArcPermissionError: The token carries no read permission.
ArcAuthenticationError: The token is missing, invalid or expired.
"""
try:
response = await self._http.get("/api/v1/databases")
return DatabaseListResponse.model_validate(response.json())
Expand All @@ -33,7 +47,18 @@ async def list(self) -> DatabaseListResponse:
raise ArcError(f"Failed to list databases: {e}") from e

async def get(self, name: str) -> DatabaseInfo:
"""Get a single database by name."""
"""Get a single database by name.

Raises:
ArcScopedAccessError: The token holds no read grant for ``name``
(carried on ``database``). It may be able to read others.
ArcPermissionError: The token carries no read permission.
ArcAuthenticationError: The token is missing, invalid or expired.
ArcNotFoundError: No such database. Kept separate from the
permission errors: "you may not read it" and "it is not
there" are different answers.
ArcValidationError: The name is not a valid database name.
"""
validate_name(name, "database")
try:
response = await self._http.get(f"/api/v1/databases/{name}")
Expand All @@ -44,7 +69,16 @@ async def get(self, name: str) -> DatabaseInfo:
raise ArcError(f"Failed to get database {name!r}: {e}") from e

async def list_measurements(self, name: str) -> MeasurementListResponse:
"""List measurements within a database."""
"""List measurements within a database.

Raises:
ArcScopedAccessError: The token holds no read grant for ``name``
(carried on ``database``). Ask for a database it is granted.
ArcPermissionError: The token carries no read permission.
ArcAuthenticationError: The token is missing, invalid or expired.
ArcNotFoundError: No such database.
ArcValidationError: The name is not a valid database name.
"""
validate_name(name, "database")
try:
response = await self._http.get(f"/api/v1/databases/{name}/measurements")
Expand Down
Loading