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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group = 'cloud.eppo'
version = '3.10.1'
version = '4.0.0'
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")

java {
Expand All @@ -17,12 +17,12 @@ java {
}

dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3'
implementation 'com.github.zafarkhaja:java-semver:0.10.2'
implementation "com.squareup.okhttp3:okhttp:4.12.0"
// For LRU and expiring maps
implementation 'org.apache.commons:commons-collections4:4.5.0'
implementation 'org.slf4j:slf4j-api:2.0.17'
testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3'
testImplementation 'org.slf4j:slf4j-simple:2.0.17'
testImplementation platform('org.junit:junit-bom:5.11.4')
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
Expand Down
159 changes: 48 additions & 111 deletions src/main/java/cloud/eppo/BaseEppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,16 @@
import cloud.eppo.logging.BanditAssignment;
import cloud.eppo.logging.BanditLogger;
import cloud.eppo.ufc.dto.*;
import cloud.eppo.ufc.dto.adapters.EppoModule;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
import java.util.Timer;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class BaseEppoClient {
private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class);
private final ObjectMapper mapper =
new ObjectMapper()
.registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this?

protected final ConfigurationRequestor requestor;

Expand All @@ -38,62 +29,48 @@ public class BaseEppoClient {
private final BanditLogger banditLogger;
private final String sdkName;
private final String sdkVersion;
private boolean isGracefulMode;
protected boolean isGracefulMode;
private final IAssignmentCache assignmentCache;
private final IAssignmentCache banditAssignmentCache;
private Timer pollTimer;

@Nullable protected CompletableFuture<Boolean> getInitialConfigFuture() {
return initialConfigFuture;
}

private final CompletableFuture<Boolean> initialConfigFuture;

// Fields useful for testing in situations where we want to mock the http client or configuration
// store (accessed via reflection)
/** @noinspection FieldMayBeFinal */
private static EppoHttpClient httpClientOverride = null;
private static IEppoHttpClient httpClientOverride = null;

// It is important that the bandit assignment cache expire with a short-enough TTL to last about
// one user session.
// The recommended is 10 minutes (per @Sven)
/** @param host To be removed in v4. use `apiBaseUrl` instead. */
/** */
protected BaseEppoClient(
@NotNull String apiKey,
@NotNull String sdkKey,
@NotNull String sdkName,
@NotNull String sdkVersion,
@Deprecated @Nullable String host,
@Nullable String apiBaseUrl,
@Nullable AssignmentLogger assignmentLogger,
@Nullable BanditLogger banditLogger,
@Nullable IConfigurationStore configurationStore,
boolean isGracefulMode,
boolean expectObfuscatedConfig,
boolean supportBandits,
@Nullable CompletableFuture<Configuration> initialConfiguration,
@Nullable Configuration initialConfiguration,
@Nullable IAssignmentCache assignmentCache,
@Nullable IAssignmentCache banditAssignmentCache) {

if (apiBaseUrl == null) {
apiBaseUrl = host != null ? Constants.appendApiPathToHost(host) : Constants.DEFAULT_BASE_URL;
}

this.assignmentCache = assignmentCache;
this.banditAssignmentCache = banditAssignmentCache;

EppoHttpClient httpClient =
buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion);
IEppoHttpClient httpClient =
buildHttpClient(apiBaseUrl, new SDKKey(sdkKey), sdkName, sdkVersion);
this.configurationStore =
configurationStore != null ? configurationStore : new ConfigurationStore();

// For now, the configuration is only obfuscated for Android clients
requestor =
new ConfigurationRequestor(
this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits);
initialConfigFuture =
initialConfiguration != null
? requestor.setInitialConfiguration(initialConfiguration)
: null;
requestor = new ConfigurationRequestor(this.configurationStore, httpClient, supportBandits);

if (initialConfiguration != null) {
requestor.activateConfiguration(initialConfiguration);
}

this.assignmentLogger = assignmentLogger;
this.banditLogger = banditLogger;
Expand All @@ -103,7 +80,7 @@ protected BaseEppoClient(
this.sdkVersion = sdkVersion;
}

private EppoHttpClient buildHttpClient(
private IEppoHttpClient buildHttpClient(
String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) {
ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl);

Expand All @@ -112,13 +89,18 @@ private EppoHttpClient buildHttpClient(
: new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion);
}

protected void loadConfiguration() {
public void activateConfiguration(@NotNull Configuration configuration) {
requestor.activateConfiguration(configuration);
}

protected void fetchAndActivateConfiguration() {
try {
requestor.fetchAndSaveFromRemote();
} catch (Exception ex) {
log.error("Encountered Exception while loading configuration", ex);
if (!isGracefulMode) {
throw ex;
} catch (Throwable e) {
if (isGracefulMode) {
log.error(e.getMessage(), e);
} else {
throw e;
}
}
}
Expand Down Expand Up @@ -160,7 +142,14 @@ protected void startPolling(long pollingIntervalMs, long pollingJitterMs) {
new FetchConfigurationTask(
() -> {
log.debug("[Eppo SDK] Polling callback");
this.loadConfiguration();
try {
this.fetchAndActivateConfiguration();
} catch (Exception ex) {
log.error("Encountered Exception while loading configuration", ex);
if (!isGracefulMode) {
throw ex;
}
}
},
pollTimer,
pollingIntervalMs,
Expand All @@ -172,22 +161,21 @@ protected void startPolling(long pollingIntervalMs, long pollingJitterMs) {
fetchConfigurationsTask.scheduleNext();
}

protected CompletableFuture<Void> loadConfigurationAsync() {
CompletableFuture<Void> future = new CompletableFuture<>();
protected void fetchAndActivateConfigurationAsync(EppoActionCallback<Configuration> callback) {

requestor
.fetchAndSaveFromRemoteAsync()
.exceptionally(
ex -> {
log.error("Encountered Exception while loading configuration", ex);
if (!isGracefulMode) {
future.completeExceptionally(ex);
}
return null;
})
.thenAccept(future::complete);
requestor.fetchAndSaveFromRemoteAsync(
new EppoActionCallback<Configuration>() {
@Override
public void onSuccess(Configuration data) {
callback.onSuccess(data);
}

return future;
@Override
public void onFailure(Throwable error) {
log.error("Encountered Exception while loading configuration", error);
callback.onFailure(error);
}
});
}

protected EppoValue getTypedAssignment(
Expand Down Expand Up @@ -306,10 +294,7 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v
typeMatch = value.isString();
break;
case JSON:
typeMatch =
value.isString()
// Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse
&& parseJsonString(value.stringValue()) != null;
typeMatch = value.isString() && Utils.isValidJson(value.stringValue());
break;
default:
throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType);
Expand Down Expand Up @@ -398,46 +383,6 @@ public String getStringAssignment(
}
}

/**
* Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}.
* If the flag is not found, does not match the requested type or is disabled, defaultValue is
* returned.
*
* @param flagKey the feature flag key
* @param subjectKey the subject key
* @param defaultValue the default value to return if the flag is not found
* @return the JSON string value of the assignment
*/
public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) {
return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue);
}

/**
* Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}.
* If the flag is not found, does not match the requested type or is disabled, defaultValue is
* returned.
*
* @param flagKey the feature flag key
* @param subjectKey the subject key
* @param defaultValue the default value to return if the flag is not found
* @return the JSON string value of the assignment
*/
public JsonNode getJSONAssignment(
String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) {
try {
EppoValue value =
this.getTypedAssignment(
flagKey,
subjectKey,
subjectAttributes,
EppoValue.valueOf(defaultValue.toString()),
VariationType.JSON);
return parseJsonString(value.stringValue());
} catch (Exception e) {
return throwIfNotGraceful(e, defaultValue);
}
}

/**
* Returns the assignment for the provided feature flag key, subject key and subject attributes as
* a JSON string. If the flag is not found, does not match the requested type or is disabled,
Expand Down Expand Up @@ -478,14 +423,6 @@ public String getJSONStringAssignment(String flagKey, String subjectKey, String
return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue);
}

private JsonNode parseJsonString(String jsonString) {
try {
return mapper.readTree(jsonString);
} catch (JsonProcessingException e) {
return null;
}
}

public BanditResult getBanditAction(
String flagKey,
String subjectKey,
Expand Down Expand Up @@ -565,7 +502,7 @@ private Map<String, String> buildLogMetaData(boolean isConfigObfuscated) {
return metaData;
}

private <T> T throwIfNotGraceful(Exception e, T defaultValue) {
protected <T> T throwIfNotGraceful(Exception e, T defaultValue) {
if (this.isGracefulMode) {
log.info("error getting assignment value: {}", e.getMessage());
return defaultValue;
Expand All @@ -580,11 +517,11 @@ public void setIsGracefulFailureMode(boolean isGracefulFailureMode) {
/**
* Subscribe to changes to the configuration.
*
* @param callback A function to be executed when the configuration changes.
* @param callback A listener which is notified of configuration changes.
* @return a Runnable which, when called unsubscribes the callback from configuration change
* events.
*/
public Runnable onConfigurationChange(Consumer<Configuration> callback) {
public Runnable onConfigurationChange(Configuration.Callback callback) {
return requestor.onConfigurationChange(callback);
}

Expand Down
Loading