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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **§16 bounded read-only retry policy** (`_retry.py`), wired into `check_access`/`can`/
`batch_check` on **both** the sync and async clients: 3 attempts, 200 ms base, 5 s cap,
**full jitter** over `[0, backoff]`, `Retry-After` honored as a floor. This SDK had no §16
policy before — only §9.3's refresh-then-retry-once, which is a different mechanism — so
§11.2 rule 5's requirement had gone unmet since it was written. Sync and async share the
backoff arithmetic so the two cannot drift.
- **§18 shutdown semantics** on `close()`/`aclose()`: idempotent, memo cleared, and
use-after-close raises `NetworkError` rather than silently reconnecting. Neither logs out
nor reaches the network — the server-side session outlives the client object, and a
`close()` that logged out would end every user's session on each deploy.
- **§19 telemetry hooks** (`_telemetry.py`) — `telemetry_hook=`, plus the frozen
`RequestStart`/`RequestEnd`/`Retry`/`Refresh` events and `examples/telemetry_hook.py` with
the OpenTelemetry mapping. A hook that raises cannot fail the operation that fired it, and
no event payload can carry a token. One request pair per *attempt*, not per logical call,
so callers can count real wire calls.
- **§17 decision memo — opt-in, off by default** (`_decision_memo.py`):
`decision_memo_ttl_ms=`, clamped to 5000 ms, thread-safe. Allows and denies memoized
identically, failures never memoized, cleared on any credential change.
**Reads-your-own-writes is not guaranteed.**
- `retry_enabled=` (§16.6), default on. No knob for the attempt cap, base or delay cap:
§16.1 forbids raising them.
- Public exports: `DecisionMemo`, `TelemetryEvent`, `TelemetryHook`, `RequestStart`,
`RequestEnd`, `Retry`, `Refresh`.

### Changed

- Re-vendored `CONTRACT.md` at **1.8.1**. `openapi.json` unchanged — docs-only contract revs.
- `login`, `verify_mfa`, `refresh` and `logout` now clear the decision memo (§17.1 rule 9)
and reject after close (§18.1 rule 4), on both clients.

- **`[speed]` extra (uvloop) and `PERFORMANCE.md` (D1/J5).** Benchmark run 5
put this SDK's `check_access` at p50 40.2 ms / 311 rps against Go, Java and
Rust's ~10 ms / ~850 rps, and the open question was what in `axiam_sdk` was
Expand Down
371 changes: 364 additions & 7 deletions CONTRACT.md

Large diffs are not rendered by default.

