Repository navigation
Description
Describe the bug
When several callers ask AccessTokenCache (or SimpleTokenCache) for a token at the same time and there is no valid cached token, one caller performs the refresh and the others wait on it. If the refreshing caller is cancelled before the refresh completes, the waiting callers never complete. They stay pending until their own subscribers give up, even after later callers have successfully fetched a new token.
BearerTokenAuthenticationPolicy uses AccessTokenCache on the async path, so an async client can hang its in-flight requests when the request that happened to start the token refresh is cancelled. Typical causes are a timeout(...), a block(Duration) that expires, or a disposed subscription such as a client disconnect in a WebFlux app, while a slow token request (for example the first managed identity token) is in progress.
Cause
In AccessTokenCache.retrieveToken the refresh runs inside
Mono.using(() -> wip,
ignored -> tokenRefresh.materialize()
.flatMap(processTokenRefreshResult(sinksOne, now, fallback))
.doOnError(sinksOne::tryEmitError),
w -> w.set(null));Waiting callers subscribe to sinksOne.asMono(). On cancellation the cleanup only clears wip; sinksOne is never completed, so the waiters never receive a signal. SimpleTokenCache.getToken has the same pattern. The existing test SimpleTokenCacheTests.wipResetsOnCancel checks that wip is cleared, but not that waiting callers complete.
To Reproduce
Through an async pipeline with BearerTokenAuthenticationPolicy: two concurrent requests, a token request that takes 2 seconds, and a 500 ms timeout on the first request only.
AtomicInteger tokenCalls = new AtomicInteger();
TokenCredential credential = request -> Mono.delay(Duration.ofSeconds(2))
.map(ignored -> new AccessToken("token-" + tokenCalls.incrementAndGet(), OffsetDateTime.now().plusHours(1)));
HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient) // any client returning 200
.policies(new BearerTokenAuthenticationPolicy(credential, "https://example.azure.net/.default"))
.build();
CompletableFuture<Integer> a = pipeline.send(new HttpRequest(HttpMethod.GET, "https://example.azure.net/a"))
.map(HttpResponse::getStatusCode).timeout(Duration.ofMillis(500)).toFuture();
CompletableFuture<Integer> b = pipeline.send(new HttpRequest(HttpMethod.GET, "https://example.azure.net/b"))
.map(HttpResponse::getStatusCode).toFuture();With azure-core 1.60.0, request A fails with its own TimeoutException and request B never completes. It is still pending after a third request has succeeded:
request A: TimeoutException (its own timeout)
request B: still waiting after 10 seconds
request C status 200, request B done: false, token calls: 1
The same thing using AccessTokenCache directly:
AtomicInteger calls = new AtomicInteger();
TokenCredential credential = request -> Mono.delay(Duration.ofSeconds(1))
.map(ignored -> new AccessToken("token-" + calls.incrementAndGet(), OffsetDateTime.now().plusHours(1)));
AccessTokenCache cache = new AccessTokenCache(credential);
TokenRequestContext context = new TokenRequestContext().addScopes("https://example.azure.net/.default");
Disposable first = cache.getToken(context, false).subscribe(); // starts the refresh
CompletableFuture<AccessToken> second = cache.getToken(context, false).toFuture(); // waits on that refresh
first.dispose(); // refreshing caller cancelled
try {
System.out.println("second caller got " + second.get(5, TimeUnit.SECONDS).getToken());
} catch (TimeoutException e) {
System.out.println("second caller: no token after 5 seconds");
}
System.out.println("new caller got " + cache.getToken(context, false).block(Duration.ofSeconds(5)).getToken());
System.out.println("second caller done: " + second.isDone());Output with azure-core 1.59.1 and 1.60.0:
second caller: no token after 5 seconds
new caller got token-1
second caller done: false
With random cancellations it is not a corner case. In a local stress run (40 concurrent callers per round, about a third of them cancelled at random times, 40 rounds), about 8% of the callers that were not cancelled never completed on 1.60.0: 87 of 1,053 with AccessTokenCache and 83 of 1,054 with SimpleTokenCache.
Expected behavior
When the refresh a caller is waiting on ends without a token because the refreshing caller was cancelled, the waiting caller should retry the acquisition instead of waiting forever.
Setup
- Library:
com.azure:azure-core1.59.1 and 1.60.0 (reproduced with both), same code on currentmain - Java 25
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Oct 18, 2026
Thank you for your feedback. Tagging and routing to the team member(s) best able to assist.
github-actions commentedon Oct 18, 2026
🎯 Agentic Issue Triage
Summary: Concurrent waiters on
AccessTokenCache/SimpleTokenCachenever complete if the caller performing the token refresh is cancelled before the refresh finishes, because the sharedSinks.Oneis never completed/errored on cancellation.📋 Issue Details
Package:
com.azure:azure-core1.59.1 / 1.60.0 (reproduced onmain)Affected API:
AccessTokenCache.retrieveToken,SimpleTokenCache.getTokenScenarios:
BearerTokenAuthenticationPolicy; the request that starts the token refresh is cancelled (e.g., viatimeout(...)), leaving the other waiting caller's subscription pending indefinitely.AccessTokenCache.getToken: aDisposablesubscription that triggered the refresh is disposed, and a second concurrent caller waiting on the same in-flight refresh never completes even though a later caller successfully obtains a new token.AccessTokenCache, 83/1054 forSimpleTokenCache) across 40 rounds of 40 concurrent callers with random cancellations.Root ask: When the in-flight refresh ends without producing a token because the refreshing caller was cancelled, waiting callers should retry acquisition (or otherwise receive a terminal signal) instead of hanging forever.
🔎 Debugging / Reproduction Notes
The reporter traces the root cause to the
Mono.usingcleanup inAccessTokenCache.retrieveToken, which only clears thewipreference (w -> w.set(null)) on cancellation but never completes or errorssinksOne. Waiting callers subscribed viasinksOne.asMono()therefore receive no terminal signal. The existing testSimpleTokenCacheTests.wipResetsOnCancelonly verifies thatwipis cleared, not that waiting callers are released — this is a coverage gap highlighted by the reporter.Suggested investigation steps:
AccessTokenCacherepro (dispose the first subscriber mid-refresh, verify the second caller'sMononever terminates).Mono.usingcleanup/resource-release logic in bothAccessTokenCache.retrieveTokenand the equivalentSimpleTokenCache.getTokenpath to add a case where thewipdisposal also signals waiters (e.g., complete/errorsinksOneso waiters retry on a fresh subscription, or surface a cancellation signal that triggers a resubscribe).wipis reset) when the refreshing caller is cancelled, including a concurrency/stress variant mirroring the reporter's scenario.🏷️ Label Confidence
Client— the issue is about internal azure-core SDK behavior (a concurrency bug in token caching), not external Azure service/REST API behavior.Azure.Core— the issue explicitly namescom.azure:azure-coreand the affected classes live under/sdk/core/azure-core.👥 Owner Routing
# ServiceLabel: %Azure.Core(line 56) — issue's predicted service label "Azure.Core" matches this entry exactly; it is the only%Azure.Coreentry (distinct from%Azure.Core.V2and%Azure.Core.AMQP, which require different, non-matching labels).@alzimmermsft@alzimmermsft; addedneeds-team-attentionsince the customer is external.%Mgmt) or other catch-all entries were scanned ahead of this match since labels did not include "Mgmt";%Azure.Core.V2and%Azure.Core.AMQPentries were skipped as they require different service labels not present on this issue.Note: The deprecated-package lookup against the Azure SDK release-metadata CSV could not be completed in this run (fetch tool unavailable);
azure-coreis a core, actively-maintained package so this is expected to have no impact on the outcome.[Core] Don't leave callers waiting forever when a token refresh is ca…