diff --git a/README.md b/README.md index 9fd32ef..013e1f8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,59 @@ dependencies { Refer to our [SDK documentation](https://docs.geteppo.com/sdks/server-sdks/java/) for how to install and use the SDK. +## Upgrading to 6.0.0 + +Version 6.0.0 adopts common SDK 4.0.2. The common SDK is exposed as an API +dependency, so applications that use its types must account for these breaking +changes: + +- Update imports of the flag and bandit DTOs, for example `VariationType`, + `FlagConfig` and `BanditParameters`, from `cloud.eppo.ufc.dto` to + `cloud.eppo.api.dto`. `cloud.eppo.model.ShardRange` moves to + `cloud.eppo.api.dto.ShardRange`. `cloud.eppo.ufc.dto.adapters.EppoModule` keeps + its package. +- `Configuration.builder(byte[])`, `Configuration.builder(byte[], boolean)`, + `Configuration.serializeFlagConfigToBytes()`, + `Configuration.serializeBanditParamsToBytes()`, + `Attributes.serializeNonNullAttributesToJSONString()` and + `cloud.eppo.exception.InvalidApiKeyException` have been removed. To build a + `Configuration` from flag JSON, use `JacksonConfigurationParser`: + + ```java + JacksonConfigurationParser parser = new JacksonConfigurationParser(); + Configuration config = parser.buildConfig(parser.parseFlagConfig(flagJson), null, null, null); + ``` +- `cloud.eppo.EppoHttpClient` has been removed. To supply your own HTTP client, + implement `cloud.eppo.http.EppoConfigurationClient` and pass it to + `EppoClient.builder(sdkKey).configurationClient(...)`. The default is + `OkHttpEppoClient`; pass `new OkHttpEppoClient(okHttpClient)` to reuse your own + OkHttp settings. + +Version 6.0.0 adds typed `get*AssignmentDetails()` methods and +`getBanditActionDetails()` for inspecting evaluation results. + +Polling now sends `If-None-Match`. When the configuration has not changed (HTTP +304), the client keeps its configuration and does not call `onConfigurationChange` +callbacks. 5.x called them after every successful poll. + +Starting in common 4.0.0, strict mode (graceful mode disabled) throws when a JSON +flag contains malformed JSON. Common 3.13.2 returned the supplied non-null default +for this case. Graceful mode continues to return the default. This applies to both +parsed JSON and JSON-string assignments. Common 4.0.1 fixes successful JSON-string +assignments while retaining this strict-mode behaviour. + +Common 4.0.2 changes how the client handles bandit failures: + +- A failed bandit fetch no longer blocks flag updates. The client applies the new + flags, keeps its loaded bandit models, and retries the bandits on the next poll. +- When bandit evaluation fails, for example because the models are not loaded yet, + graceful mode (the default) makes `getBanditAction` return the default value with + a null action, and `getBanditActionDetails` reports evaluation code `BANDIT_ERROR`. + Common 3.13.2 returned the bandit variation. The assignment logger has already + recorded the flag's bandit variation for that call, so the logged variation can + differ from the value your code receives. With graceful mode disabled, both + methods throw, as in 5.x. + ## Contributing Java 8 is required to locally compile the SDK. @@ -48,7 +101,8 @@ Once you have the prerequisites, follow the steps below to release a new version If you would like to live on the bleeding edge, you can try running against a snapshot build. Keep in mind that snapshots represent the most recent changes on master and may contain bugs. -Snapshots are published automatically after each push to `main` branch. +Snapshots are published automatically after each push to the `main` branch while `build.gradle` has a +`-SNAPSHOT` version. ### build.gradle: @@ -58,6 +112,6 @@ repositories { } dependencies { - implementation 'cloud.eppo:eppo-server-sdk:6.0.0-SNAPSHOT' + implementation 'cloud.eppo:eppo-server-sdk:X.Y.Z-SNAPSHOT' } ``` diff --git a/build.gradle b/build.gradle index 04fec14..6649bd0 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ java { } group = 'cloud.eppo' -version = '6.0.0-SNAPSHOT' +version = '6.0.0' ext.isReleaseVersion = !version.endsWith("SNAPSHOT") import org.apache.tools.ant.filters.ReplaceTokens @@ -30,7 +30,7 @@ repositories { } dependencies { - api 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' + api 'cloud.eppo:sdk-common-jvm:4.0.2' implementation 'com.github.zafarkhaja:java-semver:0.10.2' implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' @@ -39,7 +39,7 @@ dependencies { implementation 'org.jetbrains:annotations:26.1.0' // Logback classic 1.3.x is compatible with java 8 - only needed for tests - testImplementation 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT:tests' + testImplementation 'cloud.eppo:eppo-sdk-framework:0.1.2:tests' testImplementation 'ch.qos.logback:logback-classic:1.3.16' testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/src/main/java/cloud/eppo/EppoClient.java b/src/main/java/cloud/eppo/EppoClient.java index b99c473..e366039 100644 --- a/src/main/java/cloud/eppo/EppoClient.java +++ b/src/main/java/cloud/eppo/EppoClient.java @@ -4,6 +4,7 @@ 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 com.fasterxml.jackson.databind.JsonNode; @@ -46,7 +47,8 @@ private EppoClient( @Nullable BanditLogger banditLogger, boolean isGracefulMode, @Nullable IAssignmentCache assignmentCache, - @Nullable IAssignmentCache banditAssignmentCache) { + @Nullable IAssignmentCache banditAssignmentCache, + @Nullable EppoConfigurationClient configurationClient) { super( sdkKey, sdkName, @@ -62,7 +64,7 @@ private EppoClient( assignmentCache, banditAssignmentCache, new JacksonConfigurationParser(), - new OkHttpEppoClient()); + configurationClient != null ? configurationClient : new OkHttpEppoClient()); } /** @@ -84,6 +86,7 @@ public static class Builder { private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; private String apiBaseUrl = null; @Nullable private Consumer configChangeCallback; + @Nullable private EppoConfigurationClient configurationClient; // Assignment and bandit caching on by default. To disable, call // `builder.assignmentCache(null).banditAssignmentCache(null);` @@ -169,6 +172,17 @@ public Builder onConfigurationChange(Consumer configChangeCallbac return this; } + /** + * Supplies the HTTP client that fetches configurations, for example to add custom networking, + * authentication or logging. The default is an {@link OkHttpEppoClient}; to reuse your own + * OkHttp settings, pass {@code new OkHttpEppoClient(okHttpClient)}. See {@link + * EppoConfigurationClient} for the contract. + */ + public Builder configurationClient(@NotNull EppoConfigurationClient configurationClient) { + this.configurationClient = configurationClient; + return this; + } + public EppoClient buildAndInit() { AppDetails appDetails = AppDetails.getInstance(); String sdkName = appDetails.getName(); @@ -197,7 +211,8 @@ public EppoClient buildAndInit() { banditLogger, isGracefulMode, assignmentCache, - banditAssignmentCache); + banditAssignmentCache, + configurationClient); if (configChangeCallback != null) { instance.onConfigurationChange(configChangeCallback); diff --git a/src/test/java/cloud/eppo/EppoClientTest.java b/src/test/java/cloud/eppo/EppoClientTest.java index 85e0d55..1e18bc8 100644 --- a/src/test/java/cloud/eppo/EppoClientTest.java +++ b/src/test/java/cloud/eppo/EppoClientTest.java @@ -2,6 +2,7 @@ import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile; import static cloud.eppo.helpers.AssignmentTestCase.runTestCase; +import static cloud.eppo.helpers.AssignmentTestCase.runTestCaseWithDetails; import static cloud.eppo.helpers.BanditTestCase.parseBanditTestCaseFile; import static cloud.eppo.helpers.BanditTestCase.runBanditTestCase; import static org.junit.jupiter.api.Assertions.*; @@ -122,6 +123,14 @@ public void testUnobfuscatedAssignments(File testFile) { runTestCase(testCase, eppoClient); } + @ParameterizedTest + @MethodSource("getAssignmentTestData") + public void testUnobfuscatedAssignmentsWithDetails(File testFile) { + AssignmentTestCase testCase = parseTestCaseFile(testFile); + EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY); + runTestCaseWithDetails(testCase, eppoClient); + } + private static Stream getAssignmentTestData() { return AssignmentTestCase.getAssignmentTestData(); } @@ -306,6 +315,88 @@ public void testConfigurationChangeListener() { assertEquals(2, received.size()); } + // The first bandit fetch fails. The flags still apply, bandit flags return the default with + // BANDIT_ERROR, and the next poll sends no ETag, retries the bandits and serves the bandit. + @Test + public void testBanditFetchFailureAppliesFlagsAndRetriesOnNextPoll() { + String flags = readConfig("src/test/resources/shared/ufc/bandit-flags-v1.json"); + String models = readConfig("src/test/resources/shared/ufc/bandit-models-v1.json"); + mockServer.resetAll(); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .withHeader("If-None-Match", WireMock.equalTo("\"v1\"")) + .willReturn(WireMock.aResponse().withStatus(304))); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .withHeader("If-None-Match", WireMock.absent()) + .willReturn(WireMock.okJson(flags).withHeader("ETag", "\"v1\""))); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/bandits.*")) + .inScenario("bandits") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(WireMock.serverError()) + .willSetStateTo("up")); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/bandits.*")) + .inScenario("bandits") + .whenScenarioStateIs("up") + .willReturn(WireMock.okJson(models))); + mockAssignmentLogger = mock(AssignmentLogger.class); + mockBanditLogger = mock(BanditLogger.class); + EppoClient client = + EppoClient.builder(DUMMY_BANDIT_API_KEY) + .apiBaseUrl(TEST_HOST) + .assignmentLogger(mockAssignmentLogger) + .banditLogger(mockBanditLogger) + .pollingIntervalMs(0) + .forceReinitialize(true) + .buildAndInit(); + Attributes bob = new Attributes(); + bob.put("age", 25); + bob.put("country", "USA"); + BanditActions actions = new BanditActions(); + actions.put("nike", new Attributes()); + actions.put("adidas", new Attributes()); + + assertNotNull(client.getConfiguration().getFlag("banner_bandit_flag")); + BanditResult fallback = + client.getBanditAction("banner_bandit_flag", "bob", bob, actions, "control"); + assertEquals("control", fallback.getVariation()); + assertNull(fallback.getAction()); + assertEquals( + cloud.eppo.api.FlagEvaluationCode.BANDIT_ERROR, + client + .getBanditActionDetails("banner_bandit_flag", "bob", bob, actions, "control") + .getEvaluationDetails() + .getFlagEvaluationCode()); + verify(mockBanditLogger, never()).logBanditAssignment(any()); + + client.loadConfiguration(); + BanditResult recovered = + client.getBanditAction("banner_bandit_flag", "bob", bob, actions, "control"); + assertEquals("banner_bandit", recovered.getVariation()); + assertNotNull(recovered.getAction()); + } + + @Test + public void testCustomConfigurationClientIsUsed() { + OkHttpEppoClient delegate = new OkHttpEppoClient(); + List requestedPaths = new ArrayList<>(); + EppoClient eppoClient = + EppoClient.builder(DUMMY_FLAG_API_KEY) + .apiBaseUrl(TEST_HOST) + .configurationClient( + request -> { + requestedPaths.add(request.getResourcePath()); + return delegate.execute(request); + }) + .forceReinitialize(true) + .buildAndInit(); + + assertFalse(requestedPaths.isEmpty()); + assertNotNull(eppoClient.getConfiguration().getFlag("numeric_flag")); + } + public static void mockHttpError() { mockServer.stubFor( WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*"))