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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group = 'cloud.eppo'
version = '3.13.2-SNAPSHOT'
version = '3.14.0'
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")

java {
Expand Down
46 changes: 32 additions & 14 deletions src/main/java/cloud/eppo/ConfigurationRequestor.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,24 @@ void fetchAndSaveFromRemote() {
// Reuse the `lastConfig` as its bandits may be useful
Configuration lastConfig = configurationStore.getConfiguration();

byte[] flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT);
EppoHttpResponse flagsResponse = client.get(Constants.FLAG_CONFIG_ENDPOINT);

// Handle 304 Not Modified
if (flagsResponse.isNotModified()) {
// No update needed - existing config is still valid
return;
}

Configuration.Builder configBuilder =
Configuration.builder(flagConfigurationJsonBytes, expectObfuscatedConfig)
.banditParametersFromConfig(lastConfig);
Configuration.builder(flagsResponse.getBody())
.banditParametersFromConfig(lastConfig)
.flagsETag(flagsResponse.getETag());

if (supportBandits && configBuilder.requiresUpdatedBanditModels()) {
byte[] banditParametersJsonBytes = client.get(Constants.BANDIT_ENDPOINT);
configBuilder.banditParameters(banditParametersJsonBytes);
EppoHttpResponse banditsResponse = client.get(Constants.BANDIT_ENDPOINT);
if (banditsResponse.isSuccessful()) {
configBuilder.banditParameters(banditsResponse.getBody());
}
}

saveConfigurationAndNotify(configBuilder.build()).join();
Expand All @@ -105,6 +115,7 @@ void fetchAndSaveFromRemote() {
CompletableFuture<Void> fetchAndSaveFromRemoteAsync() {
log.debug("Fetching configuration from API server");
final Configuration lastConfig = configurationStore.getConfiguration();
final String existingETag = lastConfig.getFlagsETag();

if (remoteFetchFuture != null && !remoteFetchFuture.isDone()) {
log.debug("Remote fetch is active. Cancelling and restarting");
Expand All @@ -114,26 +125,33 @@ CompletableFuture<Void> fetchAndSaveFromRemoteAsync() {

remoteFetchFuture =
client
.getAsync(Constants.FLAG_CONFIG_ENDPOINT)
.getAsync(Constants.FLAG_CONFIG_ENDPOINT, existingETag)
.thenCompose(
flagConfigJsonBytes -> {
flagsResponse -> {
// Handle 304 Not Modified
if (flagsResponse.isNotModified()) {
// No update needed - existing config is still valid
return CompletableFuture.completedFuture(null);
}

synchronized (this) {
Configuration.Builder configBuilder =
Configuration.builder(flagConfigJsonBytes, expectObfuscatedConfig)
Configuration.builder(flagsResponse.getBody())
.banditParametersFromConfig(
lastConfig); // possibly reuse last bandit models loaded.
lastConfig) // possibly reuse last bandit models loaded.
.flagsETag(flagsResponse.getETag());

if (supportBandits && configBuilder.requiresUpdatedBanditModels()) {
byte[] banditParametersJsonBytes;
EppoHttpResponse banditParametersResponse;
try {
banditParametersJsonBytes =
client.getAsync(Constants.BANDIT_ENDPOINT).get();
banditParametersResponse = client.getAsync(Constants.BANDIT_ENDPOINT).get();
} catch (InterruptedException | ExecutionException e) {
log.error("Error fetching from remote: " + e.getMessage());
throw new RuntimeException(e);
}
if (banditParametersJsonBytes != null) {
configBuilder.banditParameters(banditParametersJsonBytes);
if (banditParametersResponse != null
&& banditParametersResponse.isSuccessful()) {
configBuilder.banditParameters(banditParametersResponse.getBody());
}
}

Expand Down
63 changes: 44 additions & 19 deletions src/main/java/cloud/eppo/EppoHttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import okhttp3.Request;
import okhttp3.Response;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -44,44 +45,61 @@ private static OkHttpClient buildOkHttpClient() {
return builder.build();
}

public byte[] get(String path) {
public EppoHttpResponse get(String path) {
try {
// Wait and return the async get.
return getAsync(path).get();
return getAsync(path, null).get();
} catch (InterruptedException | ExecutionException e) {
log.error("Config fetch interrupted", e);
throw new RuntimeException(e);
}
}

public CompletableFuture<byte[]> getAsync(String path) {
CompletableFuture<byte[]> future = new CompletableFuture<>();
Request request = buildRequest(path);
public CompletableFuture<EppoHttpResponse> getAsync(String path) {
return getAsync(path, null);
}

public CompletableFuture<EppoHttpResponse> getAsync(String path, @Nullable String ifNoneMatch) {
CompletableFuture<EppoHttpResponse> future = new CompletableFuture<>();
Request request = buildRequest(path, ifNoneMatch);

client
.newCall(request)
.enqueue(
new Callback() {
@Override
public void onResponse(@NotNull Call call, @NotNull Response response) {
if (response.isSuccessful() && response.body() != null) {
log.debug("Fetch successful");
try {
future.complete(response.body().bytes());
} catch (IOException ex) {
future.completeExceptionally(
new RuntimeException(
"Failed to read response from URL {}" + request.url(), ex));
try {
int statusCode = response.code();
String eTag = response.header("ETag");

// Handle 304 Not Modified
if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
future.complete(new EppoHttpResponse(new byte[0], statusCode, eTag));
return;
}
} else {
if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) {

// Handle 2xx success
if (response.isSuccessful() && response.body() != null) {
log.debug("Fetch successful");
try {
byte[] bytes = response.body().bytes();
future.complete(new EppoHttpResponse(bytes, statusCode, eTag));
} catch (IOException ex) {
future.completeExceptionally(
new RuntimeException(
"Failed to read response from URL " + request.url(), ex));
}
} else if (statusCode == HttpURLConnection.HTTP_FORBIDDEN) {
future.completeExceptionally(new RuntimeException("Invalid API key"));
} else {
log.debug("Fetch failed with status code: {}", response.code());
log.debug("Fetch failed with status code: {}", statusCode);
future.completeExceptionally(
new RuntimeException("Bad response from URL " + request.url()));
}
} finally {
response.close();
}
response.close();
}

@Override
Expand All @@ -98,7 +116,7 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) {
return future;
}

private Request buildRequest(String path) {
private Request buildRequest(String path, @Nullable String ifNoneMatch) {
HttpUrl httpUrl =
HttpUrl.parse(baseUrl + path)
.newBuilder()
Expand All @@ -107,6 +125,13 @@ private Request buildRequest(String path) {
.addQueryParameter("sdkVersion", sdkVersion)
.build();

return new Request.Builder().url(httpUrl).build();
Request.Builder requestBuilder = new Request.Builder().url(httpUrl);

// Add If-None-Match header if eTag provided
if (ifNoneMatch != null && !ifNoneMatch.isEmpty()) {
requestBuilder.header("If-None-Match", ifNoneMatch);
}

return requestBuilder.build();
}
}
44 changes: 44 additions & 0 deletions src/main/java/cloud/eppo/EppoHttpResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cloud.eppo;

import org.jetbrains.annotations.Nullable;

/**
* HTTP response wrapper containing status code, body, and optional ETag header. Used to support
* conditional requests via If-None-Match/ETag headers.
*/
public class EppoHttpResponse {
private final byte[] body;
private final int statusCode;
@Nullable private final String eTag;

public EppoHttpResponse(byte[] body, int statusCode, @Nullable String eTag) {
this.body = body;
this.statusCode = statusCode;
this.eTag = eTag;
}

/** Get response body bytes. Empty for 304 responses. */
public byte[] getBody() {
return body;
}

/** Get HTTP status code. */
public int getStatusCode() {
return statusCode;
}

/** Get ETag header value if present. */
@Nullable public String getETag() {
return eTag;
}

/** Returns true if status code is 304 Not Modified. */
public boolean isNotModified() {
return statusCode == 304;
}

/** Returns true if status code is 2xx success. */
public boolean isSuccessful() {
return statusCode >= 200 && statusCode < 300;
}
}
70 changes: 54 additions & 16 deletions src/main/java/cloud/eppo/api/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,17 @@ public class Configuration {

private final byte[] banditParamsJson;

@Nullable private final String flagsETag;

/** Default visibility for tests. */
Configuration(
Map<String, FlagConfig> flags,
Map<String, BanditReference> banditReferences,
Map<String, BanditParameters> bandits,
boolean isConfigObfuscated,
byte[] flagConfigJson,
byte[] banditParamsJson) {
byte[] banditParamsJson,
@Nullable String flagsETag) {
this.flags = flags;
this.banditReferences = banditReferences;
this.bandits = bandits;
Expand All @@ -97,6 +100,7 @@ public class Configuration {
}
this.flagConfigJson = flagConfigJson;
this.banditParamsJson = banditParamsJson;
this.flagsETag = flagsETag;
}

public static Configuration emptyConfig() {
Expand All @@ -106,36 +110,54 @@ public static Configuration emptyConfig() {
Collections.emptyMap(),
false,
emptyFlagsBytes,
null,
null);
}

@Override
public String toString() {
return "Configuration{" +
"banditReferences=" + banditReferences +
", flags=" + flags +
", bandits=" + bandits +
", isConfigObfuscated=" + isConfigObfuscated +
", flagConfigJson=" + Arrays.toString(flagConfigJson) +
", banditParamsJson=" + Arrays.toString(banditParamsJson) +
'}';
return "Configuration{"
+ "banditReferences="
+ banditReferences
+ ", flags="
+ flags
+ ", bandits="
+ bandits
+ ", isConfigObfuscated="
+ isConfigObfuscated
+ ", flagConfigJson="
+ Arrays.toString(flagConfigJson)
+ ", banditParamsJson="
+ Arrays.toString(banditParamsJson)
+ ", flagsETag='"
+ flagsETag
+ '\''
+ '}';
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Configuration that = (Configuration) o;
return isConfigObfuscated == that.isConfigObfuscated
&& Objects.equals(banditReferences, that.banditReferences)
&& Objects.equals(flags, that.flags)
&& Objects.equals(bandits, that.bandits)
&& Objects.deepEquals(flagConfigJson, that.flagConfigJson)
&& Objects.deepEquals(banditParamsJson, that.banditParamsJson);
&& Objects.equals(banditReferences, that.banditReferences)
&& Objects.equals(flags, that.flags)
&& Objects.equals(bandits, that.bandits)
&& Objects.deepEquals(flagConfigJson, that.flagConfigJson)
&& Objects.deepEquals(banditParamsJson, that.banditParamsJson)
&& Objects.equals(flagsETag, that.flagsETag);
}

@Override
public int hashCode() {
return Objects.hash(banditReferences, flags, bandits, isConfigObfuscated, Arrays.hashCode(flagConfigJson), Arrays.hashCode(banditParamsJson));
return Objects.hash(
banditReferences,
flags,
bandits,
isConfigObfuscated,
Arrays.hashCode(flagConfigJson),
Arrays.hashCode(banditParamsJson),
flagsETag);
}

public FlagConfig getFlag(String flagKey) {
Expand Down Expand Up @@ -197,6 +219,10 @@ public byte[] serializeBanditParamsToBytes() {
return banditParamsJson;
}

@Nullable public String getFlagsETag() {
return flagsETag;
}

public boolean isEmpty() {
return flags == null || flags.isEmpty();
}
Expand Down Expand Up @@ -226,6 +252,7 @@ public static class Builder {
private Map<String, BanditParameters> bandits = Collections.emptyMap();
private final byte[] flagJson;
private byte[] banditParamsJson;
@Nullable private String flagsETag;

private static FlagConfigResponse parseFlagResponse(byte[] flagJson) {
if (flagJson == null || flagJson.length == 0) {
Expand Down Expand Up @@ -336,9 +363,20 @@ public Builder banditParameters(byte[] banditParameterJson) {
return this;
}

public Builder flagsETag(@Nullable String eTag) {
this.flagsETag = eTag;
return this;
}

public Configuration build() {
return new Configuration(
flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson);
flags,
banditReferences,
bandits,
isConfigObfuscated,
flagJson,
banditParamsJson,
flagsETag);
}
}
}
3 changes: 2 additions & 1 deletion src/test/java/cloud/eppo/BaseEppoClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,8 @@ public void testPolling() {
verify(httpClient, times(2)).get(anyString());

// Set up a different config to be served
when(httpClient.get(anyString())).thenReturn(DISABLED_BOOL_FLAG_CONFIG.getBytes());
when(httpClient.get(anyString()))
.thenReturn(new EppoHttpResponse(DISABLED_BOOL_FLAG_CONFIG.getBytes(), 200, null));
client.startPolling(20);

// True until the next config is fetched.
Expand Down
Loading