Skip to content

fix(gax): do not treat a SocketTimeoutException as a thread interrupt - #14578

Open
markaddleman wants to merge 1 commit into
googleapis:mainfrom
markaddleman:fix/gax-socket-timeout-not-interrupt
Open

markaddleman wants to merge 1 commit into
googleapis:mainfrom
markaddleman:fix/gax-socket-timeout-not-interrupt

Conversation

@markaddleman

@markaddleman markaddleman commented Oct 4, 2026 •

Copy link
Copy Markdown

Summary

DirectRetryingExecutor.submit catches InterruptedIOException and sets the current thread's interrupt flag. SocketTimeoutException extends InterruptedIOException, so an HTTP connect or read timeout also sets the flag, although no thread was interrupted. setAttemptFuture then calls Future.get, which sees the flag and throws a new InterruptedException. The SocketTimeoutException is lost, and the retry algorithm judges an InterruptedException in its place. A caller sees an InterruptedException from an unknown sender.

This change catches SocketTimeoutException first and passes it to the retry algorithm unchanged. A real interrupt (InterruptedException, any other InterruptedIOException, ClosedByInterruptException) keeps the current path.

Related to #12860, which reports the interrupt handling in the same catch block. This PR covers the case where no interrupt happened at all.

How we found it

In production, google-cloud-bigquery 2.54.1 with gax 2.84.0. A getQueryResults poll did not answer within java-bigquery's default 60 s HTTP read timeout, and the query failed with BigQueryException: java.lang.InterruptedException from DirectRetryingExecutor.submit. A repro with a 2 s read timeout gives the same stack frame by frame. A Java agent on Thread.interrupt showed the read timeout, then a self-interrupt from this catch block, then the InterruptedException in Guava's AbstractFutureState.blockingGet. Details are on #12860.

Tests

DirectRetryingExecutorTest has two new tests:

  • testSocketTimeoutReachesTheRetryAlgorithm: the retry algorithm sees the SocketTimeoutException, and the call succeeds on the next attempt. On unfixed gax it fails with ExecutionException: java.lang.InterruptedException.
  • testInterruptedIOExceptionStillFailsAsInterrupted: a real InterruptedIOException keeps the current path and still fails as an InterruptedException.

All 20 tests in DirectRetryingExecutorTest pass with the change.

@markaddleman
markaddleman requested a review from a team as a code owner October 4, 2026 10:18
@google-cla

google-cla Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates DirectRetryingExecutor to catch SocketTimeoutException separately from other InterruptedIOExceptions, ensuring that socket timeouts are passed to the retry algorithm without interrupting the thread. It also adds unit tests to verify this behavior. The review feedback suggests improving testInterruptedIOExceptionStillFailsAsInterrupted by explicitly asserting that the current thread's interrupted status is correctly preserved, rather than relying on future.get() throwing an ExecutionException wrapping an InterruptedException.

Comment on lines +104 to +116
void testInterruptedIOExceptionStillFailsAsInterrupted() {
try {
RetryingFuture<String> future =
runRetryingTimeouts(
() -> {
throw new InterruptedIOException("interrupted");
});
ExecutionException e = assertThrows(ExecutionException.class, future::get);
assertInstanceOf(InterruptedException.class, e.getCause());
} finally {
Thread.interrupted();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In testInterruptedIOExceptionStillFailsAsInterrupted, the test asserts that future.get() throws an ExecutionException with an InterruptedException as its cause. However, because DirectRetryingExecutor.submit calls Thread.currentThread().interrupt(), the current thread's interrupt flag is set.

If the interrupt flag is correctly preserved, any subsequent blocking call to future.get() (which delegates to Guava's AbstractFuture.get()) should immediately throw a standalone InterruptedException rather than an ExecutionException wrapping it, unless the interrupt flag was somehow cleared and lost during the future's completion callback execution.

To ensure that the thread's interrupted status is correctly preserved and not lost, we should explicitly assert that the current thread remains interrupted after runRetryingTimeouts completes, and then clear the flag. If the flag is currently being lost, we should investigate why and fix the underlying issue.

References
  1. In Java, do not swallow InterruptedException. When catching it, restore the thread's interrupted status by calling Thread.currentThread().interrupt() and handle the interruption appropriately.

@markaddleman

Copy link
Copy Markdown
Author

I have signed the Google CLA. Please re-check.

DirectRetryingExecutor.submit catches InterruptedIOException and sets the
current thread's interrupt flag. SocketTimeoutException extends
InterruptedIOException, so an HTTP connect or read timeout also sets the
flag, although no thread was interrupted. setAttemptFuture then calls
Future.get, which sees the flag and throws a new InterruptedException. The
SocketTimeoutException is lost, and the retry algorithm judges an
InterruptedException in its place. A caller sees an InterruptedException
from an unknown sender.

Catch SocketTimeoutException first and give it to the retry algorithm
unchanged. A real interrupt (InterruptedException, other
InterruptedIOException, ClosedByInterruptException) keeps the old path.

Related to googleapis#12860.

Claude-Session: https://claude.ai/code/session_01A4Mm1HgYkqqj25ao2sPSKX
@markaddleman
markaddleman force-pushed the fix/gax-socket-timeout-not-interrupt branch from 406976f to 02f740e Compare October 4, 2026 10:53

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant