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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
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).

## [Unreleased]

### Added

- **§18 `Client.Close()`** — idempotent, satisfies `io.Closer`, clears the memo and closes
idle connections. Use-after-close returns a `*NetworkError` rather than silently
reconnecting. It does **not** log out and never reaches the network: the server-side session
outlives the `Client` value, and a `Close` that logged out would end every user's session on
each deploy.
- **§19 telemetry hooks** — `WithTelemetryHook`, the closed `TelemetryEvent` interface
(`RequestStartEvent`, `RequestEndEvent`, `RetryEvent`, `RefreshEvent`) and
`examples/telemetry-hook` with the OpenTelemetry mapping. A panicking hook is recovered, and
no event payload can carry a token. One request pair per *attempt*.
- **§17 decision memo — opt-in, off by default** — `WithDecisionMemoTTL`, clamped to
`MaxMemoTTL` (5 s), safe for concurrent use. Allows and denies memoized identically,
failures never memoized, cleared on any credential change.
**Reads-your-own-writes is not guaranteed.**
- `WithRetryDisabled` (§16.6). No option for the attempt cap, base or delay cap: §16.1 forbids
raising them.
- `NetworkError.RetryAfter`, parsed from the `Retry-After` header. Both RFC 7231 forms are
accepted — delta-seconds and HTTP-date, the latter being what CDNs and proxies commonly send
on 429/503. The parsed *duration* is stored, never the raw header text, so the D-04/CR-04
redaction invariant is untouched.

### Changed

- **§16: `retryReadOnly` replaced.** The old policy used a 100 ms base, `backoff *= 2` with
**no cap and no jitter**, and ignored `Retry-After`. It now follows the contract table: 3
attempts, 200 ms base, 5 s cap, full jitter over `[0, backoff]`, `Retry-After` as a floor.
Uncapped, the old wait was bounded by nothing but the attempt count; unjittered, every client
retried in lockstep — the thundering herd a backoff exists to prevent.
- The unexported `authzRetryMaxAttempts` constant is replaced by the exported `MaxAttempts`,
alongside `BaseDelay` and `MaxDelay`.
- Re-vendored `CONTRACT.md` at **1.8.2**. `openapi.json` unchanged — docs-only contract revs.

## [1.0.0-alpha24] - 2026-08-04

### Added
Expand Down
384 changes: 377 additions & 7 deletions CONTRACT.md

Large diffs are not rendered by default.

91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Official Go client SDK for [AXIAM](https://github.com/ilpanich/axiam) — Access

## Contract conformance

This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15 (including §6.1 mTLS).
This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19 (including §6.1 mTLS).

§12.7, §14 and §15 are named rather than folded into the range because they
landed after this SDK already claimed §1–§13: widening the range silently would
Expand Down Expand Up @@ -493,3 +493,92 @@ fetch it; pull-request events never trigger publish.
There is no registry upload step — for Go, the git tag *is* the release, and
`go get` resolves it through the module proxy. API docs appear automatically on
pkg.go.dev once the proxy has seen the tag.

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

### Retry policy (§16)

Read-only authorization checks — `CheckAccess`, `Can`, `CheckAccessAs`, `CheckAccessDecision`,
`BatchCheck` — 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 changed in D5.** The previous policy used a 100 ms base, `backoff *= 2` with **no cap
> and no jitter**, and ignored `Retry-After` entirely. Uncapped, the wait was bounded by
> nothing but the attempt count; unjittered, every client that saw the same outage retried at
> the same instant — the thundering herd the backoff is supposed to prevent.

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. A cancelled context
wins over a pending backoff.

```go
// Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
client, err := axiam.NewClient(baseURL, "acme", axiam.WithRetryDisabled())
```

There is deliberately no option 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()` releases the client's local resources and closes idle connections. It is
idempotent, satisfies `io.Closer`, and any call afterwards returns a `*NetworkError` naming the
cause rather than silently reconnecting.

**`Close` does not log out.** It never reaches the network. The server-side session
deliberately outlives the `Client` value — 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 module depending on any metrics library:

```go
client, err := axiam.NewClient(baseURL, "acme", axiam.WithTelemetryHook(
func(e axiam.TelemetryEvent) {
switch ev := e.(type) {
case axiam.RequestEndEvent:
histogram.Record(ctx, ev.Duration.Seconds(), /* labels */)
case axiam.RetryEvent:
counter.Add(ctx, 1, /* labels */)
}
},
))
```

- **A hook that panics cannot fail the operation that fired it** — and in Go an unrecovered
panic would take the process down, not just the request.
- **No event payload can carry a token.** `TelemetryEvent` is a closed interface (its marker
method is unexported) with fixed field sets — this surface exists to be shipped to a metrics
backend.
- **Path templates, not URLs**, so a metric label cannot become a cardinality bomb.

One `RequestStartEvent`/`RequestEndEvent` pair is emitted **per attempt**, so you can count
real wire calls. See [`examples/telemetry-hook`](examples/telemetry-hook) for the
OpenTelemetry mapping.

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

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

```go
client, err := axiam.NewClient(baseURL, "acme", axiam.WithDecisionMemoTTL(5*time.Second))
```

**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 `MaxMemoTTL` (5 s) 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`, `VerifyMfa`, `Refresh` and `Logout`, since entries are
keyed by subject rather than by session. It is safe for concurrent use.
81 changes: 37 additions & 44 deletions authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
)

