Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -26,7 +27,7 @@ public HttpErrorException(int status) {
super("HTTP error " + status);
this.status = status;
}

/**
* Returns the status code.
* @return the status code
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -81,15 +95,105 @@ public static boolean checkIfErrorIsRecoverableAndLog(
return true;
}
}

/**
* Returns a text description of an HTTP error.
*
*
* @param statusCode the status code
* @return the error description
*/
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
*/
Comment thread
tanderson-ld marked this conversation as resolved.
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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading