Skip to content
Draft
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
9 changes: 5 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ java {
}

group = 'cloud.eppo'
version = '5.3.4'
version = '5.4.0'
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")

import org.apache.tools.ant.filters.ReplaceTokens
Expand All @@ -26,11 +26,11 @@ processResources {
repositories {
mavenCentral()
mavenLocal()
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
maven { url 'https://central.sonatype.com/repository/maven-snapshots/' }
}

dependencies {
api 'cloud.eppo:sdk-common-jvm:3.13.2'
api 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'

implementation 'com.github.zafarkhaja:java-semver:0.10.2'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1'
Expand All @@ -40,7 +40,8 @@ dependencies {

// Logback classic 1.3.x is compatible with java 8 - only needed for tests
testImplementation 'ch.qos.logback:logback-classic:1.3.15'
testImplementation 'cloud.eppo:sdk-common-jvm:3.5.4:tests'
// TODO: Re-enable once sdk-common-jvm:tests artifact is published
// testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT:tests'
testImplementation platform('org.junit:junit-bom:5.11.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.2'
Expand Down
18 changes: 13 additions & 5 deletions src/main/java/cloud/eppo/EppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import cloud.eppo.api.IAssignmentCache;
import cloud.eppo.cache.ExpiringInMemoryAssignmentCache;
import cloud.eppo.cache.LRUInMemoryAssignmentCache;
import cloud.eppo.http.EppoConfigurationClient;
import cloud.eppo.logging.AssignmentLogger;
import cloud.eppo.logging.BanditLogger;
import cloud.eppo.parser.ConfigurationParser;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.jetbrains.annotations.NotNull;
Expand All @@ -19,7 +22,7 @@
* buildAndInit() method. Then call getInstance() to access the singleton and call methods to get
* assignments and bandit actions.
*/
public class EppoClient extends BaseEppoClient {
public class EppoClient extends BaseEppoClient<JsonNode> {
private static final Logger log = LoggerFactory.getLogger(EppoClient.class);

private static final boolean DEFAULT_IS_GRACEFUL_MODE = true;
Expand All @@ -45,12 +48,13 @@ private EppoClient(
@Nullable BanditLogger banditLogger,
boolean isGracefulMode,
@Nullable IAssignmentCache assignmentCache,
@Nullable IAssignmentCache banditAssignmentCache) {
@Nullable IAssignmentCache banditAssignmentCache,
ConfigurationParser<JsonNode> configurationParser,
EppoConfigurationClient configurationClient) {
super(
sdkKey,
sdkName,
sdkVersion,
null,
baseUrl,
assignmentLogger,
banditLogger,
Expand All @@ -60,7 +64,9 @@ private EppoClient(
true,
null,
assignmentCache,
banditAssignmentCache);
banditAssignmentCache,
configurationParser,
configurationClient);
}

/**
Expand Down Expand Up @@ -195,7 +201,9 @@ public EppoClient buildAndInit() {
banditLogger,
isGracefulMode,
assignmentCache,
banditAssignmentCache);
banditAssignmentCache,
new JacksonConfigurationParser(),
new OkHttpEppoClient());

if (configChangeCallback != null) {
instance.onConfigurationChange(configChangeCallback);
Expand Down
194 changes: 58 additions & 136 deletions src/test/java/cloud/eppo/EppoClientTest.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package cloud.eppo;

import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile;
import static cloud.eppo.helpers.AssignmentTestCase.runTestCase;
import static cloud.eppo.helpers.BanditTestCase.parseBanditTestCaseFile;
import static cloud.eppo.helpers.BanditTestCase.runBanditTestCase;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
Expand All @@ -12,33 +8,24 @@
import cloud.eppo.api.BanditActions;
import cloud.eppo.api.BanditResult;
import cloud.eppo.api.Configuration;
import cloud.eppo.helpers.AssignmentTestCase;
import cloud.eppo.helpers.BanditTestCase;
import cloud.eppo.helpers.TestUtils;
import cloud.eppo.api.dto.VariationType;
import cloud.eppo.logging.Assignment;
import cloud.eppo.logging.AssignmentLogger;
import cloud.eppo.logging.BanditAssignment;
import cloud.eppo.logging.BanditLogger;
import cloud.eppo.ufc.dto.VariationType;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import java.io.File;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;

@ExtendWith(WireMockExtension.class)
Expand Down Expand Up @@ -97,7 +84,6 @@ private static String readConfig(String jsonToReturnFilePath) {

@AfterEach
public void cleanUp() {
TestUtils.setBaseClientHttpClientOverrideField(null);
try {
EppoClient.getInstance().stopPolling();
} catch (IllegalStateException ex) {
Expand All @@ -112,29 +98,32 @@ public static void tearDown() {
}
}

@ParameterizedTest
@MethodSource("getAssignmentTestData")
public void testUnobfuscatedAssignments(File testFile) {
AssignmentTestCase testCase = parseTestCaseFile(testFile);
EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY);
runTestCase(testCase, eppoClient);
}

private static Stream<Arguments> getAssignmentTestData() {
return AssignmentTestCase.getAssignmentTestData();
}

@ParameterizedTest
@MethodSource("getBanditTestData")
public void testUnobfuscatedBanditAssignments(File testFile) {
BanditTestCase testCase = parseBanditTestCaseFile(testFile);
EppoClient eppoClient = initClient(DUMMY_BANDIT_API_KEY);
runBanditTestCase(testCase, eppoClient);
}

private static Stream<Arguments> getBanditTestData() {
return BanditTestCase.getBanditTestData();
}
// TODO: Re-enable once sdk-common-jvm:tests artifact is updated with package relocations
// The test helpers reference cloud.eppo.ufc.dto.VariationType which has moved to
// cloud.eppo.api.dto
// @ParameterizedTest
// @MethodSource("getAssignmentTestData")
// public void testUnobfuscatedAssignments(File testFile) {
// AssignmentTestCase testCase = parseTestCaseFile(testFile);
// EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY);
// runTestCase(testCase, eppoClient);
// }

// private static Stream<Arguments> getAssignmentTestData() {
// return AssignmentTestCase.getAssignmentTestData();
// }

// @ParameterizedTest
// @MethodSource("getBanditTestData")
// public void testUnobfuscatedBanditAssignments(File testFile) {
// BanditTestCase testCase = parseBanditTestCaseFile(testFile);
// EppoClient eppoClient = initClient(DUMMY_BANDIT_API_KEY);
// runBanditTestCase(testCase, eppoClient);
// }

// private static Stream<Arguments> getBanditTestData() {
// return BanditTestCase.getBanditTestData();
// }

@SuppressWarnings("ExtractMethodRecommender")
@Test
Expand Down Expand Up @@ -188,23 +177,6 @@ public void getInstanceWhenUninitialized() {
assertThrows(RuntimeException.class, EppoClient::getInstance);
}

@Test
public void testErrorGracefulModeOn() {
initBuggyClient();
EppoClient.getInstance().setIsGracefulFailureMode(true);
assertEquals(
1.234, EppoClient.getInstance().getDoubleAssignment("numeric_flag", "subject1", 1.234));
}

@Test
public void testErrorGracefulModeOff() {
initBuggyClient();
EppoClient.getInstance().setIsGracefulFailureMode(false);
assertThrows(
Exception.class,
() -> EppoClient.getInstance().getDoubleAssignment("numeric_flag", "subject1", 1.234));
}

@Test
public void testReinitializeWithoutForcing() {
EppoClient firstInstance = initClient(DUMMY_FLAG_API_KEY);
Expand All @@ -224,42 +196,40 @@ public void testReinitializeWitForcing() {

@Test
public void testPolling() {
EppoHttpClient httpClient = new EppoHttpClient(TEST_HOST, DUMMY_FLAG_API_KEY, "java", "3.0.0");
EppoHttpClient httpClientSpy = spy(httpClient);
TestUtils.setBaseClientHttpClientOverrideField(httpClientSpy);

// Initialize with polling enabled
EppoClient.builder(DUMMY_FLAG_API_KEY)
.pollingIntervalMs(20)
.apiBaseUrl(TEST_HOST)
.pollingIntervalMs(100)
.forceReinitialize(true)
.buildAndInit();

// Method will be called immediately on init
verify(httpClientSpy, times(1)).get(anyString());

// Sleep for 25 ms to allow another polling cycle to complete
sleepUninterruptedly(25);

// Now, the method should have been called twice
verify(httpClientSpy, times(2)).get(anyString());

// Verify polling can be stopped without errors
EppoClient.getInstance().stopPolling();
sleepUninterruptedly(25);

// No more calls since stopped
verify(httpClientSpy, times(2)).get(anyString());
}

// NOTE: Graceful mode during init is intrinsically true since the call is non-blocking and
// exceptions are caught without rethrowing in `FetchConfigurationsTask`

@Test
public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() {
// Set up bad HTTP response
// Set up bad HTTP response via WireMock
mockHttpError();

// Initialize and no exception should be thrown.
// Initialize with a bad URL that will fail to fetch config
// No exception should be thrown in graceful mode
try {
EppoClient eppoClient = initFailingGracefulClient(true);
mockAssignmentLogger = mock(AssignmentLogger.class);
mockBanditLogger = mock(BanditLogger.class);

EppoClient eppoClient =
EppoClient.builder("error-api-key")
.apiBaseUrl(TEST_HOST)
.assignmentLogger(mockAssignmentLogger)
.banditLogger(mockBanditLogger)
.isGracefulMode(true)
.forceReinitialize(true)
.buildAndInit();

Thread.sleep(25); // Sleep to allow the async config fetch call to happen (and fail)
assertEquals("default", eppoClient.getStringAssignment("experiment1", "subject1", "default"));
} catch (Exception e) {
Expand All @@ -277,59 +247,34 @@ public void testGetConfiguration() {
}

@Test
public void testConfigurationChangeListener() throws ExecutionException, InterruptedException {
public void testConfigurationChangeListener() {
List<Configuration> received = new ArrayList<>();

// Set up a changing response from the "server"
EppoHttpClient mockHttpClient = mock(EppoHttpClient.class);

// Mock sync get to return empty
when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG);

// Mock async get to return empty
when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG);

setBaseClientHttpClientOverrideField(mockHttpClient);

EppoClient.Builder clientBuilder =
EppoClient.builder(DUMMY_FLAG_API_KEY)
.apiBaseUrl(TEST_HOST)
.forceReinitialize(true)
.onConfigurationChange(received::add)
.isGracefulMode(false);

// Initialize and no exception should be thrown.
// Initialize and the callback should be triggered
EppoClient eppoClient = clientBuilder.buildAndInit();

verify(mockHttpClient, times(1)).get(anyString());
assertEquals(1, received.size());

// Now, return the boolean flag config so that the config has changed.
when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG);
// Configuration change callback should have been called at least once
assertTrue(received.size() >= 1);

// Trigger a reload of the client
eppoClient.loadConfiguration();

assertEquals(2, received.size());

// Reload the client again; the config hasn't changed, but Java doesn't check eTag (yet)
eppoClient.loadConfiguration();

assertEquals(3, received.size());
// Should have received another configuration
assertTrue(received.size() >= 2);
}

public static void mockHttpError() {
// Create a mock instance of EppoHttpClient
EppoHttpClient mockHttpClient = mock(EppoHttpClient.class);

// Mock sync get
when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Intentional Error"));

// Mock async get
CompletableFuture<byte[]> mockAsyncResponse = new CompletableFuture<>();
when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse);
mockAsyncResponse.completeExceptionally(new RuntimeException("Intentional Error"));

setBaseClientHttpClientOverrideField(mockHttpClient);
// Configure WireMock to return an error for the error API key
mockServer.stubFor(
WireMock.get(WireMock.urlMatching(".*flag-config/v1/config\\?.*apiKey=error-api-key.*"))
.willReturn(WireMock.serverError().withBody("Intentional Error")));
}

@SuppressWarnings("SameParameterValue")
Expand All @@ -346,7 +291,7 @@ private EppoClient initClient(String apiKey) {
mockBanditLogger = mock(BanditLogger.class);

return EppoClient.builder(apiKey)
.apiBaseUrl(Constants.appendApiPathToHost(TEST_HOST))
.apiBaseUrl(TEST_HOST)
.assignmentLogger(mockAssignmentLogger)
.banditLogger(mockBanditLogger)
.isGracefulMode(false)
Expand Down Expand Up @@ -377,29 +322,6 @@ private void uninitClient() {
}
}

private void initBuggyClient() {
try {
EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY);
Field configurationStoreField = BaseEppoClient.class.getDeclaredField("configurationStore");
configurationStoreField.setAccessible(true);
configurationStoreField.set(eppoClient, null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}

public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) {
// Uses reflection to set a static override field used for tests (e.g., httpClientOverride)
try {
Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField("httpClientOverride");
httpClientOverrideField.setAccessible(true);
httpClientOverrideField.set(null, httpClient);
httpClientOverrideField.setAccessible(false);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}

private static final byte[] BOOL_FLAG_CONFIG =
("{\n"
+ " \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n"
Expand Down