Repository navigation
[Identity] Stop reporting interruptions and JVM shutdown as authentication errors in getTokenSync - #50475
Conversation
|
Azure Pipelines: Successfully started running 1 pipeline(s). 31 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 Changes recommended
Cache interruptions can still be swallowed, and two credential paths can still log interruptions at error level.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR updates Azure Identity synchronous token acquisition to handle interruptions and JVM shutdown as cancellation signals instead of authentication failures.
Changes:
- Preserves interrupt status and adjusts cancellation logging.
- Propagates cancellation through credential chains.
- Adds regression tests and changelog documentation.
File summaries
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
143a7e4 to
178e259
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Fix the workload-identity test setup and make interruption logging null-safe.
Review details
Suppressed comments (2)
sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/util/LoggingUtil.java:45
- For a direct
InterruptedException(for example, an async MSAL future failure),getMessage()is typicallynull, so this new verbose message is emitted asinterrupted: null. Keep the shutdown/cancellation message useful by applying the same null-safe handling as the existing error path.
error.getMessage()),
sdk/identity/azure-identity/src/test/java/com/azure/identity/implementation/IdentitySyncClientInterruptionTests.java:185
- The new workload-identity test uses the same mocked
ConfidentialClientApplication.Builder, butIdentityClientBase.getWorkloadIdentityConfidentialClient()assigns the result ofappTokenProvider(...)beforebuild(). That fluent method is not stubbed here, so Mockito returnsnulland the subsequent builder call throws aNullPointerException;workloadIdentitySurfacesInterruptionAsCancellationnever reaches the interruption path. Stub this method to return the builder.
when(builder.logPii(anyBoolean())).thenReturn(builder);
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
…rrors getTokenSync waits for the MSAL token request with Future.get() on the caller's thread. When that thread is interrupted while waiting, which is what a Reactor scheduler does when it disposes a worker on cancellation, the InterruptedException was wrapped, logged as an error by the sync client and logged as an error again by the credential, the thread's interrupt status was lost, and on the cache-lookup paths the null message of the InterruptedException caused a NullPointerException. A token request that ran into the JVM shutting down (IllegalStateException "Shutdown in progress" from the shared executor) was logged as an error the same way. Wait for every MSAL future through one helper that restores the interrupt status, logs at verbose level and rethrows the interruption as a RuntimeException caused by the InterruptedException. Treat the JVM shutdown signal the same way, without touching the interrupt status. LoggingUtil.logTokenError, shared by every credential on both the sync and async paths, logs shutdown signals at verbose level instead of the configured error level, and ChainedTokenCredential surfaces them unchanged instead of reporting the credential as failed. The credentials also no longer treat a shutdown signal raised during their token-cache lookup as a cache miss. That catch-all let a cancelled request fall through to a full acquisition and, for the interactive credentials, prompt the user for a request they had already cancelled. ClientAssertion and AzurePipelines additionally re-wrapped every failure through logExceptionAsError, which logged a cancellation at error level regardless. Both now go through IdentityUtil.rethrowIfShutdownSignal. Cache-miss checks are null-safe. Fixes Azure#49239
178e259 to
a9d1dcd
Compare
…rrors (Azure#50475) getTokenSync waits for the MSAL token request with Future.get() on the caller's thread. When that thread is interrupted while waiting, which is what a Reactor scheduler does when it disposes a worker on cancellation, the InterruptedException was wrapped, logged as an error by the sync client and logged as an error again by the credential, the thread's interrupt status was lost, and on the cache-lookup paths the null message of the InterruptedException caused a NullPointerException. A token request that ran into the JVM shutting down (IllegalStateException "Shutdown in progress" from the shared executor) was logged as an error the same way. Wait for every MSAL future through one helper that restores the interrupt status, logs at verbose level and rethrows the interruption as a RuntimeException caused by the InterruptedException. Treat the JVM shutdown signal the same way, without touching the interrupt status. LoggingUtil.logTokenError, shared by every credential on both the sync and async paths, logs shutdown signals at verbose level instead of the configured error level, and ChainedTokenCredential surfaces them unchanged instead of reporting the credential as failed. The credentials also no longer treat a shutdown signal raised during their token-cache lookup as a cache miss. That catch-all let a cancelled request fall through to a full acquisition and, for the interactive credentials, prompt the user for a request they had already cancelled. ClientAssertion and AzurePipelines additionally re-wrapped every failure through logExceptionAsError, which logged a cancellation at error level regardless. Both now go through IdentityUtil.rethrowIfShutdownSignal. Cache-miss checks are null-safe. Fixes Azure#49239

Description
Fixes #49239
getTokenSyncon every credential waits for the MSAL token request withCompletableFuture.get()on the caller's thread. When that thread is interrupted while waiting, which is exactly whatBoundedElasticScheduler.Worker.dispose()does when an upstream Reactor subscription is cancelled (pod scale-down, request timeouts, client disconnects),get()throwsInterruptedException. TodayIdentitySyncClientwraps it in aRuntimeException, logs it as an error, and the credential logs it as an error a second time:An interrupt is a cooperative cancellation signal from the caller, not an authentication failure. Reporting it at error level floods dashboards during normal shutdown, and nothing downstream can suppress it because it is logged inside the SDK before the caller sees the exception. Two further problems sat on the same path:
Future.get()clears the thread's interrupt status when it throws, and the SDK never restored it, so the cancellation was invisible to the rest of the caller's code.e.getMessage().contains("Token not found in the cache")on the caught exception.InterruptedException.getMessage()isnull, so an interrupt there raised aNullPointerException, which the credentials' silent cachecatchthen swallowed.Change
IdentitySyncClientnow waits for every MSAL future through one helper,awaitTokenResult. OnInterruptedExceptionit restores the interrupt status, logs at verbose level, and rethrows aRuntimeExceptionwhose cause is theInterruptedException. The flows that previously wrapped any exception asClientAuthenticationException(username/password, device code, interactive browser, on-behalf-of) orCredentialUnavailableException(workload identity) rethrow an interruption unchanged instead, so a cancellation is never reported as "authentication failed" or "credential unavailable". The broker silent-acquisition path no longer swallows an interruption before falling through to the interactive dialog.IdentityUtil.isInterruption(Throwable)walks the cause chain for anInterruptedException. The chain is followed to its end, asRetryPolicydoes when it inspects a chain for retriable causes; two cursors advancing at different speeds make a cyclic chain (whichThrowable.initCausepermits) terminate instead of spinning.LoggingUtil.logTokenError, shared by all 16 credentials'getTokenSync, logs an interruption at verbose level with a distinct message instead of the configured error level. Genuine failures are logged exactly as before.The cache-miss checks are null-safe.
The credentials no longer treat a shutdown signal raised during their token-cache lookup as a cache miss. Their
getTokenSyncmethods wrap that lookup in acatch (Exception) { }so that a miss falls through to a full acquisition; a cancellation was swallowed there too. That made the cancelled request run a second acquisition, and forInteractiveBrowserCredentialandDeviceCodeCredentialit could prompt the user for a request they had already cancelled, or reportAuthenticationRequiredExceptionat error level. Ten such sites across eight credentials now call a sharedIdentityUtil.rethrowIfShutdownSignal(Exception).ClientAssertionCredentialandAzurePipelinesCredentialadditionally wrapped every failure withLOGGER.logExceptionAsError(new RuntimeException(e)), which logs at error level regardless of whatLoggingUtil.logTokenErrordecided. They now rethrow a shutdown signal before that wrap, so the guarantee holds for them as well.ChainedTokenCredential.getTokenSync(and thereforeDefaultAzureCredential) surfaces an interruption unchanged instead of wrapping it as "<credential>authentication failed", and does not go on to try the remaining credentials: they would be interrupted too. Credentials that use azure-core's defaultgetTokenSync(which blocks on the reactivegetToken, for exampleManagedIdentityCredential) already behave this way, because Reactor'sblock()restores the interrupt status and throws aRuntimeExceptioncaused by theInterruptedException; this change makes the MSAL-backed synchronous credentials consistent with them.A token request that fails because MSAL's own worker was interrupted (an
ExecutionExceptioncaused byInterruptedException, for example during executor shutdown) is surfaced the same way, without touching the calling thread's interrupt status.The second failure mode from the issue is covered by the same rule: a token request that runs into the JVM shutting down surfaces as
IllegalStateException("Shutdown in progress")(the JVM's message when azure-core's shared executor tries to register its shutdown hook after shutdown has begun), either as the MSAL future's failure or thrown synchronously byacquireToken.IdentityUtil.isShutdownSignalrecognises both signals; the log-level decision, the sync client and the chain use it, so the asyncdoOnErrorpath is covered as well since it logs through the sameLoggingUtil.logTokenError. Only a real interruption of the calling thread restores the interrupt status.The root cause of that second mode, azure-core's
ImplUtils.addShutdownHookSafelynot toleratingRuntime.addShutdownHookfailing during shutdown andSharedExecutorService.ensureNotShutdownrecreating the executor at that point, lives in azure-core and is worth a separate change there; this PR makes identity stop reporting the symptom as an authentication error, as the issue asks.No public API changes. The exception a caller receives on interruption is still a
RuntimeException, now with a clear message and the interrupt status set, which is the convention azure-core follows elsewhere (Thread.currentThread().interrupt()before rethrowing).Testing
LoggingUtilTest: the level decision is a function,LoggingUtil.tokenErrorLogLevel, so the tests assert it directly: an authentication failure keeps the configured error level, an interruption resolves to verbose, an interruption nested inside aClientAuthenticationException(asChainedTokenCredentialproduces) is detected,IllegalStateException("Shutdown in progress")resolves to verbose while any otherIllegalStateExceptionstays at error, a 64-deep chain is followed to its end, and a cyclic chain terminates (both under a timeout).IdentitySyncClientInterruptionTests(new): builds a realIdentitySyncClientwith a mocked MSAL application whose futures never complete, runs the acquisition on the test thread and interrupts it from a helper thread, for the client-credential, cache-lookup, on-behalf-of and workload-identity flows. Asserts the call returns promptly, the exception is caused by theInterruptedException, it is not aClientAuthenticationException,CredentialUnavailableExceptionorNullPointerException, and the interrupt status is restored. A future failed by MSAL's own worker being interrupted, or by the JVM shutting down, is surfaced as a cancellation without setting the caller's interrupt status; any other failure of the future is still an authentication error. A completed future is unaffected.ChainedTokenCredentialTest: an interrupted credential's exception is surfaced unchanged by the chain and the next credential is not tried.CredentialShutdownSignalTests(new): drives the behaviour through the publicgetTokenSyncsurface. A cancellation raised by the cache lookup is surfaced unchanged and no full acquisition follows; an ordinary cache failure still falls through and returns a token;ClientAssertionCredentialpropagates the cancellation instead of re-wrapping it; andInteractiveBrowserCredentialdoes not reach browser interaction after a cancelled cache lookup. Against the previous commit three of these four fail.ClientSecretCredentialagainst a slow local authority with the calling thread interrupted every 20 ms) run locally against this branch produces no error-level identity log, only the verbose interruption line, and leaves the interrupt status set.With the behaviour neutralised (old code paths restored), the new tests fail, including the reporter's reproducer. Existing
azure-identityunit tests pass; Checkstyle and SpotBugs are clean.All SDK Contribution checklist:
General Guidelines and Best Practices
Testing Guidelines