diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/FailureClass.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/FailureClass.java new file mode 100644 index 00000000..c678b718 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/FailureClass.java @@ -0,0 +1,44 @@ +package com.launchdarkly.sdk.internal.http; + +import javax.net.ssl.SSLException; + +import java.security.GeneralSecurityException; + +/** + * Classifies a failure into one of two regimes: {@link #NORMAL} or + * {@link #UNEXPECTED}. Used by data sources and other network-facing components + * to decide whether a failure should trigger extended-regime backoff. + *
+ * This class is for internal use only and should not be documented in the SDK API. + * It is not supported for any use outside of the LaunchDarkly SDKs, and is subject + * to change without notice. + */ +public enum FailureClass { + /** + * Ordinary transient failure. Use the normal-regime backoff. Includes HTTP + * 400 / 408 / 429, HTTP 5xx, any other HTTP status the SDK treats as a + * failure, and generic transport failures (connection refused, read timeout, + * DNS failure, etc.). + */ + NORMAL, + + /** + * Unexpected failure indicative of a longer-lived condition. Use the + * extended-regime backoff. Includes HTTP 401 / 403 and any other 4xx not in + * the NORMAL list, plus TLS / certificate validation failures. + */ + UNEXPECTED; + + /** + * Scans an exception chain for TLS / certificate validation causes. + */ + static boolean hasTlsOrCertificateCause(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException + || c instanceof GeneralSecurityException) { + return true; + } + } + return false; + } +} diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java index e99126ea..9d61b3f0 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.internal.http; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; /** * Contains shared helpers related to HTTP response validation. @@ -10,14 +11,14 @@ */ public abstract class HttpErrors { private HttpErrors() {} - + /** * Represents an HTTP response error as an exception. */ @SuppressWarnings("serial") public static final class HttpErrorException extends Exception { private final int status; - + /** * Constructs an instance. * @param status the status code @@ -26,7 +27,7 @@ public HttpErrorException(int status) { super("HTTP error " + status); this.status = status; } - + /** * Returns the status code. * @return the status code @@ -35,12 +36,18 @@ public int getStatus() { return status; } } - + /** * Tests whether an HTTP error status represents a condition that might resolve on its own if we retry. * @param statusCode the HTTP status * @return true if retrying makes sense; false if it should be considered a permanent failure + * + * @deprecated Prefer {@link #classifyHttpFailure(int)}, which returns a {@link FailureClass} + * that lets the caller distinguish an extended-regime backoff signal from an ordinary + * transient failure. This boolean method treats {@code false} as "give up permanently", + * which does not fit callers that keep retrying regardless of classification. */ + @Deprecated public static boolean isHttpErrorRecoverable(int statusCode) { if (statusCode >= 400 && statusCode < 500) { switch (statusCode) { @@ -54,18 +61,25 @@ public static boolean isHttpErrorRecoverable(int statusCode) { } return true; } - + /** * Logs an HTTP error or network error at the appropriate level and determines whether it is recoverable * (as defined by {@link #isHttpErrorRecoverable(int)}). - * + * * @param logger the logger to log to * @param errorDesc description of the error * @param errorContext a phrase like "when doing such-and-such" * @param statusCode HTTP status code, or 0 for a network error * @param recoverableMessage a phrase like "will retry" to use if the error is recoverable * @return true if the error is recoverable + * + * @deprecated Prefer {@link #classifyAndLogHttpFailure} and + * {@link #classifyAndLogTransportFailure}, which return a {@link FailureClass} that lets + * the caller distinguish an extended-regime backoff signal from an ordinary transient + * failure. This method treats a {@code false} return as "give up permanently", which does + * not fit callers that keep retrying regardless of classification. */ + @Deprecated public static boolean checkIfErrorIsRecoverableAndLog( LDLogger logger, String errorDesc, @@ -81,10 +95,10 @@ public static boolean checkIfErrorIsRecoverableAndLog( return true; } } - + /** * Returns a text description of an HTTP error. - * + * * @param statusCode the status code * @return the error description */ @@ -92,4 +106,94 @@ public static String httpErrorDescription(int statusCode) { return "HTTP error " + statusCode + (statusCode == 401 || statusCode == 403 ? " (invalid SDK key)" : ""); } + + /** + * Classifies an HTTP response by its status code. Returns + * {@link FailureClass#UNEXPECTED} for 401 / 403 and any other 4xx not in the NORMAL list; + * returns {@link FailureClass#NORMAL} for 400 / 408 / 429, 5xx, and any other status the SDK + * treats as a failure. + * + * @param statusCode the HTTP status code + * @return the classification + */ + public static FailureClass classifyHttpFailure(int statusCode) { + if (statusCode == 400 || statusCode == 408 || statusCode == 429) { + return FailureClass.NORMAL; + } + if (statusCode >= 500) { + return FailureClass.NORMAL; + } + if (statusCode >= 400 && statusCode < 500) { + return FailureClass.UNEXPECTED; + } + return FailureClass.NORMAL; + } + + /** + * Classifies a transport-level exception. TLS or certificate validation failures anywhere in + * the exception chain are {@link FailureClass#UNEXPECTED}; all other transport failures are + * {@link FailureClass#NORMAL}. + * + * @param t the transport-level exception + * @return the classification + */ + public static FailureClass classifyTransportFailure(Throwable t) { + return FailureClass.hasTlsOrCertificateCause(t) ? FailureClass.UNEXPECTED : FailureClass.NORMAL; + } + + /** + * Classifies an HTTP failure per {@link #classifyHttpFailure(int)}, logs it at the appropriate + * level, and returns the classification for the caller to act on. Unexpected classifications + * log at Error since they typically indicate a customer-side problem (invalid or expired SDK + * key, misconfiguration); normal classifications log at Warn since they are typically transient. + * + * @param logger the logger to log to + * @param statusCode the HTTP status + * @param errorContext a phrase like "in stream connection" or "on polling request" + * @param willRetryMessage a phrase like "will retry" or "will retry at next scheduled poll interval" + * @return the classification + */ + public static FailureClass classifyAndLogHttpFailure( + LDLogger logger, + int statusCode, + String errorContext, + String willRetryMessage + ) { + FailureClass failureClass = classifyHttpFailure(statusCode); + String errorDesc = httpErrorDescription(statusCode); + if (failureClass == FailureClass.UNEXPECTED) { + logger.error("Error {} ({}): {}", errorContext, willRetryMessage, errorDesc); + } else { + logger.warn("Error {} ({}): {}", errorContext, willRetryMessage, errorDesc); + } + return failureClass; + } + + /** + * Classifies a transport failure per {@link #classifyTransportFailure(Throwable)}, logs it at + * the appropriate level, and returns the classification. Unexpected classifications (TLS / + * certificate validation) log at Error since they typically indicate a customer-side problem + * (misconfigured trust store, expired cert); other transport failures log at Warn since they + * are typically transient. + * + * @param logger the logger to log to + * @param e the transport-level exception + * @param errorContext a phrase like "in stream connection" or "on polling request" + * @param willRetryMessage a phrase like "will retry" or "will retry at next scheduled poll interval" + * @return the classification + */ + public static FailureClass classifyAndLogTransportFailure( + LDLogger logger, + Throwable e, + String errorContext, + String willRetryMessage + ) { + FailureClass failureClass = classifyTransportFailure(e); + if (failureClass == FailureClass.UNEXPECTED) { + logger.error("Error {} ({}): {}", errorContext, willRetryMessage, LogValues.exceptionSummary(e)); + } else { + logger.warn("Error {} ({}): {}", errorContext, willRetryMessage, LogValues.exceptionSummary(e)); + } + return failureClass; + } } diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/http/HttpErrorsClassificationTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/http/HttpErrorsClassificationTest.java new file mode 100644 index 00000000..9b284b75 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/http/HttpErrorsClassificationTest.java @@ -0,0 +1,78 @@ +package com.launchdarkly.sdk.internal.http; + +import org.junit.Test; + +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; + +import static com.launchdarkly.sdk.internal.http.FailureClass.NORMAL; +import static com.launchdarkly.sdk.internal.http.FailureClass.UNEXPECTED; +import static org.junit.Assert.assertEquals; + +/** + * Unit coverage for {@link HttpErrors#classifyHttpFailure(int)} and + * {@link HttpErrors#classifyTransportFailure(Throwable)}. + */ +@SuppressWarnings("javadoc") +public class HttpErrorsClassificationTest { + + // 400, 408, 429 are NORMAL. + @Test public void http400IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(400)); } + @Test public void http408IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(408)); } + @Test public void http429IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(429)); } + + // Other 4xx (including 401, 403) is UNEXPECTED. + @Test public void http401IsUnexpected() { assertEquals(UNEXPECTED, HttpErrors.classifyHttpFailure(401)); } + @Test public void http403IsUnexpected() { assertEquals(UNEXPECTED, HttpErrors.classifyHttpFailure(403)); } + @Test public void http404IsUnexpected() { assertEquals(UNEXPECTED, HttpErrors.classifyHttpFailure(404)); } + @Test public void http418IsUnexpected() { assertEquals(UNEXPECTED, HttpErrors.classifyHttpFailure(418)); } + @Test public void http451IsUnexpected() { assertEquals(UNEXPECTED, HttpErrors.classifyHttpFailure(451)); } + + // 5xx is NORMAL. + @Test public void http500IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(500)); } + @Test public void http502IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(502)); } + @Test public void http503IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(503)); } + @Test public void http504IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(504)); } + @Test public void http599IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(599)); } + + // Unusual non-4xx / non-5xx failure statuses are NORMAL. + @Test public void http300IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(300)); } + @Test public void http0IsNormal() { assertEquals(NORMAL, HttpErrors.classifyHttpFailure(0)); } + + // Ordinary network I/O failures are NORMAL. + @Test public void connectExceptionIsNormal() { + assertEquals(NORMAL, HttpErrors.classifyTransportFailure(new ConnectException("connection refused"))); + } + @Test public void socketTimeoutIsNormal() { + assertEquals(NORMAL, HttpErrors.classifyTransportFailure(new SocketTimeoutException("timeout"))); + } + @Test public void ioExceptionIsNormal() { + assertEquals(NORMAL, HttpErrors.classifyTransportFailure(new IOException("something else"))); + } + + // TLS / certificate validation failures are UNEXPECTED. + @Test public void sslHandshakeIsUnexpected() { + assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new SSLHandshakeException("handshake failed"))); + } + @Test public void sslPeerUnverifiedIsUnexpected() { + assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new SSLPeerUnverifiedException("peer not verified"))); + } + @Test public void certificateExceptionIsUnexpected() { + assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new CertificateException("cert invalid"))); + } + @Test public void certificateExpiredIsUnexpected() { + assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(new CertificateExpiredException("expired"))); + } + + // Cause-chain walk finds TLS deep in wrapper exceptions. + @Test public void sslCauseWrappedIsUnexpected() { + IOException wrapper = new IOException("wrapped", new SSLHandshakeException("real cause")); + assertEquals(UNEXPECTED, HttpErrors.classifyTransportFailure(wrapper)); + } +}