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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions