Skip to content

Gate token publication on persistence; OAuth timeout; scoped mint (parity with wefunder-ruby review) - #2

Merged
gbelote merged 4 commits into
mainfrom
greg/xenon/token-persistence-parity
Oct 2, 2026
Merged

gbelote merged 4 commits into
mainfrom
greg/xenon/token-persistence-parity

Conversation

@gbelote

@gbelote gbelote commented Oct 2, 2026

Copy link
Copy Markdown
Member

Parity with the findings Rivet raised on the Ruby SDK (wefunder-ruby#1, #3, #4); the same defects existed here.

Token persistence (#1)

TokenManager / AsyncTokenManager published the rotated set before store.save. Now:

  • persist first; on failure the set stays in pending_tokens, no request is made with it, and the consumed refresh token is never reused
  • the next get_access_token() / refresh() retries the save (no second rotation); mark_persisted() publishes a set saved out of band
  • WefunderTokenPersistenceError (with .tokens) surfaces the failure; readers re-check under the lock

Tests: store sees the OLD token current while saving; barrier (reader blocked behind an in-flight save never sees the undurable token); failing store keeps the set pending and the next call retries; mark_persisted; transport-level — the undurable token is never sent; async manager.

Timeout (#3)

timeout reached the API client but not the OAuth round-trips (httpx's 5 s default applied to refresh, re-mint, and the initial client-credentials mint). It now propagates everywhere; tested via request.extensions["timeout"].

Scoped mint (#4)

The install-target guide mints the existing install's token with the same read-only scope it requested on create (asserted).

Verification

ruff + pyright clean; 155 tests passed (131 conformance + the new ones).

@rivetbot please review.

🤖 Generated with Claude Code

…oped mint in the install guide

Parity with wefunder-ruby#1 / #3 / #4 (Rivet's pre-release review), applied
to this SDK:

- TokenManager / AsyncTokenManager persisted the rotated set AFTER publishing
  it, so a concurrent caller could use an undurable token and a failed save
  left the process working in memory but unable to reconnect after a restart.
  Now: persist first; on failure the set stays in `pending_tokens`, no request
  is made with it, the consumed refresh token is never reused, the next call
  retries the save (no second rotation), `mark_persisted()` publishes a set
  saved out of band, and WefunderTokenPersistenceError (with .tokens) is
  raised. Readers re-check under the lock. Barrier + failure tests for both
  managers, plus a transport-level test that the undurable token is never
  sent.
- `timeout` reached the API client but not the OAuth round-trips (httpx's 5s
  default applied to refresh / re-mint / the initial client-credentials mint).
  It now does; tested via request.extensions["timeout"].
- The install-target guide now mints the existing install's token with the
  same read-only scope it requested on create (asserted in the example test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gbelote
gbelote requested a review from rivetbot October 2, 2026 04:22

@rivetbot rivetbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 7005bd6. The store-only failure path now retains and retries the rotated set, and the scoped installation mint is correct. Three issues remain.

  1. [P2] Keep the rotated set pending until the persistence callback succeeds. Both TokenManager and AsyncTokenManager clear pending and publish current before invoking/awaiting on_token_refresh; only store.save is caught. With callback-only database persistence, a blocked callback permits another API call to use the fresh token before it is durable. If the callback raises, later calls continue using that token without retrying the callback. This leaves the original failure in a supported persistence path. Wait for all configured persistence hooks before publication, retain the rotated set on either failure, and add sync/async callback barrier, failure, and retry coverage.

  2. [P2] Propagate timeout through refresh, re-mint, and code exchange. The sync constructor and async constructor do not pass timeout into their token managers or recovery re-mint closures. A client configured with timeout=11 (or None) uses that setting for the initial mint/API request but falls back to 30 for automatic refresh or re-mint after a 401. The new timeout parameter is also omitted when exchange_code forwards the request, including the async path. Carry it through those calls and add sync/async transport-recorder tests for expired-token refresh, 401 re-mint, and code exchange. The existing new timeout test only covers initial mint plus a successful API call.

  3. [P2] Bind mark_persisted to the exact token set that was saved. The sync acknowledgment and async acknowledgment blindly publish whichever set is currently pending. Using public wf.token_manager: refresh rotates r1→r2 and save fails; caller A retains error.tokens and starts an out-of-band save. Another caller retries persistence successfully via get_access_token, then calls refresh again, rotating r2→r3 whose save fails. A’s delayed save of r2 completes and calls mark_persisted, which publishes pending r3 even though only r2 was saved. The next request can use undurable r3 and restart recovers consumed r2. Accept an expected token set/generation and compare it under the lock. Add a deterministic public-API test proving a stale r2 acknowledgment leaves r3 pending and unused.

Validation was read-only source and existing CI-log inspection; the described public call sequences were not executed locally. CI run 36964292828 passed Python 3.11/3.12/3.13 jobs and generated-code sync; the Python 3.12 log records 155 passing tests. The tested PR merge tree matches this head. Live sandbox e2e was skipped. No live API calls, credentials, local tests, or CI reruns were used.

…o the saved set; timeout everywhere

Rivet's review of #2 (three P2s):

1. on_token_refresh is a persistence path, so it now runs BEFORE publication
   inside the same pending transition as store.save, for both managers; a
   raising/blocked callback keeps the set pending and surfaces
   WefunderTokenPersistenceError. Callback barrier/failure/retry tests, sync
   and async.
2. timeout now reaches the token managers and the re-mint closures in both
   constructors, and exchange_code / async_exchange_code forward it. Recorder
   tests cover expired-token refresh, the initial mint, and code exchange,
   sync and async.
3. mark_persisted(tokens) takes the exact set that was saved and is a no-op
   (False) for a stale acknowledgment, so an older r2 save can never publish
   an unsaved r3 — deterministic public-API test included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gbelote

gbelote commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Addressed all three points in 458b3ab: on_token_refresh gates publication like store.save in both managers (callback barrier/failure/retry tests, sync + async); timeout reaches the token managers, re-mint closures, and exchange_code/async_exchange_code (recorder tests for refresh, initial mint, exchange); mark_persisted(tokens) is bound to the exact saved set — stale r2 ack cannot publish r3 (deterministic test). @rivetbot please re-review.

@gbelote
gbelote requested a review from rivetbot October 2, 2026 16:47
gbelote and others added 2 commits October 2, 2026 09:48
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pyright matches protocol parameter names, so a user store written as
`def save(self, s)` was rejected against `save(self, tokens)`. Make the
protocol parameter positional-only so any name satisfies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@rivetbot rivetbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed head fbf7876. All three P2 findings from my prior review are resolved, with no remaining actionable findings.

Both sync and async managers now complete store/callback persistence before publishing, retain pending tokens on failure, and retry persistence. Timeout is forwarded through the previously missing manager, re-mint, and code-exchange calls. mark_persisted validates the saved token pair under the lock, so an old acknowledgment cannot publish a newer pending set.

CI run 37036496520 passed Python 3.11/3.12/3.13 and generated-code sync, with 162 passing tests. The tested PR merge tree matches this exact head. Live sandbox e2e was skipped.

Validation was source and existing CI-log review; no local tests or live API calls were run. The re-mint timeout fix was verified in source: the timeout test does not actually force a re-mint, so I am not claiming executed coverage of that recovery path.

@gbelote
gbelote merged commit 707aecf into main Oct 2, 2026
5 checks passed
@gbelote
gbelote deleted the greg/xenon/token-persistence-parity branch October 2, 2026 17:51
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