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
24 changes: 16 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call: # Allow release.yml to call this workflow

env:
UV_PYTHON: "3.11"

jobs:
lint:
name: Lint
Expand All @@ -23,7 +25,7 @@ jobs:
run: uv python install 3.11

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra all

- name: Run ruff check
run: uv run ruff check .
Expand All @@ -46,13 +48,15 @@ jobs:
run: uv python install 3.11

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra all

- name: Run mypy
run: uv run mypy src/arc_client --ignore-missing-imports

test:
name: Test (Python ${{ matrix.python-version }})
env:
UV_PYTHON: ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -71,10 +75,12 @@ jobs:
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra all

- name: Run tests
run: uv run pytest tests/unit -v --tb=short
run: |
uv run python --version
uv run pytest tests/unit -v --tb=short

test-platforms:
name: Test (${{ matrix.os }})
Expand All @@ -96,10 +102,12 @@ jobs:
run: uv python install 3.11

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra all

- name: Run tests
run: uv run pytest tests/unit -v --tb=short
run: |
uv run python --version
uv run pytest tests/unit -v --tb=short

coverage:
name: Coverage
Expand All @@ -116,7 +124,7 @@ jobs:
run: uv python install 3.11

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra all

- name: Run tests with coverage
run: uv run pytest tests/unit --cov=src/arc_client --cov-report=xml
Expand Down
31 changes: 27 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to `arc-tsdb-client` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-07-03
## [0.2.0] - Unreleased

A large feature-parity release bringing the SDK up to date with the Arc server,
plus bug fixes and the first real HTTP-layer test coverage.
Expand All @@ -28,11 +28,21 @@ plus bug fixes and the first real HTTP-layer test coverage.
`mqtt`.
- **zstd write compression** (now the default; gzip and none still supported).
Compression is configurable via a `Compression` enum, a string, or a bool.
- **Automatic retries** with exponential backoff + jitter for connection errors,
429 (honoring `Retry-After`), and 503. Configurable via `max_retries` /
`retry_backoff`.
- **Automatic retries** with exponential backoff + jitter for read-only
connection/transport failures, 429 (honoring `Retry-After`) and 503. Mutations
retry only connection establishment; data DELETE never retries. Configure
via `max_retries` / `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,9 +56,22 @@ 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

- Retention and continuous-query clients accept Arc's list/null/flat response
shapes. Sparse updates remain sparse; explicit full replacements support
released servers, and CLEAR requests explicit null values on newer servers.
- Buffered close waits for active writes, retains unacknowledged batches and
surfaces failures without replay. Typed errors retain structured diagnostics.

- Polars datetime → columnar conversion used a fragile private attribute that
raised at runtime; it now casts via the public `polars` API and normalizes the
series' `time_unit` to microseconds.
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
8 changes: 8 additions & 0 deletions src/arc_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# failure during package initialization.
__version__ = "0.2.0"

from arc_client._sentinels import CLEAR
from arc_client.async_client import AsyncArcClient
from arc_client.client import ArcClient
from arc_client.compression import Compression
Expand All @@ -17,13 +18,17 @@
ArcLicenseError,
ArcNotFoundError,
ArcNotSupportedError,
ArcPermissionDataUnavailableError,
ArcPermissionError,
ArcQueryError,
ArcRateLimitError,
ArcScopedAccessError,
ArcServerError,
ArcValidationError,
)

__all__ = [
"CLEAR",
# Clients
"ArcClient",
"AsyncArcClient",
Expand All @@ -42,6 +47,9 @@
"ArcServerError",
"ArcNotSupportedError",
"ArcLicenseError",
"ArcPermissionError",
"ArcPermissionDataUnavailableError",
"ArcScopedAccessError",
# Version
"__version__",
]
12 changes: 12 additions & 0 deletions src/arc_client/_sentinels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Explicit clearing for nullable fields in partial updates."""

from enum import Enum


class ClearType(Enum):
"""Marker for an explicit JSON null, distinct from an omitted argument."""

CLEAR = "CLEAR"


CLEAR = ClearType.CLEAR
4 changes: 2 additions & 2 deletions src/arc_client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class ClientConfig(BaseModel):
max_retries: int = Field(
default=3,
ge=0,
description="Max automatic retries for transient failures "
"(connection errors, 429, 503). 0 disables retries.",
description="Max retries for read-only transient failures or mutation connection "
"establishment failures. Data DELETE never retries. 0 disables retries.",
)
retry_backoff: float = Field(
default=0.5,
Expand Down
82 changes: 79 additions & 3 deletions src/arc_client/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,29 @@

from __future__ import annotations

from typing import Optional
from typing import Any, Optional


class ArcError(Exception):
"""Base exception for arc-client."""
"""Base exception; HTTP failures retain structured diagnostics when available."""

pass
_response_body: Optional[dict[str, Any]] = None

@property
def response_body(self) -> Optional[dict[str, Any]]:
"""Retain diagnostics through existing higher-level exception wrappers."""
error: Optional[BaseException] = self
seen: set[int] = set()
while isinstance(error, ArcError) and id(error) not in seen:
seen.add(id(error))
if error._response_body is not None:
return error._response_body
error = error.__cause__
return None

@response_body.setter
def response_body(self, value: Optional[dict[str, Any]]) -> None:
self._response_body = value


class ArcConnectionError(ArcError):
Expand Down Expand Up @@ -79,3 +95,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
21 changes: 17 additions & 4 deletions src/arc_client/http/async_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,24 +52,37 @@ async def _request(
) -> httpx.Response:
"""Send a request with retry/backoff for transient failures.

Retries connection/timeout errors and 429/503 responses up to
``config.max_retries`` times, honoring ``Retry-After`` when present.
Reads retry transient failures up to ``config.max_retries`` times.
Mutations retry only connection establishment failures; data DELETE
never retries. Unknown write outcomes are returned to the caller.
"""
request_kwargs = self._prepare_request_kwargs(headers=headers, **kwargs)
client = self._get_client()
last_exc: Optional[Exception] = None
read_only = method.upper() in {"GET", "HEAD", "OPTIONS"}
# DELETE may partially commit. Other mutations may be retried only
# when no application request could have reached the server.
data_delete = method.upper() == "POST" and path.rstrip("/") == "/api/v1/delete"

for attempt in range(self.config.max_retries + 1):
try:
response = await client.request(method, path, **request_kwargs)
except RETRYABLE_TRANSPORT_ERRORS as e:
last_exc = e
if attempt >= self.config.max_retries:
if (
data_delete
or not (read_only or isinstance(e, (httpx.ConnectError, httpx.ConnectTimeout)))
or attempt >= self.config.max_retries
):
break
await asyncio.sleep(compute_backoff(attempt, self.config.retry_backoff))
continue

if is_retryable_status(response.status_code) and attempt < self.config.max_retries:
if (
read_only
and is_retryable_status(response.status_code)
and attempt < self.config.max_retries
):
retry_after = parse_retry_after(response)
await asyncio.sleep(
compute_backoff(attempt, self.config.retry_backoff, retry_after)
Expand Down
Loading
Loading