88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Official Python client SDK for [AXIAM](https://github.com/ilpanich/axiam) — Ac

## Contract conformance

This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15 (including §6.1
This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19 (including §6.1
mTLS and the §10.1 minimum local-verification set).

§12.7, §14 and §15 are named rather than folded into the range because they
Expand Down Expand Up @@ -659,3 +659,89 @@ Coverage (as CI runs it, reported to Coveralls):
```bash
pytest --cov=axiam_sdk --cov-report=lcov
```

## Client quality-of-life (CONTRACT.md §16–§19)

### Retry policy (§16)

Read-only authorization checks — `check_access`, `can`, `batch_check`, on both the sync and
async clients — retry transient failures under the contract's normative table: **3 attempts**
(1 initial + 2 retries), 200 ms base, 5 s cap, **full jitter** (uniform over `[0, backoff]`),
and `Retry-After` honored as a **floor**.

This SDK had no §16 policy before — only §9.3's refresh-then-retry-once, which is a different
mechanism. §11.2 rule 5 had been requiring one since it was written.

Only failures that could plausibly succeed on a second attempt are retried: transport errors,
`408`, `429`, `5xx`. A `401` or `403` is an answer, not a transport failure, and surfaces after
exactly one attempt. Nothing that changes server state is ever retried.

```python
# Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
client = AxiamClient(base_url=..., tenant_slug="acme", retry_enabled=False)
```

There is deliberately no knob for the attempt cap, base delay or delay cap: §16.1 forbids
raising them, and eleven SDKs agreeing on one table is the point.

### Deterministic shutdown (§18)

`client.close()` (sync) and `await client.aclose()` (async) release local resources. Both are
idempotent, and any call afterwards raises `NetworkError` naming the cause rather than silently
reconnecting.

**Neither logs out.** They never reach the network. The server-side session deliberately
outlives the client object — that is what lets a process restart and resume — so a `close()`
that logged out would silently end every user's session on each deploy. Call `logout()` first
if ending the session is what you want.

### Telemetry hooks (§19)

Wire metrics without this package depending on any metrics library:

```python
from axiam_sdk import AxiamClient, RequestEnd, Retry, TelemetryEvent


def sink(event: TelemetryEvent) -> None:
if isinstance(event, RequestEnd):
histogram.record(event.duration_ms, {"op": event.operation, "outcome": event.outcome})
elif isinstance(event, Retry):
counter.add(1, {"op": event.operation, "attempt": event.attempt})


client = AxiamClient(base_url=..., tenant_slug="acme", telemetry_hook=sink)
```

- **A hook that raises cannot fail the operation that fired it.** Telemetry is not permitted to
fail an authorization check.
- **No event payload can carry a token.** The event dataclasses are frozen with a fixed field
set — this surface exists to be shipped to a metrics backend.
- **Path templates, not URLs**, so a metric label cannot become a cardinality bomb.

One `RequestStart`/`RequestEnd` pair is emitted **per attempt**, so you can count real wire
calls. See [`examples/telemetry_hook.py`](examples/telemetry_hook.py) for the OpenTelemetry
mapping.

### Decision memo (§17) — opt-in, off by default

An optional TTL-bounded cache for `check_access` results. **Disabled by default**, because
§11.2 rule 6's ban on caching authorization decisions is still the default behaviour.

```python
client = AxiamClient(base_url=..., tenant_slug="acme", decision_memo_ttl_ms=5000) # 0 = off
```

**What you are accepting.** The staleness bound is the TTL, in *both* directions: a grant
revoked on the server can still read as allowed for up to the TTL, and a grant just added can
still read as denied for up to the TTL.

> **Reads-your-own-writes is not guaranteed.** An admin UI that grants a role and immediately
> re-checks is the case that breaks, and it breaks silently. If that is your workload, leave
> this off.

The TTL is clamped to 5000 ms rather than rejected. Allows and denies are memoized identically
— asymmetric caching would leak which outcome occurred through latency. Failures are never
memoized: caching a transport error as a deny would turn a blip into a TTL-long outage. The
memo is cleared on `login`, `verify_mfa`, `refresh` and `logout`, since entries are keyed by
subject rather than by session. It is thread-safe.
127 changes: 127 additions & 0 deletions examples/telemetry_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Telemetry hooks — CONTRACT.md §19.

Wiring metrics to an AXIAM client **without this package depending on any
metrics library**. The sink below aggregates in-process so the example runs with
no extra dependencies; the block at the bottom shows the exact mapping onto
OpenTelemetry, which is a drop-in replacement for the body.

Run::

python examples/telemetry_hook.py
"""

from __future__ import annotations

from collections import defaultdict

from axiam_sdk import AxiamClient, RequestEnd, Retry, TelemetryEvent

#: (operation, outcome) -> [count, total_ms]
_requests: dict[tuple[str, str], list[float]] = defaultdict(lambda: [0.0, 0.0])
#: operation -> retry count
_retries: dict[str, int] = defaultdict(int)


def record(event: TelemetryEvent) -> None:
"""A §19 sink. Aggregates in memory; see the OTel mapping below."""
if isinstance(event, RequestEnd):
# One pair per ATTEMPT, not per logical call (§19.2 rule 5), so counting
# these gives the real number of wire calls — including the ones a retry
# made on your behalf.
stat = _requests[(event.operation, event.outcome)]
stat[0] += 1
stat[1] += event.duration_ms
elif isinstance(event, Retry):
# §16.5 — the reason this event exists. A retried-then-succeeded
# operation is otherwise invisible: the caller sees a slow success and
# no signal that the server is failing. Alert on this rate, not on the
# error rate, or a degrading server looks healthy right up until the
# retries stop being enough.
_retries[event.operation] += 1


def report() -> None:
print("--- requests (per attempt) ---")
for (operation, outcome), (count, total_ms) in _requests.items():
print(f" {operation:<20} {outcome:<8} count={int(count)} mean={total_ms / count:.0f}ms")
print("--- retries ---")
if not _retries:
print(" (none)")
for operation, count in _retries.items():
print(f" {operation:<20} {count}")


def main() -> None:
client = AxiamClient(
base_url="https://axiam.example.com",
tenant_slug="acme",
org_slug="acme",
telemetry_hook=record,
)

# This will fail — the host does not resolve — which is the point: a failing
# call still emits a RequestEnd carrying the failure, and the §16 retries
# are visible as Retry events. Against a real server the same sink reports
# the success path.
try:
decision = client.check_access("read", "00000000-0000-0000-0000-000000000000")
print(f"allowed={decision.allowed} ({decision.reason_code or 'no reason code'})")
except Exception as err: # noqa: BLE001 — the example is about the telemetry.
print(f"check failed as expected in this example: {type(err).__name__}")

report()

# §18: release the client's local resources. Does not log out.
client.close()


if __name__ == "__main__":
main()


# ---------------------------------------------------------------------------
# The same sink, against OpenTelemetry
# ---------------------------------------------------------------------------
#
# This package deliberately ships no ``opentelemetry-*`` dependency — §19's
# whole point is that you choose your metrics stack. With the OTel API in YOUR
# requirements, ``record`` becomes::
#
# from opentelemetry import metrics
#
# meter = metrics.get_meter("axiam-sdk")
# duration = meter.create_histogram("axiam.client.request.duration")
# retry_counter = meter.create_counter("axiam.client.retries")
#
# def record(event: TelemetryEvent) -> None:
# if isinstance(event, RequestEnd):
# duration.record(
# event.duration_ms / 1000.0,
# {
# "axiam.operation": event.operation,
# # The path TEMPLATE, never a substituted URL: a metric
# # label carrying a UUID is a cardinality bomb.
# "http.route": event.path_template,
# "http.response.status_code": event.status or 0,
# "axiam.outcome": event.outcome,
# },
# )
# elif isinstance(event, Retry):
# retry_counter.add(
# 1,
# {"axiam.operation": event.operation, "axiam.attempt": event.attempt},
# )
#
# Two rules to keep in mind when writing any adapter:
#
# * **Do not block.** Hooks run on the calling path (§19.2 rule 4). Every
# mature metrics library already buffers; if yours does not, buffer on your
# side rather than doing I/O here.
# * **Do not enrich events from elsewhere.** The event dataclasses are frozen
# with a fixed field set precisely so this surface cannot leak a token into
# a metrics backend (§19.2 rule 3). Adding, say, the current
# ``Authorization`` header would defeat that on your side of the boundary.
#
# A hook that raises is caught and swallowed by the SDK (§19.2 rule 2) — an
# authorization check is never failed by telemetry — but that is a backstop,
# not a licence to let a sink raise.
18 changes: 18 additions & 0 deletions src/axiam_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,25 @@

__version__ = "1.0.0a24"

from axiam_sdk._decision_memo import DecisionMemo
from axiam_sdk._telemetry import (
Refresh,
RequestEnd,
RequestStart,
Retry,
TelemetryEvent,
TelemetryHook,
)

__all__ = [
# §17 decision memo, §19 telemetry hooks (D5).
"DecisionMemo",
"TelemetryEvent",
"TelemetryHook",
"RequestStart",
"RequestEnd",
"Retry",
"Refresh",
"__version__",
"AxiamClient",
"AsyncAxiamClient",
Expand Down
Loading