Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

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

Cerrado
#50,855 2 comentarios 1 reacción 1 asignado Ver en GitHub

Los mantenedores suelen responder en 1 día

@alzimmermsft ya está trabajando en esto.

Desde el 18/10/2026.

  • #50856 de @prasanna164-code — fusionado

Evaluación

Este issue todavía no se ha evaluado.

Descripción

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
Lenguaje dominante
Java
Estrellas
2.6k
Forks
2.2k
Merge medio
2 d 4 h
PR fusionados (30 d)
231

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de Azure/azure-sdk-for-java

Todos los issues de Azure/azure-sdk-for-java

Issues similares

Más issues de Java

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.