Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

クローズ
#50,855 コメント 2 件 リアクション 1 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@alzimmermsft がすでに取り組んでいます。

2026年10月18日 から。

  • #50856 @prasanna164-code による — マージ済み

評価

この issue はまだ評価されていません。

説明

Azure.Core Client customer-reported needs-team-attention question

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
主要言語
Java
スター
2.6k
フォーク
2.2k
平均マージ
1日 22時間
マージ済み PR(30日)
213

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

Azure/azure-sdk-for-java のほかの issue

Azure/azure-sdk-for-java の issue をすべて見る

似ている issue

Java の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。