Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

```groovy
dependencies {
implementation 'cloud.eppo:eppo-server-sdk:5.3.3'
implementation 'cloud.eppo:eppo-server-sdk:6.0.0'
}
Comment on lines 10 to 13
```

Expand Down Expand Up @@ -58,6 +58,6 @@ repositories {
}

dependencies {
implementation 'cloud.eppo:eppo-server-sdk:4.0.1-SNAPSHOT'
implementation 'cloud.eppo:eppo-server-sdk:6.0.0-SNAPSHOT'
}
```
7 changes: 3 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 = '6.0.0-SNAPSHOT'
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,6 @@ dependencies {

// Logback classic 1.3.x is compatible with java 8 - only needed for tests
testImplementation 'ch.qos.logback:logback-classic:1.3.16'
testImplementation 'cloud.eppo:sdk-common-jvm:3.5.4: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
8 changes: 5 additions & 3 deletions src/main/java/cloud/eppo/EppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import cloud.eppo.cache.LRUInMemoryAssignmentCache;
import cloud.eppo.logging.AssignmentLogger;
import cloud.eppo.logging.BanditLogger;
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 +20,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 Down Expand Up @@ -50,7 +51,6 @@ private EppoClient(
sdkKey,
sdkName,
sdkVersion,
null,
baseUrl,
assignmentLogger,
banditLogger,
Expand All @@ -60,7 +60,9 @@ private EppoClient(
true,
null,
assignmentCache,
banditAssignmentCache);
banditAssignmentCache,
new JacksonConfigurationParser(),
new OkHttpEppoClient());
Comment on lines +64 to +65
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

/**
Expand Down
134 changes: 58 additions & 76 deletions src/test/java/cloud/eppo/EppoClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,21 @@
import cloud.eppo.api.BanditActions;
import cloud.eppo.api.BanditResult;
import cloud.eppo.api.Configuration;
import cloud.eppo.api.dto.VariationType;
import cloud.eppo.helpers.AssignmentTestCase;
import cloud.eppo.helpers.BanditTestCase;
import cloud.eppo.helpers.TestUtils;
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 com.github.tomakehurst.wiremock.stubbing.Scenario;
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;
Expand Down Expand Up @@ -60,7 +58,10 @@ public class EppoClientTest {
public static void initMockServer() {
mockServer = new WireMockServer(TEST_PORT);
mockServer.start();
registerDefaultStubs();
}

private static void registerDefaultStubs() {
// If we get the dummy flag API key, return flags-v1.json
String ufcFlagsResponseJson = readConfig("src/test/resources/shared/ufc/flags-v1.json");
mockServer.stubFor(
Expand Down Expand Up @@ -97,12 +98,13 @@ private static String readConfig(String jsonToReturnFilePath) {

@AfterEach
public void cleanUp() {
TestUtils.setBaseClientHttpClientOverrideField(null);
try {
EppoClient.getInstance().stopPolling();
} catch (IllegalStateException ex) {
// pass: Indicates that the singleton Eppo Client has not yet been initialized.
}
mockServer.resetAll();
registerDefaultStubs();
}

@AfterAll
Expand Down Expand Up @@ -224,37 +226,32 @@ 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);
// Reset request journal so we can count from zero
mockServer.resetRequests();

EppoClient.builder(DUMMY_FLAG_API_KEY)
.apiBaseUrl(TEST_HOST)
.pollingIntervalMs(20)
.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);
// Wait to allow polling cycles
sleepUninterruptedly(50);

// Now, the method should have been called twice
verify(httpClientSpy, times(2)).get(anyString());
// Verify multiple requests were made (init + at least one poll)
mockServer.verify(
com.github.tomakehurst.wiremock.client.WireMock.moreThanOrExactly(2),
WireMock.getRequestedFor(WireMock.urlMatching(".*flag-config/v1/config.*")));

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.
Expand All @@ -277,59 +274,45 @@ 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);
// Stub first response: empty config
mockServer.stubFor(
WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*"))
.inScenario("config-change")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(WireMock.okJson(new String(EMPTY_CONFIG)))
.willSetStateTo("has-config"));

setBaseClientHttpClientOverrideField(mockHttpClient);
// Stub second response: real config
mockServer.stubFor(
WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*"))
.inScenario("config-change")
.whenScenarioStateIs("has-config")
.willReturn(WireMock.okJson(new String(BOOL_FLAG_CONFIG))));

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

// Initialize and no exception should be thrown.
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);

// 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());
}

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);
mockServer.stubFor(
WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*"))
.willReturn(WireMock.serverError()));
mockServer.stubFor(
WireMock.get(WireMock.urlMatching(".*flag-config/v1/bandits.*"))
.willReturn(WireMock.serverError()));
}

@SuppressWarnings("SameParameterValue")
Expand All @@ -346,7 +329,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 All @@ -359,7 +342,7 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) {
mockBanditLogger = mock(BanditLogger.class);

return EppoClient.builder(DUMMY_FLAG_API_KEY)
.apiBaseUrl("blag")
.apiBaseUrl(TEST_HOST)
.assignmentLogger(mockAssignmentLogger)
.banditLogger(mockBanditLogger)
.isGracefulMode(isGracefulMode)
Expand All @@ -369,9 +352,9 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) {

private void uninitClient() {
try {
Field httpClientOverrideField = EppoClient.class.getDeclaredField("instance");
httpClientOverrideField.setAccessible(true);
httpClientOverrideField.set(null, null);
Field instanceField = EppoClient.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
Expand All @@ -380,21 +363,20 @@ private void uninitClient() {
private void initBuggyClient() {
try {
EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY);

// Create a mock IConfigurationStore that returns a mock Configuration.
// The mock Configuration throws on getFlag() to simulate evaluation errors,
// but returns null for getEnvironmentName()/getConfigFetchedAt()/getConfigPublishedAt()
// so the catch block in BaseEppoClient can build error details.
Configuration mockConfig = mock(Configuration.class);
when(mockConfig.getFlag(anyString()))
.thenThrow(new RuntimeException("Intentional test error"));
IConfigurationStore mockStore = mock(IConfigurationStore.class);
when(mockStore.getConfiguration()).thenReturn(mockConfig);

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);
configurationStoreField.set(eppoClient, mockStore);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
Expand Down
Loading
Loading