What is happening
The Java Server SDK cannot see any of my flags. The config endpoint it polls returns a valid response for the correct environment, but the flags map is always empty. This happens for every flag I have configured, including one I created via the REST API purely to rule out UI-side weirdness.
Setup
- Account:
xxxx
- Environment:
Production
- SDK:
cloud.eppo:eppo-server-sdk:5.3.4
- Spring boot 3.5.6
- Java 21
What I have verified via the REST Management API
The flags really are configured correctly.
curl -sS -H "X-Eppo-Token: $KEY" \
"https://eppo.cloud/api/v1/feature-flags/xxxxx/environments/yyyyy" \
| jq '{active, allocations: [.allocations[] | {name, is_default, percent_exposure, variation_weight}]}'
returns:
{
"active": true,
"allocations": [
{
"name": "everyone-gets-a-discount",
"is_default": false,
"percent_exposure": 1,
"variation_weight": [
{ "variation_id": 518234, "weight": 1 },
{ "variation_id": 518235, "weight": 0 }
]
},
{
"name": "Default",
"is_default": true,
"percent_exposure": 1,
"variation_weight": [{ "variation_id": 518234, "weight": 1 }]
}
]
}
variation_id 518234 is variant_key: "true". Environment is active, allocation is at 100%, no targeting rules, no audiences. This is about as simple as a boolean flag gets.
I get the same shape back for bbnd-9100-spike and test A.
What the SDK config endpoint returns
Same environment, same tenant, called immediately after:
curl -sS "https://40bujn.fscdn.eppo.cloud/api/flag-config/v1/config?apiKey=${SDK_KEY}&sdkName=java-server-sdk&sdkVersion=5.3.4" \
| jq '{createdAt, environment: .environment.name, format, flagCount: (.flags | length)}'
returns:
{
"createdAt": "2026-07-02T10:30:24.285Z",
"environment": "Production",
"format": "SERVER",
"flagCount": 0
}
The snapshot regenerates promptly on changes (I can watch createdAt update within a few seconds of toggling a flag), so this is not a cache issue. The snapshot itself is empty.
What I tried or checked
- Wrong SDK key type. The response reports
format: SERVER.
- Wrong environment. The response reports
environment: Production.
- Cache staleness.
createdAt advances immediately after any REST mutation.
- UI misconfiguration. The REST-created flag (
bbnd-9100-spike) has the same issue.
- Missing allocations. All three flags have a non-default allocation at 100% serving
true.
- Flag not enabled in the environment.
active: true on all three via the REST API.
TL;DR
REST API works fine but using the SDK and an SDK key I cannot see feature flags.
Using
Handrolled Java Client to Eppo (works) ✅
package com.giffgaff.broadband.productcatalogue.featureflag;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import lombok.extern.slf4j.Slf4j;
/**
* {@link FeatureFlagClient} backed by the Eppo Management REST API. Spike
* workaround while the Eppo SDK config CDN returns an empty flag payload;
* switch back to the SDK once that is fixed.
*
* <p>Handles boolean flags only. Ignores targeting rules and audiences.
* On any HTTP failure the previously cached value is retained.
*/
@Slf4j
public class EppoRestFeatureFlagClient implements FeatureFlagClient {
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private final EppoProperties properties;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final ScheduledExecutorService scheduler;
private volatile boolean cachedValue;
private volatile Long flagId;
private volatile Map<Long, String> variantKeys = Map.of();
public EppoRestFeatureFlagClient(
EppoProperties properties,
HttpClient httpClient,
ObjectMapper objectMapper) {
this(properties, httpClient, objectMapper,
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread t = new Thread(runnable, "eppo-rest-poller");
t.setDaemon(true);
return t;
}));
}
// Visible-for-testing constructor.
EppoRestFeatureFlagClient(
EppoProperties properties,
HttpClient httpClient,
ObjectMapper objectMapper,
ScheduledExecutorService scheduler) {
this.properties = Objects.requireNonNull(properties);
this.httpClient = Objects.requireNonNull(httpClient);
this.objectMapper = Objects.requireNonNull(objectMapper);
this.scheduler = Objects.requireNonNull(scheduler);
}
@PostConstruct
public void start() {
if (properties.restApiKey() == null || properties.restApiKey().isBlank()) {
throw new IllegalStateException(
"eppo.enabled=true but no REST API key found — set the EPPO_REST_API_KEY environment variable");
}
log.info("Starting Eppo REST client — flagKey={} envId={} pollIntervalSeconds={}",
properties.flagKey(), properties.environmentId(), properties.pollIntervalSeconds());
resolveFlagMetadata();
pollOnce();
scheduler.scheduleAtFixedRate(
this::pollOnce,
properties.pollIntervalSeconds(),
properties.pollIntervalSeconds(),
TimeUnit.SECONDS);
}
@PreDestroy
public void stop() {
scheduler.shutdownNow();
}
@Override
public boolean isEnabled(String flagKey, String subjectKey, boolean defaultValue) {
if (!Objects.equals(flagKey, properties.flagKey())) {
// Spike scope: only the configured flag is polled.
return defaultValue;
}
if (flagId == null) {
return defaultValue;
}
return cachedValue;
}
private void resolveFlagMetadata() {
try {
JsonNode list = doGet("/feature-flags");
if (list == null) {
return;
}
for (JsonNode node : list) {
if (properties.flagKey().equals(node.path("key").asText())) {
long id = node.path("id").asLong();
Map<Long, String> keys = fetchVariantKeys(id);
if (keys.isEmpty()) {
log.warn("Eppo flag '{}' resolved to id={} but variations were empty", properties.flagKey(), id);
return;
}
flagId = id;
variantKeys = keys;
log.info("Resolved Eppo flag '{}' -> id={} variants={}", properties.flagKey(), id, keys);
return;
}
}
log.warn("Eppo flag '{}' not found in the tenant", properties.flagKey());
} catch (IOException | RuntimeException ex) {
log.warn("Failed to resolve Eppo flag metadata for '{}'", properties.flagKey(), ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private Map<Long, String> fetchVariantKeys(long id) throws IOException, InterruptedException {
JsonNode flag = doGet("/feature-flags/" + id);
if (flag == null) {
return Map.of();
}
Map<Long, String> map = new HashMap<>();
for (JsonNode v : flag.path("variations")) {
map.put(v.path("id").asLong(), v.path("variant_key").asText());
}
return Map.copyOf(map);
}
private void pollOnce() {
try {
if (flagId == null) {
resolveFlagMetadata();
if (flagId == null) {
return;
}
}
JsonNode env = doGet("/feature-flags/" + flagId
+ "/environments/" + properties.environmentId());
if (env == null) {
return;
}
boolean previous = cachedValue;
boolean current = evaluate(env, variantKeys);
cachedValue = current;
if (previous != current) {
log.info("Eppo flag '{}' flipped {} -> {}", properties.flagKey(), previous, current);
}
} catch (IOException | RuntimeException ex) {
log.warn("Eppo REST poll failed — keeping cached value {}", cachedValue, ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private JsonNode doGet(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(properties.baseUrl() + path))
.timeout(REQUEST_TIMEOUT)
.header("X-Eppo-Token", properties.restApiKey())
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("Eppo REST GET {} returned status {}", path, response.statusCode());
return null;
}
return objectMapper.readTree(response.body());
}
/** Non-default allocations win over the default; first weighted variation wins. */
static boolean evaluate(JsonNode envNode, Map<Long, String> variantKeys) {
if (!envNode.path("active").asBoolean(false)) {
return false;
}
JsonNode allocations = envNode.path("allocations");
if (!allocations.isArray()) {
return false;
}
return streamAllocations(allocations)
.sorted(Comparator.comparing(a -> a.path("is_default").asBoolean(false)))
.filter(a -> a.path("percent_exposure").asDouble(0) > 0)
.map(a -> winningVariantKey(a, variantKeys))
.filter(Optional::isPresent)
.map(Optional::get)
.map("true"::equalsIgnoreCase)
.findFirst()
.orElse(false);
}
private static Stream<JsonNode> streamAllocations(JsonNode allocations) {
Iterable<JsonNode> iterable = allocations::elements;
return StreamSupport.stream(iterable.spliterator(), false);
}
private static Optional<String> winningVariantKey(JsonNode allocation, Map<Long, String> variantKeys) {
JsonNode weights = allocation.path("variation_weight");
if (!weights.isArray()) {
return Optional.empty();
}
for (JsonNode weight : weights) {
if (weight.path("weight").asDouble(0) > 0) {
long variationId = weight.path("variation_id").asLong();
String variantKey = variantKeys.get(variationId);
if (variantKey != null) {
return Optional.of(variantKey);
}
}
}
return Optional.empty();
}
}
What is happening
The Java Server SDK cannot see any of my flags. The config endpoint it polls returns a valid response for the correct environment, but the
flagsmap is always empty. This happens for every flag I have configured, including one I created via the REST API purely to rule out UI-side weirdness.Setup
xxxxProductioncloud.eppo:eppo-server-sdk:5.3.4What I have verified via the REST Management API
The flags really are configured correctly.
returns:
{ "active": true, "allocations": [ { "name": "everyone-gets-a-discount", "is_default": false, "percent_exposure": 1, "variation_weight": [ { "variation_id": 518234, "weight": 1 }, { "variation_id": 518235, "weight": 0 } ] }, { "name": "Default", "is_default": true, "percent_exposure": 1, "variation_weight": [{ "variation_id": 518234, "weight": 1 }] } ] }variation_id518234isvariant_key: "true". Environment is active, allocation is at 100%, no targeting rules, no audiences. This is about as simple as a boolean flag gets.I get the same shape back for
bbnd-9100-spikeandtest A.What the SDK config endpoint returns
Same environment, same tenant, called immediately after:
returns:
{ "createdAt": "2026-07-02T10:30:24.285Z", "environment": "Production", "format": "SERVER", "flagCount": 0 }The snapshot regenerates promptly on changes (I can watch
createdAtupdate within a few seconds of toggling a flag), so this is not a cache issue. The snapshot itself is empty.What I tried or checked
format: SERVER.environment: Production.createdAtadvances immediately after any REST mutation.bbnd-9100-spike) has the same issue.true.active: trueon all three via the REST API.TL;DR
REST API works fine but using the SDK and an SDK key I cannot see feature flags.
Using
Handrolled Java Client to Eppo (works) ✅