const (
Expand Down Expand Up @@ -84,10 +83,6 @@ type batchCheckResponseWire struct {
Results []AccessResult `json:"results"`
}

// authzRetryMaxAttempts bounds CF-01's retry to read-only authz checks
// only (state-changing auth calls in login.go never retry).
const authzRetryMaxAttempts = 3

// CheckAccess performs POST /api/v1/authz/check (CONTRACT.md §1),
// evaluating a single authorization check for the given action/
// resourceID/scope. This is a read-only, idempotent operation eligible
Expand Down Expand Up @@ -159,8 +154,8 @@ func (c *Client) BatchCheck(ctx context.Context, reqs []AccessCheck) ([]AccessRe
body := batchCheckRequestBody{Checks: reqs}

var wire batchCheckResponseWire
err := c.retryReadOnly(ctx, func(ctx context.Context) error {
w, err := c.sendAuthzPost(ctx, batchCheckPath, body)
err := c.retryReadOnly(ctx, "BatchCheck", func(ctx context.Context, attempt int) error {
w, err := c.sendAuthzPost(ctx, batchCheckPath, body, "BatchCheck", attempt)
if err != nil {
return err
}
Expand All @@ -174,31 +169,50 @@ func (c *Client) BatchCheck(ctx context.Context, reqs []AccessCheck) ([]AccessRe
}

func (c *Client) checkAccessWithRetry(ctx context.Context, req AccessCheck) (AccessResult, error) {
if err := c.ensureOpen(); err != nil {
return AccessResult{}, err
}

// §17: consult the memo first. Disabled by default, in which case this is
// one map lookup that always misses.
key := memoKey(req)
if memoized, ok := c.memo.get(key); ok {
return memoized, nil
}

var result AccessResult
err := c.retryReadOnly(ctx, func(ctx context.Context) error {
resp, err := c.sendAuthzPostSingle(ctx, checkPath, req)
err := c.retryReadOnly(ctx, "CheckAccess", func(ctx context.Context, attempt int) error {
resp, err := c.sendAuthzPostSingle(ctx, checkPath, req, "CheckAccess", attempt)
if err != nil {
return err
}
result = resp
return nil
})
return result, err
if err != nil {
return AccessResult{}, err
}

// Only a decision the server actually returned is memoized: reaching here
// means success, so §17.1 rule 7's ban on caching a failure is structural
// rather than a check that could be forgotten.
c.memo.set(key, result)
return result, nil
}

// sendAuthzPostSingle POSTs body to path and decodes a single AccessResult.
func (c *Client) sendAuthzPostSingle(ctx context.Context, path string, body any) (AccessResult, error) {
func (c *Client) sendAuthzPostSingle(ctx context.Context, path string, body any, operation string, attempt int) (AccessResult, error) {
var result AccessResult
if err := c.sendAuthzPostInto(ctx, path, body, &result); err != nil {
if err := c.sendAuthzPostInto(ctx, path, body, &result, operation, attempt); err != nil {
return AccessResult{}, err
}
return result, nil
}

// sendAuthzPost POSTs body to path and decodes a batchCheckResponseWire.
func (c *Client) sendAuthzPost(ctx context.Context, path string, body any) (batchCheckResponseWire, error) {
func (c *Client) sendAuthzPost(ctx context.Context, path string, body any, operation string, attempt int) (batchCheckResponseWire, error) {
var wire batchCheckResponseWire
if err := c.sendAuthzPostInto(ctx, path, body, &wire); err != nil {
if err := c.sendAuthzPostInto(ctx, path, body, &wire, operation, attempt); err != nil {
return batchCheckResponseWire{}, err
}
return wire, nil
Expand All @@ -208,7 +222,7 @@ func (c *Client) sendAuthzPost(ctx context.Context, path string, body any) (batc
// endpoints: builds the request, decorates it (X-Tenant-ID + CSRF via
// doRequest), sends it, maps non-2xx per §2, and decodes the 2xx body into
// out.
func (c *Client) sendAuthzPostInto(ctx context.Context, path string, body any, out any) error {
func (c *Client) sendAuthzPostInto(ctx context.Context, path string, body any, out any, operation string, attempt int) error {
payload, err := json.Marshal(body)
if err != nil {
return &NetworkError{Message: fmt.Sprintf("failed to encode authz request: %v", err)}
Expand All @@ -219,47 +233,26 @@ func (c *Client) sendAuthzPostInto(ctx context.Context, path string, body any, o
return err
}

// §19: one pair per attempt, with the route constant rather than a
// substituted URL — a metric label carrying a UUID is a cardinality bomb.
sp := c.telemetry.startRequest(operation, http.MethodPost, path, attempt)

resp, err := c.doRequest(req)
if err != nil {
sp.end(0, OutcomeFailure)
return err
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
sp.end(resp.StatusCode, OutcomeFailure)
return mapErrorResponse(resp)
}

if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
sp.end(resp.StatusCode, OutcomeFailure)
return deserErr(err)
}
sp.end(resp.StatusCode, OutcomeSuccess)
return nil
}

// retryReadOnly runs op with CF-01's bounded exponential backoff, retrying
// ONLY on *NetworkError (transient/429/5xx) — AuthError/AuthzError are
// decisive, never retried. Read-only authz checks are the only operations
// in this SDK eligible for this treatment; Login/VerifyMfa/Refresh/Logout
// in login.go never retry.
func (c *Client) retryReadOnly(ctx context.Context, op func(ctx context.Context) error) error {
var lastErr error
backoff := 100 * time.Millisecond
for attempt := 1; attempt <= authzRetryMaxAttempts; attempt++ {
lastErr = op(ctx)
if lastErr == nil {
return nil
}
if _, retryable := lastErr.(*NetworkError); !retryable {
return lastErr
}
if attempt == authzRetryMaxAttempts {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
}
return lastErr
}
4 changes: 2 additions & 2 deletions authz_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ func TestRetryReadOnly_ExhaustsThenReturnsNetworkError(t *testing.T) {
if !isNetworkError(err, &netErr) {
t.Fatalf("expected *NetworkError after exhaustion, got %T: %v", err, err)
}
if got := atomic.LoadInt32(&attempts); got != authzRetryMaxAttempts {
t.Fatalf("expected %d attempts, got %d", authzRetryMaxAttempts, got)
if got := atomic.LoadInt32(&attempts); got != MaxAttempts {
t.Fatalf("expected %d attempts, got %d", MaxAttempts, got)
}
}

Expand Down
Loading