Skip to content

Commit e76ff16

Browse files
authored
fix(internal): narrow TLS classification to certificate failures only (#206)
## Summary `FailureClass.hasTlsOrCertificateCause` matched any `SSLException` **or** `GeneralSecurityException` anywhere in the cause chain. Since all SDK traffic is HTTPS, every transport error arrives through the TLS layer — so this swept in transient faults unrelated to certificate validity and classified them `UNEXPECTED`, pushing data sources into extended-regime backoff (5 min – 1 hr). Found during review of #200 by @jsonbailey, who flagged the breadth but could not run a JVM to confirm what JSSE actually throws. Confirmed empirically below. ## The problem, measured Against a real `SSLServerSocket`: | Scenario | JSSE exception | Old classification | |---|---|---| | Peer sends **FIN** mid-handshake | `SSLHandshakeException: Remote host terminated the handshake`<br>← caused by `EOFException: SSL peer shut down incorrectly` | **UNEXPECTED** ❌ | | Peer sends **RST** mid-handshake | `SocketException: Broken pipe` | NORMAL ✓ | | Untrusted certificate chain | `SSLHandshakeException` → `ValidatorException` → `SunCertPathBuilderException` | UNEXPECTED ✓ | The regime therefore depended on whether an intermediary sent FIN or RST — an arbitrary implementation detail. Real triggers for FIN-mid-handshake are all transient: load balancer draining during a rolling restart, a connection-limit polite close, an idle timeout during a slow handshake. **The compounding case is worse than a single stall.** A connection that flaps faster than the 60 s healthy-operation reset window never accumulates enough continuous connectivity to reset, so it ratchets 5 m → 10 m → 20 m → 40 m → 1 hr and stays there. ## The fix Match only genuinely long-lived certificate problems: ```java c instanceof CertificateException // expired, not-yet-valid, hostname mismatch; // also covers ValidatorException || c instanceof CertPathValidatorException // untrusted chain || c instanceof CertPathBuilderException || c instanceof SSLPeerUnverifiedException // hostname mismatch ``` Verified that a genuinely untrusted chain still classifies `UNEXPECTED` — JSSE's `ValidatorException` is a `CertificateException` and `SunCertPathBuilderException` is a `CertPathBuilderException`, so two links of the real chain match. ## Parity with Go This aligns Java with the Go server SDK, whose `classifyTransportFailure` enumerates only certificate errors and treats everything else as normal: ```go tls.CertificateVerificationError x509.UnknownAuthorityError x509.HostnameError x509.CertificateInvalidError // everything else -> FailureClassNormal ``` The previous Java behavior was a divergence from that reference implementation, not a different reading of the spec. ## Tests - **Replaces** `sslHandshakeIsUnexpected`, which asserted the over-broad behavior, with `bareSslHandshakeFailureIsNormal` and `peerClosedMidHandshakeIsNormal` (the latter reproducing the real `SSLHandshakeException` → `EOFException` shape). - **Adds** `certPathValidatorFailureIsUnexpected`, `certPathBuilderFailureIsUnexpected`, `certificateNotYetValidIsUnexpected`, `untrustedChainWrappedInHandshakeExceptionIsUnexpected`, `sslExceptionFromConnectionResetIsNormal`. - Full `lib/shared/internal` suite green; `checkstyleMain` clean. ## Test plan for reviewers - [x] Confirm the four matched types are the right set — in particular that `CertificateException` is the correct catch-all for validator failures, and that nothing in Go's four cases lacks a Java counterpart here. - [x] Consider whether a bare `SSLHandshakeException` with a *cipher/protocol* mismatch cause (e.g. `handshake_failure` alert, "No appropriate protocol") should be `UNEXPECTED`. It is persistent like a cert problem, but it is not a certificate error and is now classified `NORMAL`. I left it as `NORMAL` to avoid re-widening, and because a persistent mismatch keeps retrying at 1–30 s rather than stalling — but it is a judgment call. - [x] Sanity-check that no other caller depends on the old broad behavior. ## Downstream #200 consumes this classifier. It needs an internal release (1.11.1) before it can pick this up, in addition to the `classifyAndLogHttpFailure` rename already noted in review there. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Narrows when HTTPS transport failures trigger extended-regime backoff** by changing `FailureClass.hasTlsOrCertificateCause` to walk the exception chain for **certificate validation problems only** (`CertificateException`, `CertPathValidatorException`, `CertPathBuilderException`, `SSLPeerUnverifiedException`), instead of any `SSLException` or `GeneralSecurityException`. > > Because all SDK traffic is TLS, the old rule treated many **transient** handshake faults (e.g. peer FIN mid-handshake, bare `SSLHandshakeException`, connection-reset `SSLException`) as **UNEXPECTED**, which could push data sources into multi-minute backoff. Genuine cert issues (expired/not-yet-valid, untrusted chain wrapped in `SSLHandshakeException`) still classify **UNEXPECTED**. > > Tests are updated to match: removed the expectation that every SSL handshake failure is unexpected, added cases for cert-path errors and normal transient SSL shapes, and kept wrapped-certificate-cause coverage. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fa662f2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9ddac84 commit e76ff16

2 files changed

Lines changed: 45 additions & 9 deletions

File tree

‎lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/FailureClass.java‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.launchdarkly.sdk.internal.http;
22

3-
import javax.net.ssl.SSLException;
3+
import javax.net.ssl.SSLPeerUnverifiedException;
44

5-
import java.security.GeneralSecurityException;
5+
import java.security.cert.CertPathBuilderException;
6+
import java.security.cert.CertPathValidatorException;
7+
import java.security.cert.CertificateException;
68

79
/**
810
* Classifies a failure into one of two regimes: {@link #NORMAL} or
@@ -34,8 +36,10 @@ public enum FailureClass {
3436
*/
3537
static boolean hasTlsOrCertificateCause(Throwable t) {
3638
for (Throwable c = t; c != null; c = c.getCause()) {
37-
if (c instanceof SSLException
38-
|| c instanceof GeneralSecurityException) {
39+
if (c instanceof CertificateException
40+
|| c instanceof CertPathValidatorException
41+
|| c instanceof CertPathBuilderException
42+
|| c instanceof SSLPeerUnverifiedException) {
3943
return true;
4044
}
4145
}

‎lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/http/HttpErrorsClassificationTest.java‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
import org.junit.Test;
44

5+
import javax.net.ssl.SSLException;
56
import javax.net.ssl.SSLHandshakeException;
67
import javax.net.ssl.SSLPeerUnverifiedException;
78

9+
import java.io.EOFException;
810
import java.io.IOException;
911
import java.net.ConnectException;
1012
import java.net.SocketTimeoutException;
13+
import java.security.cert.CertPathBuilderException;
14+
import java.security.cert.CertPathValidatorException;
1115
import java.security.cert.CertificateException;
1216
import java.security.cert.CertificateExpiredException;
17+
import java.security.cert.CertificateNotYetValidException;
1318

1419
import static com.launchdarkly.sdk.internal.http.FailureClass.NORMAL;
1520
import static com.launchdarkly.sdk.internal.http.FailureClass.UNEXPECTED;
@@ -57,9 +62,6 @@ public class HttpErrorsClassificationTest {
5762
}
5863

5964
// TLS / certificate validation failures are UNEXPECTED.
60-
@Test public void sslHandshakeIsUnexpected() {
61-
assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new SSLHandshakeException("handshake failed")));
62-
}
6365
@Test public void sslPeerUnverifiedIsUnexpected() {
6466
assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new SSLPeerUnverifiedException("peer not verified")));
6567
}
@@ -69,10 +71,40 @@ public class HttpErrorsClassificationTest {
6971
@Test public void certificateExpiredIsUnexpected() {
7072
assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new CertificateExpiredException("expired")));
7173
}
74+
@Test public void certificateNotYetValidIsUnexpected() {
75+
assertEquals(UNEXPECTED,
76+
HttpErrors.classifyTransportFailure(new CertificateNotYetValidException("not yet valid")));
77+
}
78+
@Test public void certPathValidatorFailureIsUnexpected() {
79+
assertEquals(UNEXPECTED,
80+
HttpErrors.classifyTransportFailure(new CertPathValidatorException("path invalid")));
81+
}
82+
@Test public void certPathBuilderFailureIsUnexpected() {
83+
assertEquals(UNEXPECTED,
84+
HttpErrors.classifyTransportFailure(new CertPathBuilderException("cannot build path")));
85+
}
86+
87+
@Test public void untrustedChainWrappedInHandshakeExceptionIsUnexpected() {
88+
SSLHandshakeException e = new SSLHandshakeException("PKIX path building failed");
89+
e.initCause(new CertPathBuilderException("unable to find valid certification path"));
90+
assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(e));
91+
}
92+
93+
@Test public void bareSslHandshakeFailureIsNormal() {
94+
assertEquals(NORMAL, HttpErrors.classifyTransportFailure(new SSLHandshakeException("handshake failed")));
95+
}
96+
@Test public void peerClosedMidHandshakeIsNormal() {
97+
SSLHandshakeException e = new SSLHandshakeException("Remote host terminated the handshake");
98+
e.initCause(new EOFException("SSL peer shut down incorrectly"));
99+
assertEquals(NORMAL, HttpErrors.classifyTransportFailure(e));
100+
}
101+
@Test public void sslExceptionFromConnectionResetIsNormal() {
102+
assertEquals(NORMAL, HttpErrors.classifyTransportFailure(new SSLException("Connection reset")));
103+
}
72104

73105
// Cause-chain walk finds TLS deep in wrapper exceptions.
74-
@Test public void sslCauseWrappedIsUnexpected() {
75-
IOException wrapper = new IOException("wrapped", new SSLHandshakeException("real cause"));
106+
@Test public void certificateCauseWrappedIsUnexpected() {
107+
IOException wrapper = new IOException("wrapped", new CertificateException("real cause"));
76108
assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(wrapper));
77109
}
78110
}

0 commit comments

Comments
 (0)