Serguey Asael Shinder: SSLHandshakeException is an IOException, so your retry loop retries it
A common way to make a network call resilient looks like this:
for (int attempt = 1; ; attempt++) {
try {
return fetch(url);
} catch (IOException e) {
if (attempt == 3) throw e;
sleep(backoff(attempt));
}
}
It reads as "retry on network errors". It actually means "retry on anything in the IOException tree", and that tree holds very different failures.
One parent, four meanings. From the JDK 25 javadoc:
| Exception | Superclass chain | What it says | |---|---|---| | SocketTimeoutException | InterruptedIOException → IOException | a socket read or accept timed out | | ConnectException | SocketException → IOException | the connection attempt failed | | UnknownHostException | IOException | the host's IP address could not be determined | | SSLHandshakeException | SSLException → IOException | client and server "could not negotiate the desired level of security. The connection is no longer usable." |

SSLException's other direct subclasses — SSLPeerUnverifiedException ("the peer's identity has not been verified"), SSLKeyException, SSLProtocolException — sit in the same place.
Why it matters. A timeout may well succeed on the second try. A handshake that failed because the certificate chain does not validate will fail the same way on the third try — or, worse, the "resilient" code does not retry the same server but falls back to the next one: a mirror, a secondary endpoint, the next repository in a list. A TLS failure is then exactly the condition under which your code quietly goes somewhere else.
That is not hypothetical. On 7 October Gradle fixed GHSA-j5m7-59rp-24f5: SSLException and SSLHandshakeException were treated as neither fatal nor transient during dependency resolution, so Gradle moved on to the next repository.
The fix is ordering. Catch the TLS family first and do not retry or fall back on it; then decide which remaining IOExceptions you consider transient.
} catch (SSLException e) {
throw e; // trust failure: stop, never fall through
} catch (SocketTimeoutException | ConnectException e) {
if (attempt == 3) throw e; // plausibly transient: retry
sleep(backoff(attempt));
}
The compiler enforces the order: JLS §11.2.3 makes it a compile-time error for a catch (SSLException) to follow a catch (IOException) in the same try. Any other IOException here propagates — which is the point. Decide what "transient" means; do not inherit it from a superclass.
No JDK runs on the machine this note was written on; the class hierarchy is the one in the linked javadoc, and the catch-order rule is the one in the linked JLS section.