Skip to content

[BUG] AccessTokenCache/SimpleTokenCache: concurrent callers wait forever when the caller refreshing the token is cancelled #50855

Closed

Description

@prasanna164-code

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-core 1.59.1 and 1.60.0 (reproduced with both), same code on current main
  • Java 25

Activity

  1. added
    questionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that
    ClientThis issue points to a problem in the data-plane of the library.
    customer-reportedIssues that are reported by GitHub users external to the Azure organization.
    needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK team
    on Oct 18, 2026
  2. github-actions commented on Oct 18, 2026

    @github-actions
    Contributor

    Thank you for your feedback. Tagging and routing to the team member(s) best able to assist.

    Generated by Agentic Triage for #50855 · auto · 39.9 AIC · ⌖ 3.75 AIC · ⊞ 16.2K · ◷

  3. github-actions commented on Oct 18, 2026

    @github-actions
    Contributor

    🎯 Agentic Issue Triage

    Summary: Concurrent waiters on AccessTokenCache/SimpleTokenCache never complete if the caller performing the token refresh is cancelled before the refresh finishes, because the shared Sinks.One is never completed/errored on cancellation.

    📋 Issue Details

    Package: com.azure:azure-core 1.59.1 / 1.60.0 (reproduced on main)
    Affected API: AccessTokenCache.retrieveToken, SimpleTokenCache.getToken
    Scenarios:

    • Two concurrent requests through BearerTokenAuthenticationPolicy; the request that starts the token refresh is cancelled (e.g., via timeout(...)), leaving the other waiting caller's subscription pending indefinitely.
    • Direct use of AccessTokenCache.getToken: a Disposable subscription 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.
    • Stress test: ~8% of non-cancelled concurrent callers never completed (87/1053 for AccessTokenCache, 83/1054 for SimpleTokenCache) 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.using cleanup in AccessTokenCache.retrieveToken, which only clears the wip reference (w -> w.set(null)) on cancellation but never completes or errors sinksOne. Waiting callers subscribed via sinksOne.asMono() therefore receive no terminal signal. The existing test SimpleTokenCacheTests.wipResetsOnCancel only verifies that wip is cleared, not that waiting callers are released — this is a coverage gap highlighted by the reporter.

    Suggested investigation steps:

    1. Reproduce using the provided direct AccessTokenCache repro (dispose the first subscriber mid-refresh, verify the second caller's Mono never terminates).
    2. Review the Mono.using cleanup/resource-release logic in both AccessTokenCache.retrieveToken and the equivalent SimpleTokenCache.getToken path to add a case where the wip disposal also signals waiters (e.g., complete/error sinksOne so waiters retry on a fresh subscription, or surface a cancellation signal that triggers a resubscribe).
    3. Add/expand tests to assert that waiting callers complete (not just that wip is reset) when the refreshing caller is cancelled, including a concurrency/stress variant mirroring the reporter's scenario.
    🏷️ Label Confidence
    • Category: Client — the issue is about internal azure-core SDK behavior (a concurrency bug in token caching), not external Azure service/REST API behavior.
    • Service: Azure.Core — the issue explicitly names com.azure:azure-core and the affected classes live under /sdk/core/azure-core.
    • Confidence: High — the package, affected classes, and file paths are explicitly named with no ambiguity between services, and the category is clearly SDK-internal rather than service-level.
    👥 Owner Routing
    • Matched CODEOWNERS entry: # ServiceLabel: %Azure.Core (line 56) — issue's predicted service label "Azure.Core" matches this entry exactly; it is the only %Azure.Core entry (distinct from %Azure.Core.V2 and %Azure.Core.AMQP, which require different, non-matching labels).
    • AzureSdkOwners: @alzimmermsft
    • ServiceOwners: none listed
    • Routing action: Assigned @alzimmermsft; added needs-team-attention since the customer is external.
    • Scan notes: No management (%Mgmt) or other catch-all entries were scanned ahead of this match since labels did not include "Mgmt"; %Azure.Core.V2 and %Azure.Core.AMQP entries 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-core is a core, actively-maintained package so this is expected to have no impact on the outcome.

    Generated by Agentic Triage for #50855 · auto · 39.9 AIC · ⌖ 3.75 AIC · ⊞ 16.2K · ◷

  4. added a commit that references this issue on Oct 19, 2026
    9d12974
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Azure.Coreazure-coreClientThis issue points to a problem in the data-plane of the library.customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK teamquestionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions