Skip to content

SDK config endpoint returns empty flags: {} for an environment that has active flags with valid allocations #157

Description

@tomaytotomato

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions