feat(session): token refresh on 401 at the session layer (LIB-9)#114
Conversation
Session-created child clients transparently refresh the Synergia bearer token and retry once on a 401, instead of surfacing the raw error. Concurrent 401s for the same child collapse to a single portal round- trip (stampede protection). A 401 that persists after the fresh token throws LibrusAuthenticationError with code AUTH_REFRESH_FAILED, with the original 401 preserved as cause. Standalone SynergiaApiClient with no callback is unchanged. Adds LibrusSession.refreshBearerToken(childId) as a public refresh primitive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewSeveral issues should be addressed before merging:
Verified locally: |
- forChildWiadomosci() now receives the onAuthInvalidated callback so API 3.0 methods on those clients also benefit from token refresh - performBearerTokenRefresh() updates accountsCache after a successful refresh so a subsequent forChild() starts with the fresh token instead of the now-stale one stored at login time - withAuthRefresh: use cause: error (first/original 401) instead of cause: retryError (second 401), consistent with CHANGELOG and docs - onAuthInvalidated moved from exported SynergiaApiClientOptions to an internal SynergiaApiClientInternalOptions interface so it no longer appears in the public .d.ts as a settable option - refreshBearerToken() documented in README session methods list - Test: identity-checks that cause was the pre-refresh 401 (stale token) not the post-refresh one (fresh token) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up code reviewThe previous findings around
Verified locally on head |
Add latestTokenPerChild map to LibrusSession so that: - A delayed 401 that fires after the in-flight refresh has already cleared can detect the superseded token (staleToken arg passed by onAuthInvalidated) and return the cached fresh token without a second portal round-trip. Previously the in-flight dedup only helped while the promise was still pending. - forChild(childObject) always starts with the freshest known token, even when the caller holds a stale ChildAccount reference from before a manual refreshBearerToken() call. onAuthInvalidated now receives the client's stale token as an argument so LibrusSession.refreshBearerToken() can compare it against the latest known value. The public refreshBearerToken(childId) signature is unchanged for external callers (staleToken is an optional second arg). Two new tests cover both scenarios. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up code reviewThe two previously reported cross-client stale-token cases are fixed on
Verified locally on head |
SynergiaApiClient.withAuthRefresh now captures this.accessToken before run() so that concurrent sibling requests each report the token they actually used, not the value a racing handler may have already updated. This lets acquireFreshToken() correctly detect superseded stale tokens. LibrusSession: split refreshBearerToken (public, no stale-token arg) from acquireFreshToken (private helper used by onAuthInvalidated). The stale-token parameter no longer appears in the public .d.ts. forChildBff(childObject) now reads latestTokenPerChild so a previously refreshed token is used instead of child.accessToken. Test: replace the direct-refreshBearerToken stale-token dedup test with a same-client parallel-request test that exercises the real code path; add assertion that explicit double-refresh does two portal calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up code reviewNo blocking findings on The three previously reported issues are addressed:
I re-ran the delayed same-client race manually with a deliberately late second Non-blocking test cleanup: the test named Verified locally: |
Rename "reuses the already-refreshed token when a delayed 401 fires after the in-flight refresh cleared" → "refreshBearerToken always triggers a portal call regardless of the latest-token cache" to match what the test now verifies (explicit double-refresh semantics). Make the same-client parallel-401 test deterministic: gate the second stale-token 401 response with a Promise, release it via vi.waitFor after getFreshSynergiaAccount has been called (macrotask boundary guarantees latestTokenPerChild is set). This explicitly exercises the "delayed 401 after in-flight cleared" path rather than relying on Promise.all scheduling. Also update PR description test count from 405 to 408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up code reviewNo findings on The remaining non-blocking test cleanup from the previous review is addressed:
I re-ran the delayed same-client race manually. The observed sequence remains Verified locally: |
Summary
LibrusSession.forChild()clients now transparently refresh the Synergia bearer token and retry once on a401, instead of surfacing the expired-token error to the caller401s for the same child collapse to a single refresh, and a401that persists after refresh throwsLibrusAuthenticationErrorwith codeAUTH_REFRESH_FAILED(the original401preserved ascause)LibrusSession.refreshBearerToken(childId)is also available as a public refresh primitivenew SynergiaApiClient(token)with no callback is unchanged — still throws on401Closes LIB-9.
Design notes
SynergiaApiClient.withAuthRefresh(where the mutable token slot and callback live).request.tsgains an explicit GET-only/idempotent invariant comment.onAuthInvalidatedis an internal-only option (not in exportedSynergiaApiClientOptions, not in public.d.ts) —SynergiaApiClientInternalOptionsis unexported.latestTokenPerChildmap eliminates redundant portal refreshes when a delayed stale-token401fires after the in-flight map clears, and ensuresforChild(childObject)uses the freshest known token regardless of what the caller'sChildAccountreference holds.run()inwithAuthRefreshso parallel requests on the same client each report the token they actually used, even if a sibling handler already updatedthis.accessToken.Test plan
npm run build— type-check passesnpm test— 408 tests pass (24 test files), including new tests:BearergetFreshSynergiaAccountcallAUTH_REFRESH_FAILED+cause= original 401forChild(childObject)afterrefreshBearerTokenstarts with fresh tokenrefreshBearerTokenalways triggers a portal call regardless of cachenpm run lint— clean🤖 Generated with Claude Code