diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index 67502ef8..49cc52a0 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "jackson": "2.11.2", "jsr305": "3.0.2", "launchdarklyJavaSdkCommon": "2.3.0", - "launchdarklyJavaSdkInternal": "1.12.2", + "launchdarklyJavaSdkInternal": "1.13.0", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "5.0.1", diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index cbc0fab9..78c2329a 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -32,6 +32,7 @@ public class TestService { "context-comparison", "context-type", "evaluation-hooks", + "event-delivery-cooldown", "event-gzip", "event-sampling", "filtering", diff --git a/lib/sdk/server/contract-tests/test-suppressions-fdv2.txt b/lib/sdk/server/contract-tests/test-suppressions-fdv2.txt index 9765a098..67388ff5 100644 --- a/lib/sdk/server/contract-tests/test-suppressions-fdv2.txt +++ b/lib/sdk/server/contract-tests/test-suppressions-fdv2.txt @@ -1,3 +1,8 @@ streaming/fdv2/recoverable fallback to secondary synchronizer streaming/fdv2/recoverable fallback with recovery streaming/fdv2/permanent fallback with recovery +# launchdarkly-java-sdk-internal's HttpErrors.classifyHttpFailure treats a 413 as an unexpected +# failure (it has no locally-unrecoverable classification), so a 413 opens a cooldown window +# instead of only dropping the oversized payload (SDK-3513 owns the fix and the removal of this +# suppression). +events/delivery cooldown/413 is dropped without disabling delivery diff --git a/lib/sdk/server/contract-tests/test-suppressions.txt b/lib/sdk/server/contract-tests/test-suppressions.txt index 8b137891..f2bb348b 100644 --- a/lib/sdk/server/contract-tests/test-suppressions.txt +++ b/lib/sdk/server/contract-tests/test-suppressions.txt @@ -1 +1,5 @@ - +# launchdarkly-java-sdk-internal's HttpErrors.classifyHttpFailure treats a 413 as an unexpected +# failure (it has no locally-unrecoverable classification), so a 413 opens a cooldown window +# instead of only dropping the oversized payload (SDK-3513 owns the fix and the removal of this +# suppression). +events/delivery cooldown/413 is dropped without disabling delivery diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index f808ebf7..56c6eccd 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -299,11 +299,11 @@ public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { private Result transformResult(com.launchdarkly.sdk.server.subsystems.EventSender.Result result) { switch (result) { case FAILURE: - return new Result(false, false, null); + return Result.failure(); case STOP: - return new Result(false, true, null); + return Result.unexpectedFailure(); default: - return new Result(true, false, null); + return Result.success(null); } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventSender.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventSender.java index 20b4e29f..bac4725f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventSender.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventSender.java @@ -29,8 +29,19 @@ public enum Result { FAILURE, /** - * The EventSender was not able to deliver the events, and the nature of the error indicates that - * the SDK should not attempt to send any more events. + * The EventSender was not able to deliver the events, and the nature of the error (such as an + * HTTP 401 error, implying that the SDK key is invalid) indicates that the SDK should stop + * attempting delivery for a while. + *

+ * The SDK responds by entering a bounded cooldown window. While the window is open, the SDK + * initiates no new analytics-event deliveries through this EventSender (a delivery already + * dispatched when the window opened still completes), and analytics events that would have + * been sent during the window are discarded rather than held. Diagnostic-event calls are not + * suppressed, and this result from one of them opens a window of its own. If delivery is + * still failing this way when a window elapses, the next window is longer, up to a ceiling; + * sustained successful delivery returns it to the initial length. Delivery resumes on its own + * once a window has elapsed; no application action is required, and event delivery is never + * disabled permanently. Returning {@link #FAILURE} instead never opens or lengthens a window. */ STOP }; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EventSenderWrapperTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EventSenderWrapperTest.java new file mode 100644 index 00000000..8b3084c9 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EventSenderWrapperTest.java @@ -0,0 +1,109 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.ComponentsImpl.EventProcessorBuilderImpl.EventSenderWrapper; +import com.launchdarkly.sdk.server.subsystems.EventSender; + +import org.junit.Test; + +import java.io.IOException; +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class EventSenderWrapperTest { + private static final URI FAKE_URI = URI.create("http://fake"); + private static final byte[] FAKE_DATA = new byte[] {1, 2, 3}; + + private static final class RecordingSender implements EventSender { + private final Result result; + String methodCalled; + byte[] receivedData; + int receivedEventCount = -1; + URI receivedUri; + + RecordingSender(Result result) { + this.result = result; + } + + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + methodCalled = "analytics"; + receivedData = data; + receivedEventCount = eventCount; + receivedUri = eventsBaseUri; + return result; + } + + @Override + public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + methodCalled = "diagnostic"; + receivedData = data; + receivedUri = eventsBaseUri; + return result; + } + + @Override + public void close() throws IOException {} + } + + @Test + public void successMapsToSuccessfulDelivery() { + RecordingSender sender = new RecordingSender(EventSender.Result.SUCCESS); + com.launchdarkly.sdk.internal.events.EventSender.Result result = + new EventSenderWrapper(sender).sendAnalyticsEvents(FAKE_DATA, 3, FAKE_URI); + + assertTrue(result.isSuccess()); + assertFalse(result.isUnexpectedFailure()); + assertNull(result.getTimeFromServer()); + + // The wrapper routes to the analytics method with the arguments unchanged. + assertEquals("analytics", sender.methodCalled); + assertSame(FAKE_DATA, sender.receivedData); + assertEquals(3, sender.receivedEventCount); + assertSame(FAKE_URI, sender.receivedUri); + } + + @Test + public void failureMapsToOrdinaryFailure() { + RecordingSender sender = new RecordingSender(EventSender.Result.FAILURE); + com.launchdarkly.sdk.internal.events.EventSender.Result result = + new EventSenderWrapper(sender).sendAnalyticsEvents(FAKE_DATA, 1, FAKE_URI); + + assertFalse(result.isSuccess()); + assertFalse(result.isUnexpectedFailure()); + } + + @SuppressWarnings("deprecation") + @Test + public void stopMapsToUnexpectedFailure() { + RecordingSender sender = new RecordingSender(EventSender.Result.STOP); + com.launchdarkly.sdk.internal.events.EventSender.Result result = + new EventSenderWrapper(sender).sendAnalyticsEvents(FAKE_DATA, 1, FAKE_URI); + + assertFalse(result.isSuccess()); + assertTrue(result.isUnexpectedFailure()); + // The legacy flag stays written for consumers that have not migrated off it. + assertTrue(result.isMustShutDown()); + } + + @Test + public void diagnosticPathUsesTheSameMappingAndRouting() { + RecordingSender stopSender = new RecordingSender(EventSender.Result.STOP); + com.launchdarkly.sdk.internal.events.EventSender.Result stopResult = + new EventSenderWrapper(stopSender).sendDiagnosticEvent(FAKE_DATA, FAKE_URI); + assertTrue(stopResult.isUnexpectedFailure()); + assertEquals("diagnostic", stopSender.methodCalled); + assertSame(FAKE_DATA, stopSender.receivedData); + assertSame(FAKE_URI, stopSender.receivedUri); + + RecordingSender successSender = new RecordingSender(EventSender.Result.SUCCESS); + assertTrue(new EventSenderWrapper(successSender) + .sendDiagnosticEvent(FAKE_DATA, FAKE_URI).isSuccess()); + assertEquals("diagnostic", successSender.methodCalled); + } +}