Skip to content

feat: §16 retry, §17 memo, §18 Close(), §19 telemetry (D5) - #31

Merged
ilpanich merged 2 commits into
mainfrom
claude/improvements-run5-benchmark-def-bazzei
Aug 9, 2026
Merged

feat: §16 retry, §17 memo, §18 Close(), §19 telemetry (D5)#31
ilpanich merged 2 commits into
mainfrom
claude/improvements-run5-benchmark-def-bazzei

Conversation

@ilpanich

@ilpanich ilpanich commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Implements the four contract-1.8 quality-of-life sections. Unlike the SDKs so far, §16 here replaces an existing policy rather than adding one. Re-vendors CONTRACT.md at 1.8.2.

Fourth of eleven, after rust#45, typescript#47 and python#34.

§16 — what retryReadOnly was doing

backoff := 100 * time.Millisecond
...
case <-time.After(backoff):
}
backoff *= 2

No cap. No jitter. No Retry-After. Both omissions matter, and neither is theoretical:

  • Uncapped, the wait is bounded by nothing but the attempt count. At three attempts that is survivable; the constant was one edit away from it not being.
  • Unjittered, every client that saw the same outage retries at the same instant. That is the thundering herd a backoff exists to prevent — an unjittered backoff schedules it rather than avoiding it.

Go was one of five SDKs that had each invented a policy, and all five disagreed. That divergence is what contract 1.8.2 documents and §16 exists to end.

Before After (§16 table)
Base 100 ms 200 ms
Cap none 5 s
Jitter none full — uniform over [0, backoff]
Retry-After ignored floor

A cancelled context still wins over a pending backoff — and returns ctx.Err() rather than the transport error, because a cancelled context is the caller's decision, not the server's failure.

authzRetryMaxAttempts becomes the exported MaxAttempts, alongside BaseDelay and MaxDelay.

NetworkError.RetryAfter, without breaking redaction

The policy needs the header, and this SDK has a hard invariant (D-04/CR-04) that NetworkError never carries unredacted response data.

The field stores the parsed time.Duration, never the raw header text — a duration cannot carry a token, a URL, or anything else a header might. Both RFC 7231 forms parse: delta-seconds and HTTP-date. The date form is not hypothetical; CDNs and proxies commonly send it on 429/503, and treating it as unparseable would silently discard the server's own statement about when it will be ready. A negative value collapses to zero rather than becoming a floor.

§17 — opt-in, concurrent-safe

Off by default, clamped to MaxMemoTTL (5 s), mutex-guarded — a Go client is routinely shared across goroutines, and a cache that corrupted under concurrency would be a worse bug than the one it is optimising away.

Allows and denies memoized identically (asymmetric caching leaks the outcome through latency). Failures never memoized — structurally, since set is only reachable after a successful decode. Cleared on any credential change, since entries are keyed by subject rather than session.

Reads-your-own-writes is not guaranteed.

§18 — Close() does not log out

Idempotent, satisfies io.Closer, clears the memo, and calls CloseIdleConnectionsnot anything more forceful. An in-flight request on another goroutine is the caller's to finish; tearing its connection out would turn a lifecycle bug into a truncated response.

It never reaches the network. The server-side session deliberately outlives the Client value, so a Close that logged out would silently end every user's session on each deploy. Asserted against the wire, because a Logout wired into Close succeeds silently.

§19 — a closed interface, and panic recovery that matters more here

TelemetryEvent's marker method is unexported, so no package outside this one can add a variant carrying a secret. That makes "no field can hold a token" checkable rather than aspirational.

emit recovers from a panicking hook. §19.2 rule 2 says telemetry may not fail an authorization check — and in Go the stakes are higher than in the other SDKs: an unrecovered panic in a hook would take the process down, not just the request.

One request pair per attempt, with the attempt threaded through the retry helper.

Verification

Gate Result
go build ./... clean
go vet ./... clean
go test ./... all packages pass, 31 new conformance cases
go build ./examples/... clean
gofmt -l . clean

d5_conformance_test.go asserts through the public CheckAccess surface and counts requests reaching an httptest server, as contract 1.8.1 requires. Jitter is pinned to 0 there so the tests do not really sleep — "a test that really waits 200 ms is a test nobody runs" — and the delay arithmetic is asserted directly instead.

The pre-existing TestRetryReadOnly_* tests pass unchanged against the new policy; only the renamed constant was updated.

Notes

  • withJitterSource is deliberately unexported — it is a test seam, not API surface.
  • No open issues in this repository to reference.

Generated by Claude Code

claude added 2 commits August 9, 2026 19:37
Implements the four contract-1.8 quality-of-life sections. Unlike the other
SDKs so far, §16 here REPLACES an existing policy rather than adding one.
Re-vendors CONTRACT.md at 1.8.2.

§16. authz.go's retryReadOnly used a 100 ms base and `backoff *= 2` with no
cap, no jitter and no Retry-After handling. Both omissions matter:

  - Uncapped, the wait is bounded by nothing but the attempt count. At three
    attempts that is survivable; the constant was one edit away from not being.
  - Unjittered, every client that saw the same outage retries at the same
    instant. That is the thundering herd a backoff exists to prevent, and an
    unjittered backoff schedules it rather than avoiding it.

Go was one of five SDKs that had each invented a policy, and all five
disagreed — the divergence contract 1.8 exists to end. retry.go is now the
shared table: 3 attempts, 200 ms base, 5 s cap, full jitter over [0, backoff],
Retry-After as a floor. A cancelled context still wins over a pending backoff,
and returns ctx.Err() rather than the transport error, because a cancelled
context is the caller's decision and not the server's failure.

NetworkError gains a RetryAfter field so the policy can honor the header. It
stores the parsed DURATION, never the raw header text, so the D-04/CR-04
redaction invariant is untouched — a duration cannot carry a token. Both RFC
7231 forms parse: delta-seconds and HTTP-date, the latter being what CDNs and
proxies commonly send on 429/503, and dropping it would silently discard the
server's own statement about when it will be ready. A negative value collapses
to zero rather than becoming a floor.

§17. Opt-in decision memo, off by default, clamped to 5 s, safe for concurrent
use — a Go client is routinely shared across goroutines. Allows and denies
memoized identically, because asymmetric caching leaks which outcome occurred
through latency. Failures never memoized: set() is only reachable after a
successful decode, so §17.1 rule 7 is structural rather than a check that could
be forgotten. Cleared on Login/VerifyMfa/Refresh/Logout, since entries are
keyed by subject rather than session.

§18. Close() sets an atomic flag, clears the memo and closes idle connections.
CloseIdleConnections rather than anything more forceful: an in-flight request on
another goroutine is the caller's to finish, and tearing its connection out
would turn a lifecycle bug into a truncated response. 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. The
test asserts that against the wire, because a Logout wired into Close succeeds
silently.

§19. TelemetryEvent is a closed interface — the marker method is unexported, so
no outside package can add a variant carrying a secret. emit() recovers from a
panicking hook: telemetry may not fail an authorization check, and in Go an
unrecovered panic in a hook would take the process down rather than just the
request. One request pair per attempt, with the attempt threaded through the
retry helper.

d5_conformance_test.go (31 cases) asserts through the public CheckAccess
surface and counts requests reaching the test server, as contract 1.8.1
requires. Jitter is pinned to 0 in those tests so they do not really sleep; the
delay arithmetic is asserted directly instead.

Gates: go build, go vet, go test ./... all green; examples build; gofmt clean.
The coverage job enforces a 94% library floor and my D5 code took it to 93.7%.
The conformance suite asserts the contract's behaviour, which left the
defensive and bookkeeping paths around it unexercised:

  - memo eviction at the entry cap, and re-insert refreshing rather than
    duplicating a key
  - a negative TTL disabling the memo rather than wrapping
  - the nil-receiver memo methods a Client built without one would hit
  - delayFor clamping a jitter fraction outside [0, 1] — a caller-supplied
    source is not trusted to stay in range, and a fraction above 1 would
    exceed the §16.1 cap while a negative one would produce a negative sleep
  - a cancelled context beating a pending backoff, asserting it returns
    ctx.Err() rather than the transport error AND that the loop stops
  - the unexported isTelemetryEvent markers, so the "closed set" guarantee is
    an executed line rather than an untested claim

These are real assertions rather than coverage filler: the eviction test is
the difference between a bounded cache and a memory leak in any service that
checks many resources, and the clamp test is the difference between a bounded
backoff and a negative sleep.

Library statement coverage: 93.7% -> 94.5%.
@ilpanich
ilpanich merged commit 0164e48 into main Aug 9, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants