Repository navigation
[Core] Don't leave callers waiting forever when a token refresh is cancelled - #50856
Merged
Alan Zimmer (alzimmermsft) merged 2 commits intoOct 19, 2026
Merged
Alan Zimmer (alzimmermsft) merged 2 commits into
Alan Zimmer (alzimmermsft) merged 2 commits into
Conversation
AccessTokenCache and SimpleTokenCache let one caller refresh the token while concurrent callers wait on a Sinks.One. When the refreshing caller was cancelled, the Mono.using cleanup only reset wip and never completed the sink, so every waiting caller stayed pending indefinitely. The cleanup now also completes the sink empty, a no-op when the refresh already completed it. Released callers use the current cached token if it is still valid and otherwise acquire a new one. The branch for an already finished refresh uses the same fallback instead of returning a captured token that could be null or expired. Fixes Azure#50855
prasanna164-code
requested review from
a team,
Jonathan Giles (JonathanGiles),
Alan Zimmer (alzimmermsft) and
vcolin7
as code owners
October 18, 2026 20:19
Contributor
|
Thank you for your contribution prasanna164-code! We will review the pull request and get back to you soon. |
|
Azure Pipelines: Successfully started running 1 pipeline(s). 34 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
An empty refresh in SimpleTokenCache can trigger recursive resubscription before cleanup and overflow the stack.
What changed in this PR
Addresses #50855 by releasing concurrent token-cache callers when a refresh is cancelled, without changing public APIs.
Changes:
- Completes waiting sinks during refresh cleanup.
- Uses valid cached tokens or retries acquisition.
- Adds cancellation regression tests and a changelog entry.
| File | Description |
|---|---|
| sdk/core/azure-core/src/test/java/com/azure/core/credential/SimpleTokenCacheTests.java | Tests waiter recovery after cancellation. |
| sdk/core/azure-core/src/test/java/com/azure/core/credential/AccessTokenCacheTests.java | Tests waiter recovery after cancellation. |
| sdk/core/azure-core/src/main/java/com/azure/core/credential/SimpleTokenCache.java | Releases waiters and adds reacquisition fallback. |
| sdk/core/azure-core/src/main/java/com/azure/core/credential/AccessTokenCache.java | Releases waiters and validates the latest cached token. |
| sdk/core/azure-core/CHANGELOG.md | Documents the cancellation fix. |
Review effort: Balanced
…resh When the token request completed empty, the NO REFRESH branch released waiting callers while wip still held the finished refresh. In SimpleTokenCache a released waiter then re-entered the acquisition, found the same completed sink, and recursed until StackOverflowError. Waiting callers are now released only by the Mono.using cleanup, after wip has been cleared, so a retrying waiter always starts a new acquisition. SimpleTokenCache retries through a private acquireToken() instead of the overridable getToken().
Alan Zimmer (alzimmermsft)
approved these changes
Oct 19, 2026
Member
|
Thanks for fixing this prasanna164-code! Let me know about the comment I had just in case I need to approve this again. |
Contributor
Author
|
Alan Zimmer (@alzimmermsft) I've replied to your question on the test above. No code changes since your approval, so the re-request notification can be ignored; it was a misclick on my end. Sorry for the noise! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Description
Fixes #50855.
AccessTokenCacheandSimpleTokenCachelet one caller refresh the token while concurrent callers wait on aSinks.One. The refresh runs insideMono.using, and its cleanup only resetswip:On success the sink receives the token and on failure it receives the error, but if the refreshing caller is cancelled the sink is never completed. Every caller waiting on
sinksOne.asMono()then stays pending indefinitely, even after a later caller has fetched a new token. On the async pathBearerTokenAuthenticationPolicyusesAccessTokenCache, so concurrent requests from an async client can hang when the request that started the refresh is cancelled, for example by atimeout(...)or a disposed subscription while a slow token request is in progress.Change
Mono.usingcleanup in both classes now completes the sink empty after clearingwip. It is the only place that completes the sink empty: theNO REFRESHbranch no longer does, so waiting callers are released only oncewipno longer refers to the finished refresh. When the refresh already completed the sink with a token or an error, the cleanup's emission is a no-op.AccessTokenCache: the fallback reads the latest cached token and completes empty if there is none or it has expired. The existingrepeatWhenEmptyingetTokenthen retries. Previously the fallback returned thecachedTokencaptured before waiting, so the comment "defer to updated cache in case just missed onNext()" didn't hold, and the value could benullor already expired.SimpleTokenCache:getTokenhas norepeatWhenEmpty, so the fallback starts a new acquisition when the cached token isn't valid. It already read the livecachefield. The acquisition logic moved fromgetToken()into a privateacquireToken(), whichgetToken()delegates to, so the retry doesn't go through the overridable public method.wip == nullbranch ("the refreshing thread has finished") uses the same fallback. It previously returnedMono.just(cachedToken)(Mono.just(cache)inSimpleTokenCache). That errors with aNullPointerExceptionwhen no token has been cached yet, and can return an expired token.No public API changes.
Waiting callers released this way make a new token request instead of reusing the cancelled one. If a
SimpleTokenCachesupplier keeps completing empty, the released callers retry one at a time until each has made its own attempt. That is at most one extra token request per waiting caller; previously they all completed empty. If the retried request fails, the waiters receive that error, subject to the existingrefreshDelayback-off thatSimpleTokenCacheapplies after a failed refresh. I considered keeping the refresh running after its caller cancels so that the waiters could share it. That changes cancellation semantics, though: the token request would outlive every caller, and it would have to run outside the caller's ReactorContext. A second token request after a cancellation seemed the safer trade-off.Related observations, not addressed here
containerregistry'simplementation/authentication/AccessTokenCacheImplhas the same pattern, withdoFinally(ignored -> wip.set(null))and waiters onsinksOne.asMono(), so the Container Registry credential policy can hang the same way. I've kept this PR toazure-coreand am happy to send the same change for Container Registry separately.AccessTokenCache(refreshOnContextChangewith a different tenant, scopes or claims) leavesthis.tokenRequestContextset to the new context without a matching token. The next request for that context then gets the previously cached token for the old context. For example, after a cancelled refresh for tenant B, a request for tenant B receives tenant A's token, until that token reaches its refresh time. This happens onmainand on this branch alike, and the sync path has the same ordering. It needs a different change, recording the context only once a token for it has been acquired, so I'd suggest handling it separately as well.Testing
New tests in
AccessTokenCacheTestsandSimpleTokenCacheTests:waitingCallerGetsTokenWhenRefreshingCallerIsCancelled: the first token request never completes, a second caller waits on that refresh, and the first caller is disposed. The second caller must receive a token from a new acquisition. This fails onmain, where the second caller never completes.waitingCallerGetsTokenWhenRefreshCompletesEmpty: the first token request completes empty while a second caller waits. The waiter must receive a token from a second acquisition. WithSimpleTokenCachethis completes empty onmain.Outside the module's tests, comparing the released 1.60.0 with this change:
BearerTokenAuthenticationPolicy, with a timeout on the first. Request B never completes on 1.60.0 and completes with this change.getToken()again from their own callback. The refresh completes with a token, fails, completes empty or is cancelled. Later token requests return a token, complete empty or fail, synchronously or asynchronously. On 1.60.0 every waiter hangs when the refresh is cancelled. With this change every waiter completes, and nothing overflows the stack.azure-corepasses its full module build (11,894 tests), including Checkstyle, SpotBugs and RevApi.All SDK Contribution checklist:
General Guidelines and Best Practices
Testing Guidelines