From c113675889f3af30de86f8fe0b0d555ebe52512a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 20:56:58 +0200 Subject: [PATCH 01/93] fix(go-feature-flag): pin WASM evaluation engine to 0.2.4 GO Feature Flag Provider Specification 1.0 (GOFF-ENG-001) requires all providers to evaluate using engine modules/core v0.7.2, which a WASM-based provider satisfies by pinning WASM module 0.2.4. The provider was pinned to 0.2.3, so in-process evaluations ran an older engine than the contract allows. Advance the wasm-releases submodule alongside the pin: 0.2.4 only exists from commit 76bf27b, and the chicory-compiler-maven-plugin resolves against the submodule at build time. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/pom.xml | 2 +- providers/go-feature-flag/wasm-releases | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index 18bb7a8916..8adadac5b9 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -20,7 +20,7 @@ ${groupId}.gofeatureflag - 0.2.3 + 0.2.4 diff --git a/providers/go-feature-flag/wasm-releases b/providers/go-feature-flag/wasm-releases index 1eda3b7f5b..76bf27bab8 160000 --- a/providers/go-feature-flag/wasm-releases +++ b/providers/go-feature-flag/wasm-releases @@ -1 +1 @@ -Subproject commit 1eda3b7f5b6696554acf454a488bc33da5f5a062 +Subproject commit 76bf27bab805b2fdc564a0f8557c03d1af414d70 From 3095ec3c6a9f9e13ee22f3fbd0e0144040e26645 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 21:07:20 +0200 Subject: [PATCH 02/93] fix(go-feature-flag): preserve endpoint path prefix on the configuration route GOFF-IP-003 requires any path prefix on the configured endpoint to survive URL construction. retrieveFlagConfiguration built its URL with endpoint.resolve("/v1/flag/configuration"): an absolute-path reference, which RFC 3986 section 5.3 resolves by replacing the base path entirely. An endpoint of https://mydomain.com/gofeatureflagproxy/ - the form the README documents - therefore posted to https://mydomain.com/v1/flag/configuration, silently bypassing the proxy prefix. Normalise the endpoint to a directory-like base in the constructor and resolve routes relatively through a new route() helper, which also strips a leading slash so a call site cannot reintroduce the bug. The OFREP evaluation and data collector routes still build their URLs the old way; they are addressed separately under GOFF-REM-001 and GOFF-COLL-001. GoffApiMock now matches routes with contains() so an endpoint carrying a path prefix still reaches the right handler. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 17 +++++++++-- .../providers/gofeatureflag/util/Const.java | 6 ++-- .../api/GoFeatureFlagApiTest.java | 28 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 11 +++++--- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 591a05338e..43234982b1 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -74,7 +74,8 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva this.apiKey = options.getApiKey(); try { - this.endpoint = new URI(options.getEndpoint()); + val rawEndpoint = options.getEndpoint(); + this.endpoint = new URI(rawEndpoint.endsWith("/") ? rawEndpoint : rawEndpoint + "/"); } catch (URISyntaxException e) { throw new InvalidEndpoint(e); } @@ -166,7 +167,7 @@ private GoFeatureFlagResponse evaluateFlag( public FlagConfigResponse retrieveFlagConfiguration(final String etag, final List flags) { try { val request = new FlagConfigApiRequest(flags == null ? Collections.emptyList() : flags); - final URI url = this.endpoint.resolve("/v1/flag/configuration"); + final URI url = route(Const.PATH_FLAG_CONFIGURATION); HttpRequest.Builder reqBuilder = HttpRequest.newBuilder().uri(url).header(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON); @@ -294,6 +295,18 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) } } + /** + * route builds the URL of an API route from the configured endpoint, preserving any path prefix + * the endpoint carries. Resolving an absolute path such as {@code /v1/flag/configuration} would + * discard that prefix (RFC 3986 section 5.3) and silently retarget the request. + * + * @param path - route path, relative to the endpoint and without a leading slash + * @return the URL to call + */ + private URI route(final String path) { + return this.endpoint.resolve(path.startsWith("/") ? path.substring(1) : path); + } + /** * prepareHttpRequest is preparing the request to be sent to the GO Feature Flag relay proxy. * diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 6b3c6a326c..ae18c0c5d5 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -17,6 +17,8 @@ public class Const { public static final String HTTP_HEADER_ETAG = "ETag"; public static final String HTTP_HEADER_IF_NONE_MATCH = "If-None-Match"; public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; + // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) + public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); @@ -27,7 +29,7 @@ public class Const { public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); public static final ObjectMapper SERIALIZE_OBJECT_MAPPER = new ObjectMapper(); - public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper() - .setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper().setDefaultPropertyInclusion( + com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) .setDateFormat(new StdDateFormat().withColonInTimeZone(true)); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 7dab61e3a1..4561e4ff66 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -498,6 +498,34 @@ public void requestShouldCallTheConfigurationEndpoint() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("request should keep the path prefix of the endpoint") + @Test + public void requestShouldKeepThePathPrefixOfTheEndpoint() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = "/gofeatureflagproxy/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @SneakyThrows + @DisplayName("request should keep the path prefix of an endpoint without a trailing slash") + @Test + public void requestShouldKeepThePathPrefixOfAnEndpointWithoutTrailingSlash() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = "/gofeatureflagproxy/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 7cfef3e4ab..899f30b995 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -44,14 +44,16 @@ public MockResponse dispatch(RecordedRequest request) { lastRequestBody = request.getBody().readUtf8(); assert request.getPath() != null; - if (request.getPath().startsWith("/ofrep/v1/evaluate/flags/")) { + // routes are matched with contains() so that an endpoint carrying a path prefix + // (https://host/gofeatureflagproxy/) still reaches the right handler + if (request.getPath().contains("/ofrep/v1/evaluate/flags/")) { return handleEvaluateFlags(request); } - if (request.getPath().startsWith("/v1/data/collector")) { + if (request.getPath().contains("/v1/data/collector")) { collectorCallCount++; return handleCollector(request); } - if (request.getPath().startsWith("/v1/flag/configuration")) { + if (request.getPath().contains("/v1/flag/configuration")) { configurationCallCount++; return handleFlagConfiguration(request); } @@ -67,7 +69,8 @@ public GoffApiMock(final MockMode mode) { @SneakyThrows public MockResponse handleEvaluateFlags(RecordedRequest request) { assert request.getPath() != null; - String flagName = request.getPath().replace("/ofrep/v1/evaluate/flags/", ""); + String flagName = request.getPath().substring(request.getPath().indexOf("/ofrep/v1/evaluate/flags/") + + "/ofrep/v1/evaluate/flags/".length()); switch (flagName) { case "timeout": Thread.sleep(500); From ae5eefd45b0b12c52a79de1e006ead042c7fd955 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 21:30:31 +0200 Subject: [PATCH 03/93] fix(go-feature-flag): make a 304 structurally incapable of carrying a configuration GOFF-IP-007 requires the not-modified branch to be signalled by a distinct type or sentinel rather than by an empty response object, so the distinction cannot be lost downstream. retrieveFlagConfiguration handled 200 and 304 in the same switch case and returned the same FlagConfigResponse type for both, so a 304 produced an ordinary-looking configuration with a null flag map and a freshly read ETag and Last-Modified. Return Optional, with 304 mapped to Optional.empty() before anything is parsed. handleFlagConfigurationSuccess is now reachable only for a 200 and builds its result in one expression, so it cannot produce a flagless configuration. InProcessEvaluator applies the configuration with ifPresent, and the polling stream filters empties out before the refresh consumer, so the 304 path cannot reach the state write at all. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 32 ++++++++-------- .../evaluator/InProcessEvaluator.java | 21 +++++++---- .../GoFeatureFlagProviderTest.java | 37 +++++++++++++++++++ .../api/GoFeatureFlagApiTest.java | 29 ++++++++++++++- .../gofeatureflag/util/GoffApiMock.java | 19 ++++++++++ 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 43234982b1..1960e2eaf2 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import lombok.Builder; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -161,10 +162,15 @@ private GoFeatureFlagResponse evaluateFlag( * retrieveFlagConfiguration is calling the GO Feature Flag relay proxy to retrieve the flags' * configuration. * - * @param etag - etag of the request - * @return FlagConfigResponse with the flag configuration + *

A {@code 304 Not Modified} response is reported as an empty Optional rather than as an + * empty configuration object, so that the not-modified branch is structurally incapable of + * carrying a configuration and cannot be mistaken for one downstream.

+ * + * @param etag - etag of the request + * @param flags - flags to retrieve, empty for all of them + * @return the flag configuration, or empty if the configuration has not been modified */ - public FlagConfigResponse retrieveFlagConfiguration(final String etag, final List flags) { + public Optional retrieveFlagConfiguration(final String etag, final List flags) { try { val request = new FlagConfigApiRequest(flags == null ? Collections.emptyList() : flags); final URI url = route(Const.PATH_FLAG_CONFIGURATION); @@ -188,8 +194,9 @@ public FlagConfigResponse retrieveFlagConfiguration(final String etag, final Lis String body = response.body(); switch (response.statusCode()) { case HttpURLConnection.HTTP_OK: + return Optional.of(handleFlagConfigurationSuccess(response, body)); case HttpURLConnection.HTTP_NOT_MODIFIED: - return handleFlagConfigurationSuccess(response, body); + return Optional.empty(); case HttpURLConnection.HTTP_NOT_FOUND: throw new FlagConfigurationEndpointNotFound(); case HttpURLConnection.HTTP_UNAUTHORIZED: @@ -251,8 +258,8 @@ public void sendEventToDataCollector(final List eventsList, final Map eventsList, final Map response, final String body) throws JsonProcessingException { - var result = FlagConfigResponse.builder() + val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, FlagConfigApiResponse.class); + return FlagConfigResponse.builder() .etag(response.headers().firstValue(Const.HTTP_HEADER_ETAG).orElse(null)) .lastUpdated(extractLastUpdatedFromHeaders(response)) + .flags(goffResp.getFlags()) + .evaluationContextEnrichment(goffResp.getEvaluationContextEnrichment()) .build(); - - if (response.statusCode() == HttpURLConnection.HTTP_OK) { - val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, FlagConfigApiResponse.class); - result.setFlags(goffResp.getFlags()); - result.setEvaluationContextEnrichment(goffResp.getEvaluationContextEnrichment()); - } - - return result; } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 4a63cb3adf..058432eca1 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -21,6 +21,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; @@ -115,12 +116,14 @@ public boolean isFlagTrackable(final String flagKey) { @Override public void init() { - val configFlags = api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()); - this.state = new EvaluatorState( - configFlags.getFlags(), - configFlags.getEvaluationContextEnrichment(), - configFlags.getEtag(), - configFlags.getLastUpdated()); + // an empty response means the configuration has not been modified, so the state we already + // hold is still current and must not be overwritten. + api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()) + .ifPresent(configFlags -> this.state = new EvaluatorState( + configFlags.getFlags(), + configFlags.getEvaluationContextEnrichment(), + configFlags.getEtag(), + configFlags.getLastUpdated())); // start the polling of the flag configuration this.configurationDisposable = startCheckFlagConfigurationChangesDaemon(); @@ -150,8 +153,12 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList())) .onErrorResumeNext(e -> { log.error("error while calling flag configuration API", e); - return Observable.empty(); + return Observable.>empty(); })) + // a 304 emits an empty Optional: drop it here so the refresh consumer below is + // structurally unable to write state for a response that carries no configuration. + .filter(Optional::isPresent) + .map(Optional::get) .subscribeOn(Schedulers.io()); return apiCallObservable.subscribe( diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index dd837f7969..12a065a45d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -451,6 +451,43 @@ void shouldEmitConfigurationChangeEventIfConfigHasChanged() { assertEquals(List.of("bool_targeting_match", "new-flag-changed", "disabled_bool"), flagsChanged); } + @DisplayName("Should keep polling the configuration after a 304 not-modified response") + @SneakyThrows + @Test + void shouldKeepPollingAfterANotModifiedResponse() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_THEN_CHANGE); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> configurationChangedCalled.set(true)); + + val before = + client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + // the 1st poll answers a 304, so the change can only arrive on a later poll + int maxWait = 200; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + + assertTrue( + configurationChangedCalled.get(), + "the polling daemon must survive a 304 and pick up the next configuration change"); + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the 304 poll should have happened"); + val after = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertNotEquals(before, after); + } + } + @DisplayName("Should not emit configuration change event, if config has not changed") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 4561e4ff66..b94c64f00f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Optional; import java.util.Map; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -526,6 +527,30 @@ public void requestShouldKeepThePathPrefixOfAnEndpointWithoutTrailingSlash() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("a 304 should not return a configuration") + @Test + public void a304ShouldNotReturnAConfiguration() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertEquals(Optional.empty(), api.retrieveFlagConfiguration("304-with-etag", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 304 without an etag should not return a configuration either") + @Test + public void a304WithoutAnEtagShouldNotReturnAConfiguration() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertEquals(Optional.empty(), api.retrieveFlagConfiguration("304-without-etag", Collections.emptyList())); + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test @@ -690,7 +715,7 @@ public void requestShouldHaveReturn200SimpleFlags() { .parse("Wed, 21 Oct 2015 07:28:00 GMT")) .evaluationContextEnrichment(evaluationContextEnrichment) .build(); - assertEquals(want, got); + assertEquals(Optional.of(want), got); } @SneakyThrows @@ -734,7 +759,7 @@ public void requestShouldNotReturnLastModifiedDateIfInvalidHeader() { .lastUpdated(null) .evaluationContextEnrichment(evaluationContextEnrichment) .build(); - assertEquals(want, got); + assertEquals(Optional.of(want), got); } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 899f30b995..94e45d16f5 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -22,6 +22,7 @@ public class GoffApiMock { @Getter private int collectorCallCount = 0; + @Getter private int configurationCallCount = 0; /** * lastRequestBody contains the body of the last request. @@ -143,6 +144,14 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 1 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case NOT_MODIFIED_THEN_CHANGE: + if (configurationCallCount == 2) { + // a 304 on the 2nd call: the polling daemon must survive it and keep polling + return new MockResponse().setResponseCode(304); + } + configLocation = + configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; + break; case SIMPLE_CONFIG: configLocation = "valid-flag-config.json"; break; @@ -170,6 +179,15 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { return new MockResponse().setResponseCode(404); case "500": return new MockResponse().setResponseCode(500); + case "304-with-etag": + // a 304 that echoes the validator back, as the relay proxy does + return new MockResponse() + .setResponseCode(304) + .addHeader(Const.HTTP_HEADER_ETAG, "\"" + configLocation + "\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "304-without-etag": + // a 304 carrying no validator at all + return new MockResponse().setResponseCode(304); case "invalid-lastmodified-header": return new MockResponse() .setResponseCode(200) @@ -191,6 +209,7 @@ public enum MockMode { ENDPOINT_ERROR, ENDPOINT_ERROR_404, CHANGE_CONFIG_AFTER_1ST_EVAL, + NOT_MODIFIED_THEN_CHANGE, SIMPLE_CONFIG, DEFAULT, SERVE_OLD_CONFIGURATION, From 49fdcbf1e289ac57f09134111b8225b40ad0e187 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 21:35:48 +0200 Subject: [PATCH 04/93] fix(go-feature-flag): treat a 200 carrying no flag map as a failed refresh GOFF-IP-009 requires a 200 whose decoded flag map is null or absent to be treated as a failed refresh. The provider accepted it: the null map was written into provider state, wiping every flag, and the ETag advanced with it, so the next poll answered 304 and the empty state became permanent. A body of the JSON literal null did not even reach that point, it raised a NullPointerException. Reject a response that decodes to no flag map with ImpossibleToRetrieveConfiguration, the same failed-refresh signal an unparseable body already uses, so the previous configuration is preserved, the stored ETag does not advance and polling continues. A null evaluationContextEnrichment is explicitly not the same case - the relay proxy builds that field from a Go map and a nil map marshals to null - so it is still accepted as "no enrichment". Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 13 ++++- .../api/GoFeatureFlagApiTest.java | 58 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 28 +++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 1960e2eaf2..ad8fa01f55 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -264,11 +264,22 @@ public void sendEventToDataCollector(final List eventsList, final Map response, final String body) throws JsonProcessingException { val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, FlagConfigApiResponse.class); + + // A 200 that decodes to no flag map is a failed refresh, not an empty configuration: + // accepting it would wipe every flag and advance the ETag, making the empty state permanent. + // A null evaluationContextEnrichment is NOT the same case - the relay proxy builds that field + // from a Go map and a nil map marshals to null - so it is accepted as "no enrichment". + if (goffResp == null || goffResp.getFlags() == null) { + throw new ImpossibleToRetrieveConfiguration( + "retrieve flag configuration error: the response contains no flag map"); + } + return FlagConfigResponse.builder() .etag(response.headers().firstValue(Const.HTTP_HEADER_ETAG).orElse(null)) .lastUpdated(extractLastUpdatedFromHeaders(response)) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index b94c64f00f..fa5a50a758 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; @@ -527,6 +528,63 @@ public void requestShouldKeepThePathPrefixOfAnEndpointWithoutTrailingSlash() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("a 200 carrying no flag map should be a failed refresh") + @Test + public void a200CarryingNoFlagMapShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("no-flags", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 200 with a null flag map should be a failed refresh") + @Test + public void a200WithANullFlagMapShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("null-flags", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 200 whose body is the json literal null should be a failed refresh") + @Test + public void a200WithANullBodyShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("null-body", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a null evaluationContextEnrichment should be accepted as no enrichment") + @Test + public void aNullEvaluationContextEnrichmentShouldBeAccepted() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("null-enrichment", Collections.emptyList()); + assertTrue(got.isPresent()); + assertNull(got.get().getEvaluationContextEnrichment()); + assertEquals(1, got.get().getFlags().size()); + } + @SneakyThrows @DisplayName("a 304 should not return a configuration") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 94e45d16f5..34de12bda6 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -179,6 +179,34 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { return new MockResponse().setResponseCode(404); case "500": return new MockResponse().setResponseCode(500); + case "no-flags": + // a 200 whose body carries no flags key at all + return new MockResponse() + .setResponseCode(200) + .setBody("{}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "null-flags": + // a 200 whose flags key is explicitly null + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": null, \"evaluationContextEnrichment\": {\"env\": \"production\"}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "null-body": + // a 200 whose body is the JSON literal null + return new MockResponse() + .setResponseCode(200) + .setBody("null") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\""); + case "null-enrichment": + // a nil Go map marshals to null: valid, and means "no enrichment" + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true, \"off\": false}," + + " \"defaultRule\": {\"variation\": \"on\"}}}," + + " \"evaluationContextEnrichment\": null}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"null-enrichment\""); case "304-with-etag": // a 304 that echoes the validator back, as the relay proxy does return new MockResponse() From f002f4341adeaffe1861f31e8a75d954fef4ebe9 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 22:00:59 +0200 Subject: [PATCH 05/93] fix(go-feature-flag): treat the flag configuration as opaque GOFF-IP-016 requires the flag configuration to be handed to the evaluation engine exactly as received, with no field dropped or reconstructed from a typed model. The provider deserialised it into a closed model - Flag, FlagBase, Rule, ProgressiveRollout and friends - and re-serialised it on the way into the engine. With FAIL_ON_UNKNOWN_PROPERTIES disabled, every field the model did not declare was dropped silently, so the first flag feature the engine ships that this provider predates would produce a wrong value with no error. Carry flags as raw JsonNode from the API response through to the WASM input, and delete the seven model classes that are no longer needed: an opaque provider needs no flag model, no rollout types and no migration when the schema evolves. isFlagTrackable reads trackEvents off the node, the only field a provider may read, with its previous semantics unchanged. Configuration changes are now detected with JsonNode deep equality, which compares the whole flag rather than only the fields a model happened to declare. Two tests asserted a reconstructed Flag/Rule graph and are rewritten to compare raw JSON. WasmInputTest pins the invariant that matters: the NON_NULL serialisation inclusion does not strip nulls inside the opaque flag, so what reaches the engine is equivalent to what the relay proxy sent. Signed-off-by: Thomas Poignant --- .../api/bean/FlagConfigApiResponse.java | 8 +- .../bean/ExperimentationRollout.java | 13 --- .../providers/gofeatureflag/bean/Flag.java | 14 ---- .../gofeatureflag/bean/FlagBase.java | 21 ----- .../bean/FlagConfigResponse.java | 3 +- .../bean/ProgressiveRollout.java | 12 --- .../bean/ProgressiveRolloutStep.java | 14 ---- .../providers/gofeatureflag/bean/Rule.java | 17 ---- .../gofeatureflag/bean/ScheduledStep.java | 14 ---- .../evaluator/InProcessEvaluator.java | 24 +++--- .../providers/gofeatureflag/util/Const.java | 3 + .../gofeatureflag/wasm/bean/WasmInput.java | 6 +- .../api/GoFeatureFlagApiTest.java | 79 +++++++++---------- .../gofeatureflag/util/GoffApiMock.java | 9 +++ .../wasm/bean/WasmInputTest.java | 33 ++++++++ 15 files changed, 109 insertions(+), 161 deletions(-) delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java index 4d5c9d6bd9..5125779093 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java @@ -1,17 +1,21 @@ package dev.openfeature.contrib.providers.gofeatureflag.api.bean; import com.fasterxml.jackson.annotation.JsonProperty; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Map; import lombok.Data; /** * Represents the response body for the flag configuration API. + * + *

Flags are kept as raw JSON: the evaluation engine owns the flag schema, so deserialising it + * into a typed model here would silently drop any field a newer engine adds and hand a truncated + * flag to evaluation.

*/ @Data public class FlagConfigApiResponse { @JsonProperty("flags") - private Map flags; + private Map flags; @JsonProperty("evaluationContextEnrichment") private Map evaluationContextEnrichment; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java deleted file mode 100644 index 8e6b3052a9..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; - -/** - * This class represents the rollout of an experimentation. - */ -@Data -public class ExperimentationRollout { - private Date start; - private Date end; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java deleted file mode 100644 index e6ed6126fd..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.List; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * Flag is a class that represents a feature flag for GO Feature Flag. - */ -@EqualsAndHashCode(callSuper = true) -@Data -public class Flag extends FlagBase { - private List scheduledRollout; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java deleted file mode 100644 index ad301eadd8..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.List; -import java.util.Map; -import lombok.Data; - -/** - * FlagBase is a class that represents the base structure of a feature flag for GO Feature Flag. - */ -@Data -public abstract class FlagBase { - private Map variations; - private List targeting; - private String bucketingKey; - private Rule defaultRule; - private ExperimentationRollout experimentation; - private Boolean trackEvents; - private Boolean disable; - private String version; - private Map metadata; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java index a373135aa1..4143f6e6cc 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java @@ -1,5 +1,6 @@ package dev.openfeature.contrib.providers.gofeatureflag.bean; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Date; import java.util.Map; import lombok.Builder; @@ -11,7 +12,7 @@ @Data @Builder public class FlagConfigResponse { - private Map flags; + private Map flags; private Map evaluationContextEnrichment; private String etag; private Date lastUpdated; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java deleted file mode 100644 index 6c39471e13..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import lombok.Data; - -/** - * ProgressiveRollout is a class that represents the progressive rollout of a feature flag. - */ -@Data -public class ProgressiveRollout { - private ProgressiveRolloutStep initial; - private ProgressiveRolloutStep end; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java deleted file mode 100644 index 843becfd75..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; - -/** - * ProgressiveRolloutStep is a class that represents a step in the progressive rollout of a feature flag. - */ -@Data -public class ProgressiveRolloutStep { - private String variation; - private Float percentage; - private Date date; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java deleted file mode 100644 index dc7a59ad28..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Map; -import lombok.Data; - -/** - * This class represents a rule in the GO Feature Flag system. - */ -@Data -public class Rule { - private String name; - private String query; - private String variation; - private Map percentage; - private Boolean disable; - private ProgressiveRollout progressiveRollout; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java deleted file mode 100644 index ca9a5d219b..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * ScheduledStep is a class that represents a scheduled step in the rollout of a feature flag. - */ -@EqualsAndHashCode(callSuper = true) -@Data -public class ScheduledStep extends FlagBase { - private Date date; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 058432eca1..51c99a732b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -1,8 +1,8 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; +import com.fasterxml.jackson.databind.JsonNode; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -47,13 +47,13 @@ public class InProcessEvaluator implements IEvaluator { private Disposable configurationDisposable; private static final class EvaluatorState { - final Map flags; + final Map flags; final Map evaluationContextEnrichment; final String etag; final Date lastUpdate; EvaluatorState( - Map flags, + Map flags, Map evaluationContextEnrichment, String etag, Date lastUpdate) { @@ -110,8 +110,14 @@ public GoFeatureFlagResponse evaluate(String key, Object defaultValue, Evaluatio @Override public boolean isFlagTrackable(final String flagKey) { - Flag flag = this.state.flags.get(flagKey); - return flag != null && (flag.getTrackEvents() == null || flag.getTrackEvents()); + // trackEvents is the only field of the flag configuration a provider may read: everything + // else belongs to the evaluation engine and is passed through untouched. + JsonNode flag = this.state.flags.get(flagKey); + if (flag == null) { + return false; + } + JsonNode trackEvents = flag.get(Const.FIELD_TRACK_EVENTS); + return trackEvents == null || trackEvents.isNull() || trackEvents.asBoolean(true); } @Override @@ -199,16 +205,16 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { * @return - list of flags that have changed */ private List findFlagConfigurationChanges( - final Map originalFlags, final Map newFlags) { + final Map originalFlags, final Map newFlags) { // this function should return a list of flags that have changed between the two maps // it should contain all updated, added and removed flags List changedFlags = new ArrayList<>(); // Find added or updated flags - for (Map.Entry entry : newFlags.entrySet()) { + for (Map.Entry entry : newFlags.entrySet()) { String key = entry.getKey(); - Flag newFlag = entry.getValue(); - Flag originalFlag = originalFlags.get(key); + JsonNode newFlag = entry.getValue(); + JsonNode originalFlag = originalFlags.get(key); if (originalFlag == null || !originalFlag.equals(newFlag)) { changedFlags.add(key); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index ae18c0c5d5..10d8167978 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -19,6 +19,9 @@ public class Const { public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; + // FLAG CONFIGURATION FIELDS + // the only field of a flag configuration a provider may read, everything else is the engine's + public static final String FIELD_TRACK_EVENTS = "trackEvents"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java index ef342fefd4..a454228d28 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,6 +10,8 @@ /** * This class represents the input to the WASM module. * It contains the flag key, the flag, the evaluation context, and the flag context. + * The flag is passed through as the raw JSON received from the relay proxy, so that no field the + * engine understands can be lost on the way in. */ @Data @Builder @@ -17,7 +19,7 @@ @NoArgsConstructor public class WasmInput { private String flagKey; - private Flag flag; + private JsonNode flag; private Map evalContext; private FlagContext flagContext; } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index fa5a50a758..92d9715b7d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -6,14 +6,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Rule; import dev.openfeature.contrib.providers.gofeatureflag.bean.TrackingEvent; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToRetrieveConfiguration; @@ -50,6 +49,11 @@ public class GoFeatureFlagApiTest { private GoffApiMock goffAPIMock; private HttpUrl baseUrl; + private static final String TEST_FLAG_JSON = + "{\"variations\": {\"off\": false, \"on\": true}, \"defaultRule\": {\"variation\": \"off\"}}"; + private static final String TEST2_FLAG_JSON = + "{\"variations\": {\"off\": false, \"on\": true}, \"defaultRule\": {\"variation\": \"on\"}}"; + @BeforeEach void beforeEach(TestInfo testInfo) throws IOException { this.server = new MockWebServer(); @@ -528,6 +532,27 @@ public void requestShouldKeepThePathPrefixOfAnEndpointWithoutTrailingSlash() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("a flag should be kept opaque, including fields this provider has no model for") + @Test + public void aFlagShouldBeKeptOpaque() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("unknown-flag-field", Collections.emptyList()); + assertTrue(got.isPresent()); + val flag = got.get().getFlags().get("TEST"); + + // a field the engine may have gained after this provider was written + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"nested\": [1, 2, 3]}"), + flag.get("aFieldFromANewerEngine")); + // and a field the deleted typed model never declared + assertEquals("teamId", flag.get("bucketingKey").asText()); + } + @SneakyThrows @DisplayName("a 200 carrying no flag map should be a failed refresh") @Test @@ -746,26 +771,11 @@ public void requestShouldHaveReturn200SimpleFlags() { val evaluationContextEnrichment = new HashMap(); evaluationContextEnrichment.put("env", "production"); - val flags = new HashMap(); - val variations = new HashMap(); - variations.put("on", true); - variations.put("off", false); - val rule = new Rule(); - rule.setVariation("off"); - - val rule2 = new Rule(); - rule2.setVariation("on"); - - val flag1 = new Flag(); - flag1.setVariations(variations); - flag1.setDefaultRule(rule); - - val flag2 = new Flag(); - flag2.setVariations(variations); - flag2.setDefaultRule(rule2); - - flags.put("TEST", flag1); - flags.put("TEST2", flag2); + // flags are compared as raw JSON: the provider must not reconstruct them from a typed + // model, so there is no model here to compare against either. + val flags = new HashMap(); + flags.put("TEST", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST_FLAG_JSON)); + flags.put("TEST2", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST2_FLAG_JSON)); val want = FlagConfigResponse.builder() .flags(flags) .etag("\"valid-flag-config.json\"") @@ -791,26 +801,11 @@ public void requestShouldNotReturnLastModifiedDateIfInvalidHeader() { val evaluationContextEnrichment = new HashMap(); evaluationContextEnrichment.put("env", "production"); - val flags = new HashMap(); - val variations = new HashMap(); - variations.put("on", true); - variations.put("off", false); - val rule = new Rule(); - rule.setVariation("off"); - - val rule2 = new Rule(); - rule2.setVariation("on"); - - val flag1 = new Flag(); - flag1.setVariations(variations); - flag1.setDefaultRule(rule); - - val flag2 = new Flag(); - flag2.setVariations(variations); - flag2.setDefaultRule(rule2); - - flags.put("TEST", flag1); - flags.put("TEST2", flag2); + // flags are compared as raw JSON: the provider must not reconstruct them from a typed + // model, so there is no model here to compare against either. + val flags = new HashMap(); + flags.put("TEST", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST_FLAG_JSON)); + flags.put("TEST2", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST2_FLAG_JSON)); val want = FlagConfigResponse.builder() .flags(flags) .etag("\"valid-flag-config.json\"") diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 34de12bda6..ad36e4ffe6 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -179,6 +179,15 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { return new MockResponse().setResponseCode(404); case "500": return new MockResponse().setResponseCode(500); + case "unknown-flag-field": + // a flag carrying fields this provider has no model for: they must survive intact + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}," + + " \"aFieldFromANewerEngine\": {\"nested\": [1, 2, 3]}," + + " \"bucketingKey\": \"teamId\"}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-flag-field\""); case "no-flags": // a 200 whose body carries no flags key at all return new MockResponse() diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java new file mode 100644 index 0000000000..72e8e74bc3 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java @@ -0,0 +1,33 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class WasmInputTest { + @SneakyThrows + @DisplayName("the flag should reach the engine exactly as it was received") + @Test + void theFlagShouldReachTheEngineExactlyAsItWasReceived() { + // a flag carrying a field no version of this provider ever modelled, and an explicit null + val rawFlag = "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}," + + "\"nullField\":null,\"aFieldFromANewerEngine\":{\"nested\":[1,2,3]}}"; + + val wasmInput = WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree(rawFlag)) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + + val serialized = new String(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); + val flagSentToEngine = Const.DESERIALIZE_OBJECT_MAPPER.readTree(serialized).get("flag"); + + // byte-for-byte equivalent: no field dropped, reordered into a lossy model or reconstructed, + // and the NON_NULL serialisation inclusion does not strip nulls inside the opaque flag. + assertEquals(Const.DESERIALIZE_OBJECT_MAPPER.readTree(rawFlag), flagSentToEngine); + } +} From 7945ab5a35eeb0267e014e05abdd41db9ea0b1d9 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 22:05:46 +0200 Subject: [PATCH 06/93] fix(go-feature-flag): make in-process initialization safe to call twice GOFF-LIFE-002 requires initialization to be safe to call more than once: a second call must cancel and join any existing polling task and must not start a duplicate. init() overwrote configurationDisposable without disposing it, so the previous RxJava interval subscription was stranded on the io scheduler, polling forever and racing the new poller to write configuration state. destroy() could only dispose the last one, so every re-initialization leaked another. Extract stopPolling(), called from both init() and destroy(). It disposes the subscription and clears the field, so a disposed subscription cannot be disposed twice or mistaken for a live one. Once dispose() returns no further emission reaches the refresh consumer, so a request still in flight cannot reach the configuration state. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 15 ++++ .../evaluator/InProcessEvaluatorTest.java | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 51c99a732b..9653f65f10 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -122,6 +122,9 @@ public boolean isFlagTrackable(final String flagKey) { @Override public void init() { + // We ensure that no polling is happening before starting the initialization. + stopPolling(); + // an empty response means the configuration has not been modified, so the state we already // hold is still current and must not be overwritten. api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()) @@ -137,8 +140,20 @@ public void init() { @Override public void destroy() { + stopPolling(); + } + + /** + * stopPolling cancels the configuration polling task, if one is running. + * Once dispose() returns, the subscription is guaranteed to deliver no further emission to the + * refresh consumer, so a request still in flight can no longer reach the configuration state. The + * field is cleared so that the disposed subscription cannot be disposed, or mistaken for a live + * one, a second time. + */ + private synchronized void stopPolling() { if (this.configurationDisposable != null) { this.configurationDisposable.dispose(); + this.configurationDisposable = null; } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java new file mode 100644 index 0000000000..cbb3f27de3 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -0,0 +1,69 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import java.io.IOException; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class InProcessEvaluatorTest { + private static final long POLLING_INTERVAL_MS = 100L; + + private MockWebServer server; + private GoffApiMock goffApiMock; + + @BeforeEach + void beforeEach() throws IOException { + this.server = new MockWebServer(); + this.goffApiMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + this.server.setDispatcher(goffApiMock.dispatcher); + this.server.start(); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + } + + @SneakyThrows + private InProcessEvaluator evaluator() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + return new InProcessEvaluator(api, options, details -> {}); + } + + @SneakyThrows + @DisplayName("a second init should not leave a second poller running") + @Test + void aSecondInitShouldNotLeaveASecondPollerRunning() { + val evaluator = evaluator(); + evaluator.init(); + evaluator.init(); + + // let both a stranded poller and the live one have several chances to fire + Thread.sleep(POLLING_INTERVAL_MS * 5); + evaluator.destroy(); + + // whatever was in flight when destroy() was called may still land, so settle first + Thread.sleep(POLLING_INTERVAL_MS * 2); + val afterDestroy = goffApiMock.getConfigurationCallCount(); + assertTrue(afterDestroy > 0, "the evaluator should have polled at least once"); + + // a poller stranded by the second init would keep calling the API forever + Thread.sleep(POLLING_INTERVAL_MS * 5); + assertEquals(afterDestroy, goffApiMock.getConfigurationCallCount(), "polling continued after destroy()"); + } +} From 2b307bfaf84f075d1b47d9dadec3d233f0bb697c Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 14 Sep 2026 22:16:15 +0200 Subject: [PATCH 07/93] fix(go-feature-flag): reset the events publisher on re-initialization GOFF-LIFE-003 requires any one-shot shutdown guard to be reset by re-initialization. EventsPublisher had two, and neither was. Its ScheduledExecutorService was a final field built in the constructor, and shutdown() terminated it; an ExecutorService cannot be restarted, so a provider that was shut down and initialized again never flushed again and silently accumulated events. Its isShutdown flag was declared public final and read by add(), but never written anywhere, so a shut down publisher kept accepting events it would never send. Add an idempotent start() that recreates the scheduler and clears the flag, call it from the constructor and from provider initialization, and raise the flag in shutdown() before the final drain so nothing can be enqueued behind it. Both lifecycle methods are synchronized because initialization and shutdown can arrive on different threads. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 3 + .../service/EventsPublisher.java | 27 +++++-- .../service/EventsPublisherTest.java | 72 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 75dba19ec8..481405bcff 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -129,6 +129,9 @@ public ProviderEvaluation getObjectEvaluation( @Override public void initialize(EvaluationContext evaluationContext) throws Exception { super.initialize(evaluationContext); + // re-initialization must reset the publisher: its shutdown flag and its scheduler are both + // one-shot, so without this a provider that is shut down and initialized again never flushes. + this.eventsPublisher.start(); this.evalService.init(); this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); // In case of remote evaluation, we don't need to send the data to the collector diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index 32e1272f7a..1536b08f65 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -32,8 +32,9 @@ public final class EventsPublisher { private final Lock readLock = readWriteLock.readLock(); private final Lock writeLock = readWriteLock.writeLock(); - private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); + private final long flushIntervalMs; private final List eventsList; + private ScheduledExecutorService scheduledExecutorService; /** * Constructor. @@ -47,6 +48,21 @@ public EventsPublisher(Consumer> publisher, long flushIntervalMs, int ma eventsList = new CopyOnWriteArrayList<>(); this.publisher = publisher; this.maxPendingEvents = maxPendingEvents; + this.flushIntervalMs = flushIntervalMs; + start(); + } + + /** + * start schedules the periodic flush. + * Calling start() on a running publisher does nothing, so it is safe to call from both the + * constructor and provider initialization. + */ + public synchronized void start() { + if (scheduledExecutorService != null && !scheduledExecutorService.isShutdown()) { + return; + } + isShutdown.set(false); + scheduledExecutorService = Executors.newScheduledThreadPool(1); log.debug("Scheduling events publishing at fixed rate of {} milliseconds", flushIntervalMs); scheduledExecutorService.scheduleAtFixedRate( this::publish, flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS); @@ -113,10 +129,13 @@ public int publish() { } } - /** Shutdown. */ - public void shutdown() { + /** Shutdown: stop accepting events, drain what is buffered and stop the scheduler. */ + public synchronized void shutdown() { log.info("shutdown, draining remaining events"); + isShutdown.set(true); publish(); - ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); + if (scheduledExecutorService != null) { + ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); + } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java new file mode 100644 index 0000000000..842f49667b --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -0,0 +1,72 @@ +package dev.openfeature.contrib.providers.gofeatureflag.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class EventsPublisherTest { + private static final long FLUSH_INTERVAL_MS = 100L; + + @SneakyThrows + @DisplayName("a publisher restarted after a shutdown should collect and flush again") + @Test + void aPublisherRestartedAfterAShutdownShouldCollectAndFlushAgain() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.add("before-shutdown"); + publisher.shutdown(); + assertEquals(List.of("before-shutdown"), published, "shutdown should drain what is buffered"); + + // the provider being initialized again must reset both one-shot pieces of state + publisher.start(); + publisher.add("after-restart"); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + + assertEquals( + List.of("before-shutdown", "after-restart"), + published, + "a restarted publisher should accept and flush events again"); + assertFalse(publisher.isShutdown.get(), "start() should reset the shutdown flag"); + } + + @SneakyThrows + @DisplayName("a shut down publisher should drop events until it is restarted") + @Test + void aShutDownPublisherShouldDropEventsUntilItIsRestarted() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.shutdown(); + publisher.add("dropped"); + assertTrue(publisher.isShutdown.get(), "the shutdown flag should be raised"); + + // restart, so a scheduler exists again: anything accepted while shut down would surface here + publisher.start(); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + + assertEquals(List.of(), published, "a shut down publisher should not accept new events"); + } + + @SneakyThrows + @DisplayName("start should be a no-op on a running publisher") + @Test + void startShouldBeANoOpOnARunningPublisher() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.start(); + publisher.add("once"); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + publisher.shutdown(); + + assertEquals(List.of("once"), published, "a second scheduler would publish the event twice"); + } +} From a92d6e8f283a4ddc9dcef0b19067afe900516e51 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 15:01:27 +0200 Subject: [PATCH 08/93] fix(go-feature-flag): report PROVIDER_NOT_READY until a configuration is loaded GOFF-LIFE-006 requires evaluations to report PROVIDER_NOT_READY until a flag configuration has been successfully loaded at least once, and forbids FLAG_NOT_FOUND, which misattributes an infrastructure failure to the caller's flag key. The SDK does not cover this. OpenFeatureClient short-circuits only the NOT_READY and FATAL provider states, and FeatureProviderStateManager maps a failed initialize() to ERROR unless the exception is an OpenFeatureError carrying PROVIDER_FATAL - which this provider never throws. The provider also stays bound to its domain when initialization fails, so every subsequent evaluation reached the resolver and answered FLAG_NOT_FOUND for every key. Carry a configurationLoaded marker inside the immutable EvaluatorState snapshot, so readiness and the flag map it describes can never be read out of step, and answer PROVIDER_NOT_READY from evaluate() until a configuration has been applied. The existing state constructor delegates with the marker set, so a state built from a retrieved configuration is loaded by construction. An empty but valid configuration counts as loaded: a relay proxy legitimately serving zero flags must still answer FLAG_NOT_FOUND. destroy() deliberately does not reset the marker, because the stored ETag survives and a re-initialization answered 304 keeps the configuration it already holds. Behaviour change: in-process evaluations before any successful configuration load, and after a failed initialize(), now report PROVIDER_NOT_READY instead of FLAG_NOT_FOUND. Also applies spotless formatting missed by the preceding commits on this branch. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 36 +++++++- .../providers/gofeatureflag/util/Const.java | 4 +- .../GoFeatureFlagProviderTest.java | 26 ++++++ .../api/GoFeatureFlagApiTest.java | 4 +- .../evaluator/InProcessEvaluatorTest.java | 86 ++++++++++++++++++- .../gofeatureflag/util/GoffApiMock.java | 13 ++- .../wasm/bean/WasmInputTest.java | 3 +- 7 files changed, 161 insertions(+), 11 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 9653f65f10..39f5c00b34 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -51,16 +51,39 @@ private static final class EvaluatorState { final Map evaluationContextEnrichment; final String etag; final Date lastUpdate; + /** + * true once a configuration has been retrieved and applied. A configuration carrying an empty + * flag map counts as loaded: an empty configuration is a valid one, not a missing one. + * The marker lives in the snapshot rather than beside it, so that readiness and the flag map + * it describes can never be read out of step. + */ + final boolean configurationLoaded; + + /** the state held until a configuration has been applied for the first time. */ + static EvaluatorState notLoaded() { + return new EvaluatorState(Collections.emptyMap(), null, "", new Date(0), false); + } EvaluatorState( Map flags, Map evaluationContextEnrichment, String etag, Date lastUpdate) { + // only reachable from a retrieved configuration, so it is loaded by construction + this(flags, evaluationContextEnrichment, etag, lastUpdate, true); + } + + private EvaluatorState( + Map flags, + Map evaluationContextEnrichment, + String etag, + Date lastUpdate, + boolean configurationLoaded) { this.flags = flags; this.evaluationContextEnrichment = evaluationContextEnrichment; this.etag = etag; this.lastUpdate = lastUpdate; + this.configurationLoaded = configurationLoaded; } } @@ -78,7 +101,7 @@ public InProcessEvaluator( this.api = api; this.options = options; this.emitProviderConfigurationChanged = emitProviderConfigurationChanged; - this.state = new EvaluatorState(Collections.emptyMap(), null, "", new Date(0)); + this.state = EvaluatorState.notLoaded(); int poolSize = options.getWasmEvaluatorPoolSize() != null ? options.getWasmEvaluatorPoolSize() : Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE; @@ -88,6 +111,17 @@ public InProcessEvaluator( @Override public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { EvaluatorState current = this.state; + // With no configuration ever loaded every key is absent, and answering FLAG_NOT_FOUND would + // blame the caller's flag key for an infrastructure failure. + if (!current.configurationLoaded) { + val notReady = new GoFeatureFlagResponse(); + notReady.setReason(Reason.ERROR.name()); + notReady.setErrorCode(ErrorCode.PROVIDER_NOT_READY.name()); + notReady.setErrorDetails( + "impossible to evaluate flag " + key + ": no flag configuration has been loaded yet"); + notReady.setValue(defaultValue); + return notReady; + } if (current.flags.get(key) == null) { val err = new GoFeatureFlagResponse(); err.setReason(Reason.ERROR.name()); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 10d8167978..da57d5ae46 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -32,7 +32,7 @@ public class Const { public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); public static final ObjectMapper SERIALIZE_OBJECT_MAPPER = new ObjectMapper(); - public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper().setDefaultPropertyInclusion( - com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper() + .setDefaultPropertyInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) .setDateFormat(new StdDateFormat().withColonInTimeZone(true)); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 12a065a45d..e474894144 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -554,6 +554,32 @@ void shouldErrorIfFlagConfigurationEndpointReturn404() { assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); } + @DisplayName("Should report PROVIDER_NOT_READY on evaluation after a failed initialization") + @SneakyThrows + @Test + void shouldReportProviderNotReadyAfterAFailedInitialization() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + assertThrows( + GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + // a failed initialization leaves the provider in ERROR, which the SDK does not + // short-circuit, so the resolver is reached and must answer for itself + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals(false, got.getValue()); + } + } + @DisplayName("Should ignore configuration if etag is different by last-modified is older") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 92d9715b7d..d2ba8e04e3 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -5,8 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; @@ -29,8 +29,8 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Optional; import java.util.Map; +import java.util.Optional; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index cbb3f27de3..3fc612c609 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -1,11 +1,17 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Reason; import java.io.IOException; import lombok.SneakyThrows; import lombok.val; @@ -36,9 +42,9 @@ void afterEach() throws IOException { } @SneakyThrows - private InProcessEvaluator evaluator() { + private InProcessEvaluator evaluator(MockWebServer srv) { val options = GoFeatureFlagProviderOptions.builder() - .endpoint(server.url("").toString()) + .endpoint(srv.url("").toString()) .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); @@ -49,7 +55,7 @@ private InProcessEvaluator evaluator() { @DisplayName("a second init should not leave a second poller running") @Test void aSecondInitShouldNotLeaveASecondPollerRunning() { - val evaluator = evaluator(); + val evaluator = evaluator(this.server); evaluator.init(); evaluator.init(); @@ -66,4 +72,78 @@ void aSecondInitShouldNotLeaveASecondPollerRunning() { Thread.sleep(POLLING_INTERVAL_MS * 5); assertEquals(afterDestroy, goffApiMock.getConfigurationCallCount(), "polling continued after destroy()"); } + + @SneakyThrows + @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") + @Test + void shouldReportProviderNotReadyBeforeAnyConfigurationIsLoaded() { + val got = evaluator(this.server).evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + + assertEquals(ErrorCode.PROVIDER_NOT_READY.name(), got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals(false, got.getValue()); + } + + @SneakyThrows + @DisplayName("should report PROVIDER_NOT_READY, not FLAG_NOT_FOUND, after a failed init") + @Test + void shouldReportProviderNotReadyAfterAFailedInit() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404).dispatcher); + val evaluator = evaluator(s); + assertThrows(FlagConfigurationEndpointNotFound.class, evaluator::init); + + val got = evaluator.evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + + assertEquals(ErrorCode.PROVIDER_NOT_READY.name(), got.getErrorCode()); + // the flag key is not at fault here, the relay proxy is + assertNotEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("should report FLAG_NOT_FOUND for an unknown key once a configuration is loaded") + @Test + void shouldReportFlagNotFoundForAnUnknownKeyOnceLoaded() { + val evaluator = evaluator(this.server); + evaluator.init(); + + val got = evaluator.evaluate("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); + + assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); + evaluator.destroy(); + } + + @SneakyThrows + @DisplayName("an empty but valid configuration counts as loaded") + @Test + void anEmptyButValidConfigurationCountsAsLoaded() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.EMPTY_FLAG_CONFIG).dispatcher); + val evaluator = evaluator(s); + evaluator.init(); + + val got = evaluator.evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + + // a relay proxy legitimately serving zero flags is loaded: the key really is unknown + assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); + evaluator.destroy(); + } + } + + @SneakyThrows + @DisplayName("should stay loaded across destroy and re-init") + @Test + void shouldStayLoadedAcrossDestroyAndReInit() { + val evaluator = evaluator(this.server); + evaluator.init(); + evaluator.destroy(); + evaluator.init(); + + val got = evaluator.evaluate("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); + + // the stored ETag survives destroy(), so a re-init answered 304 must keep serving what it holds + assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); + evaluator.destroy(); + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index ad36e4ffe6..8c6eb440bf 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -70,8 +70,9 @@ public GoffApiMock(final MockMode mode) { @SneakyThrows public MockResponse handleEvaluateFlags(RecordedRequest request) { assert request.getPath() != null; - String flagName = request.getPath().substring(request.getPath().indexOf("/ofrep/v1/evaluate/flags/") - + "/ofrep/v1/evaluate/flags/".length()); + String flagName = request.getPath() + .substring( + request.getPath().indexOf("/ofrep/v1/evaluate/flags/") + "/ofrep/v1/evaluate/flags/".length()); switch (flagName) { case "timeout": Thread.sleep(500); @@ -130,6 +131,13 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { break; case ENDPOINT_ERROR_404: return new MockResponse().setResponseCode(404); + case EMPTY_FLAG_CONFIG: + // a valid configuration that happens to contain no flag + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"empty-flag-config\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); case SERVE_OLD_CONFIGURATION: if (configurationCallCount > 1) { // we serve an old configuration after the 1st call. @@ -251,5 +259,6 @@ public enum MockMode { DEFAULT, SERVE_OLD_CONFIGURATION, SCHEDULED_ROLLOUT_FLAG_CONFIG, + EMPTY_FLAG_CONFIG, } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java index 72e8e74bc3..d7eba237f0 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java @@ -24,7 +24,8 @@ void theFlagShouldReachTheEngineExactlyAsItWasReceived() { .build(); val serialized = new String(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); - val flagSentToEngine = Const.DESERIALIZE_OBJECT_MAPPER.readTree(serialized).get("flag"); + val flagSentToEngine = + Const.DESERIALIZE_OBJECT_MAPPER.readTree(serialized).get("flag"); // byte-for-byte equivalent: no field dropped, reordered into a lossy model or reconstructed, // and the NON_NULL serialisation inclusion does not strip nulls inside the opaque flag. From 2fcc0c6f9c083c33bfceac1c05328052d1807fdd Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 17:36:22 +0200 Subject: [PATCH 09/93] fix(go-feature-flag): put the provider in FATAL when credentials are rejected GOFF-EVT-007 requires an authentication failure during initialization to put the provider in PROVIDER_FATAL, because credentials cannot be repaired by retrying. A 401 or 403 on the flag configuration endpoint threw ImpossibleToRetrieveConfiguration, a plain RuntimeException. FeatureProviderStateManager only sets FATAL when initialize() throws an OpenFeatureError whose error code is PROVIDER_FATAL, so the provider landed in ERROR instead and kept polling a relay proxy that will never accept it. Throw a new AuthenticationFailure, which extends the SDK's FatalError, so the SDK moves the provider to FATAL and the client short-circuits later evaluations with PROVIDER_FATAL. On a polling refresh the same exception is logged and swallowed by the stream, so background refresh still cannot reach the application. Two API tests asserted the previous exception type and are updated. Remote evaluation cannot satisfy this requirement yet: RemoteEvaluator.init() is a no-op, so remote initialization never fails. That is GOFF-EVT-001. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 3 ++- .../exception/AuthenticationFailure.java | 14 +++++++++++ .../GoFeatureFlagProviderTest.java | 23 +++++++++++++++++++ .../api/GoFeatureFlagApiTest.java | 9 ++++---- 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index ad8fa01f55..fd4c2c055f 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -12,6 +12,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToRetrieveConfiguration; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToSendEventsException; @@ -201,7 +202,7 @@ public Optional retrieveFlagConfiguration(final String etag, throw new FlagConfigurationEndpointNotFound(); case HttpURLConnection.HTTP_UNAUTHORIZED: case HttpURLConnection.HTTP_FORBIDDEN: - throw new ImpossibleToRetrieveConfiguration( + throw new AuthenticationFailure( "retrieve flag configuration error: authentication/authorization error"); case HttpURLConnection.HTTP_BAD_REQUEST: throw new ImpossibleToRetrieveConfiguration( diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java new file mode 100644 index 0000000000..ccd3b682e0 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java @@ -0,0 +1,14 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import dev.openfeature.sdk.exceptions.FatalError; +import lombok.experimental.StandardException; + +/** + * Thrown when the relay proxy rejects our credentials (HTTP 401 or 403). + * + *

It extends the SDK's FatalError, whose error code is PROVIDER_FATAL, because that is what the + * SDK inspects to move the provider to the FATAL state: credentials cannot be repaired by retrying, + * so a provider that keeps retrying them would never recover and would hide the real problem.

+ */ +@StandardException +public class AuthenticationFailure extends FatalError {} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index e474894144..2173172f67 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -22,6 +22,7 @@ import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.GeneralError; import java.io.IOException; import java.util.ArrayList; @@ -554,6 +555,28 @@ void shouldErrorIfFlagConfigurationEndpointReturn404() { assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); } + @DisplayName("Should be in FATAL state if the relay proxy rejects the credentials") + @SneakyThrows + @Test + void shouldBeInFatalStateIfTheRelayProxyRejectsTheCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + + assertThrows( + FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); + } + } + @DisplayName("Should report PROVIDER_NOT_READY on evaluation after a failed initialization") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index d2ba8e04e3..7a24e4763a 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -14,6 +14,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.TrackingEvent; +import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToRetrieveConfiguration; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToSendEventsException; @@ -699,9 +700,9 @@ public void requestShouldHaveReturn401() { .endpoint(baseUrl.toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); + // fatal, not retryable: the SDK moves the provider to FATAL on a PROVIDER_FATAL error assertThrows( - ImpossibleToRetrieveConfiguration.class, - () -> api.retrieveFlagConfiguration("401", Collections.emptyList())); + AuthenticationFailure.class, () -> api.retrieveFlagConfiguration("401", Collections.emptyList())); } @SneakyThrows @@ -712,9 +713,9 @@ public void requestShouldHaveReturn403() { .endpoint(baseUrl.toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); + // fatal, not retryable: the SDK moves the provider to FATAL on a PROVIDER_FATAL error assertThrows( - ImpossibleToRetrieveConfiguration.class, - () -> api.retrieveFlagConfiguration("403", Collections.emptyList())); + AuthenticationFailure.class, () -> api.retrieveFlagConfiguration("403", Collections.emptyList())); } @SneakyThrows From 192961ae6930b8596afd977e7b963ecb9b0ad55c Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 17:51:55 +0200 Subject: [PATCH 10/93] fix(go-feature-flag): let the engine decide about a missing targeting key GOFF-CTX-003 requires a missing or empty targeting key to be passed through to the evaluation engine. EvaluationService rejected it client-side with TargetingKeyMissingError before the evaluator was ever called, which breaks every flag that does not bucket: the engine returns TARGETING_KEY_MISSING only for flags that actually need a key. Remove the check. Tests cover both halves: object_key, whose default rule selects a variation directly, now resolves with an empty context, while string_key, whose default rule is a percentage split, still reports TARGETING_KEY_MISSING from the engine. Signed-off-by: Thomas Poignant --- .../service/EvaluationService.java | 5 -- .../GoFeatureFlagProviderTest.java | 48 +++++++++++++++++++ .../evaluator/InProcessEvaluatorTest.java | 28 +++++++++++ 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java index da452d39f7..03a0e5657a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java @@ -9,7 +9,6 @@ import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; import dev.openfeature.sdk.exceptions.TypeMismatchError; import lombok.AllArgsConstructor; import lombok.val; @@ -62,10 +61,6 @@ public void destroy() { public ProviderEvaluation getEvaluation( String flagKey, T defaultValue, EvaluationContext evaluationContext, Class expectedType) { - if (evaluationContext.getTargetingKey() == null) { - throw new TargetingKeyMissingError("GO Feature Flag requires a targeting key"); - } - val goffResp = evaluator.evaluate(flagKey, defaultValue, evaluationContext); // Check for FLAG_NOT_FOUND error first, before general error handling diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 2173172f67..23e8a8c78b 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -15,6 +16,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.MutableStructure; @@ -555,6 +557,52 @@ void shouldErrorIfFlagConfigurationEndpointReturn404() { assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); } + @DisplayName("Should not reject an evaluation context without a targeting key") + @SneakyThrows + @Test + void shouldNotRejectAnEvaluationContextWithoutATargetingKey() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val got = client.getObjectDetails("object_key", new Value("default"), new ImmutableContext()); + + assertNull(got.getErrorCode()); + assertEquals("varA", got.getVariant()); + } + } + + @DisplayName("Should return TARGETING_KEY_MISSING if the flag needs a targeting key to bucket") + @SneakyThrows + @Test + void shouldReturnTargetingKeyMissingIfTheFlagNeedsATargetingKeyToBucket() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key buckets on a percentage default rule, so the engine cannot evaluate it blind + val got = client.getStringDetails("string_key", "default", new ImmutableContext()); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals("default", got.getValue()); + } + } + @DisplayName("Should be in FATAL state if the relay proxy rejects the credentials") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 3fc612c609..d583ac07ff 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -146,4 +146,32 @@ void shouldStayLoadedAcrossDestroyAndReInit() { assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); evaluator.destroy(); } + + @SneakyThrows + @DisplayName("a missing targeting key should be passed through to the engine") + @Test + void aMissingTargetingKeyShouldBePassedThroughToTheEngine() { + val evaluator = evaluator(this.server); + evaluator.init(); + + // object_key resolves through a default rule that does not bucket, so it needs no targeting key + val got = evaluator.evaluate("object_key", null, new ImmutableContext()); + + assertEquals("", got.getErrorCode()); + assertEquals("varA", got.getVariationType()); + evaluator.destroy(); + } + + @SneakyThrows + @DisplayName("the engine should report TARGETING_KEY_MISSING only for a flag that buckets") + @Test + void theEngineShouldReportTargetingKeyMissingOnlyForAFlagThatBuckets() { + val evaluator = evaluator(this.server); + evaluator.init(); + + val got = evaluator.evaluate("string_key", "default", new ImmutableContext()); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING.name(), got.getErrorCode()); + evaluator.destroy(); + } } From ab4bcdd1c3cdb27ee60f36fdd3a438a261d77e78 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 18:20:31 +0200 Subject: [PATCH 11/93] fix(go-feature-flag): merge into the gofeatureflag namespace instead of replacing it GOFF-CTX-006 requires the provider to merge into the gofeatureflag context key, setting or replacing only exporterMetadata and preserving every sibling. The enrichment hook built a fresh single-entry structure and wrote it over the whole key, so a caller-supplied flagList or currentDateTime - both caller inputs the namespace is shared for - was silently destroyed on every evaluation. Copy the caller's entries when the existing value is a structure, then set exporterMetadata. A value that is present but not a structure is still replaced rather than failing, which GOFF-CTX-008 requires; that now follows from an explicit guard rather than from never reading the value at all. Signed-off-by: Thomas Poignant --- .../hook/EnrichEvaluationContextHook.java | 13 +++- .../hook/EnrichEvaluationContextHookTest.java | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java index 68bfa2d93e..a6449d6161 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java @@ -9,11 +9,14 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import lombok.val; /** * EnrichEvaluationContextHook is an OpenFeature Hook in charge of enriching the evaluation context. */ public class EnrichEvaluationContextHook implements Hook { + private static final String GOFEATUREFLAG_KEY = "gofeatureflag"; + private static final String EXPORTER_METADATA_KEY = "exporterMetadata"; private final Map exporterMetadata; public EnrichEvaluationContextHook(Map exporterMetadata) { @@ -53,9 +56,13 @@ public Optional before(HookContext ctx, Map expMetadata = new HashMap<>(); - expMetadata.put("exporterMetadata", new Value(metadata)); - mutableContext.add("gofeatureflag", new MutableStructure(expMetadata)); + Map goffNamespace = new HashMap<>(); + val existing = ctx.getCtx().getValue(GOFEATUREFLAG_KEY); + if (existing != null && existing.isStructure()) { + goffNamespace.putAll(existing.asStructure().asMap()); + } + goffNamespace.put(EXPORTER_METADATA_KEY, new Value(metadata)); + mutableContext.add(GOFEATUREFLAG_KEY, new MutableStructure(goffNamespace)); return Optional.of(mutableContext); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java index b49d95f295..97372aca93 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java @@ -5,7 +5,12 @@ import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.SneakyThrows; import lombok.val; @@ -43,4 +48,70 @@ void shouldReturnSameContextIfMetadataEmpty() { val res = hook.before(hookContext, null); assertEquals(Optional.of(hookContext.getCtx()), res); } + + @DisplayName("Should preserve the caller keys of the gofeatureflag namespace") + @SneakyThrows + @Test + void shouldPreserveTheCallerKeysOfTheGoFeatureFlagNamespace() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerNamespace = new MutableStructure() + .add("flagList", List.of(new Value("flag-a"), new Value("flag-b"))) + .add("currentDateTime", "2026-09-15T00:00:00Z"); + val callerContext = new MutableContext("user-key").add("gofeatureflag", callerNamespace); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + List.of(new Value("flag-a"), new Value("flag-b")), + got.getValue("flagList").asList()); + assertEquals("2026-09-15T00:00:00Z", got.getValue("currentDateTime").asString()); + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } + + @DisplayName("Should replace a caller supplied exporterMetadata") + @SneakyThrows + @Test + void shouldReplaceACallerSuppliedExporterMetadata() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerNamespace = new MutableStructure() + .add("exporterMetadata", new MutableStructure().add("appVersion", "from-the-caller")); + val callerContext = new MutableContext("user-key").add("gofeatureflag", callerNamespace); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } + + @DisplayName("Should replace the gofeatureflag namespace if it is not a structure") + @SneakyThrows + @Test + void shouldReplaceTheGoFeatureFlagNamespaceIfItIsNotAStructure() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerContext = new MutableContext("user-key").add("gofeatureflag", "not-a-structure"); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } } From e3e28e13ba0934fedae3fd50b5be818a78f002e3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 21:52:08 +0200 Subject: [PATCH 12/93] fix(go-feature-flag): accept any integral number in the float resolver GOFF-EVAL-003 requires the float resolver to accept an integral JSON number, since JSON does not distinguish 100 from 100.0. convertValue widened only Integer, which is the Java accident the specification calls out in section 1.5: a number above Integer.MAX_VALUE decodes to Long and then satisfies neither the integer resolver, because it does not fit, nor the double resolver, because the class does not match. A flag carrying 3000000000 was unusable from Java. Widen any Number through doubleValue(). Boolean is not a Number, so it still falls through to the class check and reports TYPE_MISMATCH, and a decimal requested as an integer is unaffected; both are now pinned by tests. Signed-off-by: Thomas Poignant --- .../service/EvaluationService.java | 9 ++- .../service/EvaluationServiceTest.java | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java index 03a0e5657a..2a97504d86 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java @@ -114,6 +114,9 @@ public ProviderEvaluation getEvaluation( * @param the type we want to convert to. * @return A converted object */ + // The cast to T is unchecked on purpose: getEvaluation compares the runtime class with + // expectedType right after and raises a TypeMismatchError if the API returned another type. + @SuppressWarnings("unchecked") private T convertValue(Object value, Class expectedType) { boolean isPrimitive = expectedType == Boolean.class || expectedType == String.class @@ -121,8 +124,10 @@ private T convertValue(Object value, Class expectedType) { || expectedType == Double.class; if (isPrimitive) { - if (value.getClass() == Integer.class && expectedType == Double.class) { - return (T) Double.valueOf((Integer) value); + // JSON does not distinguish 100 from 100.0, and a number too large for an int decodes to + // Long, so the float resolver accepts any number rather than only Integer. + if (expectedType == Double.class && value instanceof Number) { + return (T) Double.valueOf(((Number) value).doubleValue()); } return (T) value; } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java index fb6df43ea7..be54ef024e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java @@ -15,6 +15,7 @@ import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -98,4 +99,61 @@ void shouldHandleSuccessfulEvaluation() { assertEquals("enabled", result.getVariant()); assertEquals(null, result.getErrorCode()); } + + @DisplayName("Should accept an int number for the double resolver") + @Test + void shouldAcceptAnIntegralNumberForTheDoubleResolver() { + assertEquals(100.0, doubleEvaluationOf(100)); + } + + @DisplayName("Should accept an int number larger than an int for the double resolver") + @Test + void shouldAcceptAnIntegralNumberLargerThanAnIntForTheDoubleResolver() { + // above Integer.MAX_VALUE Jackson decodes a JSON integer to Long + assertEquals(3000000000.0, doubleEvaluationOf(3000000000L)); + } + + @DisplayName("Should accept a decimal number for the double resolver") + @Test + void shouldAcceptADecimalNumberForTheDoubleResolver() { + assertEquals(101.25, doubleEvaluationOf(101.25)); + } + + @DisplayName("Should not let a boolean satisfy the double resolver") + @Test + void shouldNotLetABooleanSatisfyTheDoubleResolver() { + when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) + .thenReturn(responseWithValue(true)); + + assertThrows( + TypeMismatchError.class, + () -> evaluationService.getEvaluation("test-flag", 0.0, evaluationContext, Double.class)); + } + + @DisplayName("Should not let a decimal number satisfy the integer resolver") + @Test + void shouldNotLetADecimalNumberSatisfyTheIntegerResolver() { + when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) + .thenReturn(responseWithValue(101.25)); + + assertThrows( + TypeMismatchError.class, + () -> evaluationService.getEvaluation("test-flag", 0, evaluationContext, Integer.class)); + } + + private Double doubleEvaluationOf(Object engineValue) { + when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) + .thenReturn(responseWithValue(engineValue)); + return evaluationService + .getEvaluation("test-flag", 0.0, evaluationContext, Double.class) + .getValue(); + } + + private GoFeatureFlagResponse responseWithValue(Object value) { + GoFeatureFlagResponse response = new GoFeatureFlagResponse(); + response.setValue(value); + response.setReason(Reason.TARGETING_MATCH.name()); + response.setVariationType("enabled"); + return response; + } } From 595d7353935ee2d224150585abf58662f5899287 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 22:17:29 +0200 Subject: [PATCH 13/93] fix(go-feature-flag): return the caller default when the engine value is null GOFF-EVAL-006 requires a null evaluation result to return the caller's default value rather than the language's zero value. The null reached flagValue.getClass() and raised a NullPointerException, which the SDK swallowed into a GENERAL error, so the caller got a result labelled as a failure and lost the engine's details entirely. Guard the null before conversion and return the caller's default with the flag metadata carried through. Deviation from the specification, deliberate: GOFF-EVAL-006 also asks for the engine's reason and variant to be preserved. This reports reason DEFAULT and no variant instead, because the variant the engine named describes a value that is not the one being returned. Signed-off-by: Thomas Poignant --- .../service/EvaluationService.java | 8 ++++ .../service/EvaluationServiceTest.java | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java index 2a97504d86..1aa17c8a4b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java @@ -89,6 +89,14 @@ public ProviderEvaluation getEvaluation( .build(); } + if (goffResp.getValue() == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(MetadataUtil.convertFlagMetadata(goffResp.getMetadata())) + .build(); + } + // Convert the value received from the API. T flagValue = convertValue(goffResp.getValue(), expectedType); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java index be54ef024e..3438f53f7e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -14,8 +15,10 @@ import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -156,4 +159,46 @@ private GoFeatureFlagResponse responseWithValue(Object value) { response.setVariationType("enabled"); return response; } + + @DisplayName("Should return the caller default and keep the engine details when the value is null") + @Test + void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { + GoFeatureFlagResponse response = responseWithValue(null); + response.setMetadata(Map.of("description", "a flag with no value")); + + when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) + .thenReturn(response); + + ProviderEvaluation result = + evaluationService.getEvaluation("test-flag", true, evaluationContext, Boolean.class); + + assertEquals(true, result.getValue()); + assertEquals(Reason.DEFAULT.name(), result.getReason()); + assertNull(result.getVariant()); + assertEquals("a flag with no value", result.getFlagMetadata().getString("description")); + assertNull(result.getErrorCode()); + } + + @DisplayName("Should not return a zero value when the value is null") + @Test + void shouldNotReturnAZeroValueWhenTheValueIsNull() { + when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) + .thenReturn(responseWithValue(null)); + + assertEquals( + 42, + evaluationService + .getEvaluation("test-flag", 42, evaluationContext, Integer.class) + .getValue()); + assertEquals( + "caller-default", + evaluationService + .getEvaluation("test-flag", "caller-default", evaluationContext, String.class) + .getValue()); + assertEquals( + new Value("caller-default"), + evaluationService + .getEvaluation("test-flag", new Value("caller-default"), evaluationContext, Value.class) + .getValue()); + } } From 1f7ccefa482f3f68602c6dfd335c00226b9a3228 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 22:35:27 +0200 Subject: [PATCH 14/93] fix(go-feature-flag): discard and rebuild a WASM instance whose guest faulted GOFF-WASM-008 requires a trapped instance to be discarded and rebuilt: a trap does not unwind the module's shadow-stack pointer, so the instance is permanently poisoned and must never be returned to a pool or reused. Chicory surfaces a trap as TrapException or WasmRuntimeException, both of which extend ChicoryException, so the existing catch (Exception) absorbed them indistinguishably from a JSON failure and the finally block handed the instance straight back to the pool, where its corrupted state served every later evaluation. Mark the instance poisoned from a dedicated catch of the guest fault types, and have the pool drop it and build a replacement. Host-side failures such as a serialisation error do not poison, because the instance is still healthy. If the replacement cannot be built the pool shrinks rather than handing out a corrupted instance. A package-private constructor takes an instance factory so the rebuild can be tested; the public constructor delegates to it. Testing note: the 0.2.4 binary answers a guarded input with a structured PARSE_ERROR instead of trapping, so a real trap could not be provoked. The discard path is covered with prepared instances, and a test pins that a guarded input does not poison, since over-discarding would rebuild an instance on every malformed context. GOFF-WASM-012, which forbids calling free on a trapped instance, is not yet addressed: the finally block still frees the input pointer on the trap path. Signed-off-by: Thomas Poignant --- .../gofeatureflag/wasm/EvaluationWasm.java | 27 +++- .../gofeatureflag/wasm/WasmEvaluatorPool.java | 43 +++++- .../wasm/WasmEvaluatorPoolTest.java | 143 ++++++++++++++++++ 3 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index e413913dc3..46debb15c4 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -7,9 +7,11 @@ import com.dylibso.chicory.runtime.ImportValues; import com.dylibso.chicory.runtime.Instance; import com.dylibso.chicory.runtime.Memory; +import com.dylibso.chicory.runtime.WasmException; import com.dylibso.chicory.wasi.WasiExitException; import com.dylibso.chicory.wasi.WasiOptions; import com.dylibso.chicory.wasi.WasiPreview1; +import com.dylibso.chicory.wasm.ChicoryException; import com.dylibso.chicory.wasm.types.ValueType; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; @@ -22,6 +24,7 @@ import java.util.Collections; import java.util.List; import java.util.stream.Collectors; +import lombok.Getter; import lombok.val; /** @@ -34,6 +37,13 @@ public final class EvaluationWasm { private final ExportFunction malloc; private final ExportFunction free; + /** + * poisoned is set when the guest faults. A trap does not unwind the module's shadow-stack + * pointer, so the instance is permanently unusable and must never serve another evaluation. + */ + @Getter + private volatile boolean poisoned; + /** * Constructor of the EvaluationWasm. * It initializes the WASM module and the host functions. @@ -126,16 +136,23 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { // Convert the output to a WasmOutput object return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); + } catch (ChicoryException | WasmException e) { + this.poisoned = true; + return errorResponse(e); } catch (Exception e) { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.GENERAL.name()); - response.setReason(Reason.ERROR.name()); - response.setErrorDetails(e.getMessage()); - return response; + return errorResponse(e); } finally { if (len > 0) { this.free.apply(ptr, len); } } } + + private GoFeatureFlagResponse errorResponse(final Exception e) { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.GENERAL.name()); + response.setReason(Reason.ERROR.name()); + response.setErrorDetails(e.getMessage()); + return response; + } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java index dd7a2a99f6..86a79edd2d 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -7,6 +7,7 @@ import dev.openfeature.sdk.Reason; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; /** @@ -17,24 +18,34 @@ @Slf4j public final class WasmEvaluatorPool { private final BlockingQueue pool; + private final Supplier instanceFactory; /** * Creates a pool of {@code size} independent EvaluationWasm instances. * All instances are allocated eagerly so that first-call latency is - * absorbed at provider initialisation time. + * absorbed in provider initialisation time. * * @param size number of WASM instances; must be >= 1 * @throws WasmFileNotFound if the embedded WASM module cannot be loaded */ public WasmEvaluatorPool(int size) throws WasmFileNotFound { + this(size, EvaluationWasm::new); + } + + WasmEvaluatorPool(int size, Supplier instanceFactory) throws WasmFileNotFound { + this.instanceFactory = instanceFactory; this.pool = new ArrayBlockingQueue<>(size); for (int i = 0; i < size; i++) { - EvaluationWasm instance = new EvaluationWasm(); - instance.preWarmWasm(); - pool.add(instance); + pool.add(newInstance()); } } + private EvaluationWasm newInstance() { + EvaluationWasm instance = instanceFactory.get(); + instance.preWarmWasm(); + return instance; + } + /** * Evaluates a feature flag by borrowing one WASM instance from the pool, * delegating to it, and returning it when done. @@ -58,9 +69,29 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { try { return instance.evaluate(wasmInput); } finally { - if (!pool.offer(instance)) { - log.error("Failed to return WASM instance to pool — instance leaked, pool capacity may be compromised"); + returnToPool(instance); + } + } + + /** + * returnToPool gives the instance back, replacing it first if the guest faulted while it was + * serving. A trapped instance is permanently poisoned and must never be reused, so it is dropped + * and a fresh one takes its place; if the replacement cannot be built the pool simply shrinks, + * which is preferable to handing out a corrupted instance. + */ + private void returnToPool(final EvaluationWasm instance) { + EvaluationWasm toReturn = instance; + if (instance.isPoisoned()) { + log.warn("discarding a WASM instance whose guest faulted, and rebuilding it"); + try { + toReturn = newInstance(); + } catch (Exception e) { + log.error("failed to rebuild a WASM instance, the evaluation pool has shrunk", e); + return; } } + if (!pool.offer(toReturn)) { + log.error("Failed to return WASM instance to pool - instance leaked, pool capacity may be compromised"); + } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java new file mode 100644 index 0000000000..0e8a1a862b --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -0,0 +1,143 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class WasmEvaluatorPoolTest { + + private static WasmInputFixture input() { + return new WasmInputFixture(); + } + + private static final class WasmInputFixture { + private final dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput value; + + @SneakyThrows + private WasmInputFixture() { + this.value = dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree( + "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}}")) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + } + } + + /** a factory handing out prepared instances, recording every one it is asked for. */ + private static final class RecordingFactory implements Supplier { + private final List handedOut = new ArrayList<>(); + private final List queue; + + private RecordingFactory(List queue) { + this.queue = new ArrayList<>(queue); + } + + @Override + public EvaluationWasm get() { + val instance = queue.remove(0); + handedOut.add(instance); + return instance; + } + } + + private static EvaluationWasm instanceThatIsPoisonedAfterEvaluating(boolean poisoned) { + val instance = mock(EvaluationWasm.class); + when(instance.evaluate(any())).thenReturn(new GoFeatureFlagResponse()); + when(instance.isPoisoned()).thenReturn(poisoned); + return instance; + } + + @SneakyThrows + @DisplayName("a trapped instance should be discarded and rebuilt, never served again") + @Test + void aTrappedInstanceShouldBeDiscardedAndRebuiltNeverServedAgain() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val replacement = instanceThatIsPoisonedAfterEvaluating(false); + val factory = new RecordingFactory(List.of(trapped, replacement)); + + val pool = new WasmEvaluatorPool(1, factory); + pool.evaluate(input().value); + + assertEquals(List.of(trapped, replacement), factory.handedOut, "a replacement should have been built"); + + // the very next evaluation must not land on the poisoned instance + pool.evaluate(input().value); + assertNotSame(trapped, factory.handedOut.get(1)); + } + + @SneakyThrows + @DisplayName("a healthy instance should be returned to the pool unchanged") + @Test + void aHealthyInstanceShouldBeReturnedToThePoolUnchanged() { + val healthy = instanceThatIsPoisonedAfterEvaluating(false); + val factory = new RecordingFactory(List.of(healthy)); + + val pool = new WasmEvaluatorPool(1, factory); + pool.evaluate(input().value); + pool.evaluate(input().value); + + assertEquals(1, factory.handedOut.size(), "no instance should have been rebuilt"); + assertSame(healthy, factory.handedOut.get(0)); + } + + @SneakyThrows + @DisplayName("a real instance should not be poisoned by a successful evaluation") + @Test + void aRealInstanceShouldNotBePoisonedByASuccessfulEvaluation() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val got = instance.evaluate(input().value); + + assertEquals(true, got.getValue()); + assertFalse(instance.isPoisoned()); + } + + @SneakyThrows + @DisplayName("a guarded input should answer PARSE_ERROR without poisoning the instance") + @Test + void aGuardedInputShouldAnswerParseErrorWithoutPoisoningTheInstance() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val deep = new StringBuilder(); + for (int i = 0; i < 400; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + for (int i = 0; i < 400; i++) { + deep.append("}"); + } + val evalContext = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"targetingKey\":\"k\",\"deep\":" + deep + "}", java.util.Map.class); + + val wasmInput = dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree( + "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}}")) + .evalContext(evalContext) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + + val got = instance.evaluate(wasmInput); + + assertEquals("PARSE_ERROR", got.getErrorCode()); + assertFalse(instance.isPoisoned(), "a guarded input is not a guest fault, the instance is still healthy"); + } +} From 5b36108f0456daa5b86904dbb22d1a663e267a93 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 22:51:40 +0200 Subject: [PATCH 15/93] chore(go-feature-flag): use the non-deprecated Chicory HostFunction constructor The custom proc_exit host function was built through the HostFunction constructor taking two List arguments. Chicory deprecated that overload in 1.3.0, along with the whole ValueType enum, in favour of passing a FunctionType built from ValType. The provider is on Chicory 1.7.5, so both still resolve but the module no longer compiles clean. proc_exit takes a single i32 and returns nothing, which is exactly what FunctionType.accepting builds, so the signature carries over without an explicit empty return list. The HostFunction class itself is not deprecated and the handler body is untouched, so this is a pure constructor swap with no behavioural change. The remaining deprecation warnings in the module come from the OpenFeature SDK's HookContext.from in EnrichEvaluationContextHookTest and are unrelated. A follow-up can remove this host function altogether: WasiOptions now exposes withThrowOnExit0(false), which makes Chicory's own proc_exit raise ExecutionCompletedException on a zero exit code, and Instance already swallows that around _start. Signed-off-by: Thomas Poignant --- .../providers/gofeatureflag/wasm/EvaluationWasm.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 46debb15c4..486f4c6c12 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -12,7 +12,8 @@ import com.dylibso.chicory.wasi.WasiOptions; import com.dylibso.chicory.wasi.WasiPreview1; import com.dylibso.chicory.wasm.ChicoryException; -import com.dylibso.chicory.wasm.types.ValueType; +import com.dylibso.chicory.wasm.types.FunctionType; +import com.dylibso.chicory.wasm.types.ValType; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -21,7 +22,6 @@ import dev.openfeature.sdk.Reason; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import lombok.Getter; @@ -81,11 +81,7 @@ private ImportFunction replaceProcExit(HostFunction hf) { */ private ImportFunction getProcExitFunc() { return new HostFunction( - "wasi_snapshot_preview1", - "proc_exit", - Collections.singletonList(ValueType.I32), - Collections.emptyList(), - (instance, args) -> { + "wasi_snapshot_preview1", "proc_exit", FunctionType.accepting(ValType.I32), (instance, args) -> { if ((int) args[0] != 0) { throw new WasiExitException((int) args[0]); } From 25fe65b136df22e710d9f85aeb7cc7c153d88309 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 22:56:50 +0200 Subject: [PATCH 16/93] refactor(go-feature-flag): let WASI handle a zero exit code instead of a custom proc_exit The module ends _start with proc_exit(0), which Chicory reports by default as a WasiExitException, so the provider replaced the WASI proc_exit import with its own host function that swallowed a zero code and only rethrew for a non-zero one. WasiOptions now covers that case directly: withThrowOnExit0(false) makes Chicory's own procExit raise ExecutionCompletedException on a zero code, and Instance.instantiate already catches that around the _start export. The custom import, the stream that spliced it into the WASI host functions, and eight imports go away with it. The remaining delta is that proc_exit(0) now unwinds rather than returning into the guest, which during _start is what the guest expects anyway. Were it ever raised from inside evaluate, ExecutionCompletedException extends ChicoryException, so the existing handler treats it as a guest fault and poisons the instance rather than continuing on a module that has already exited. The comment about capturing stdout and stderr in two output streams described something the code never did, and is replaced by a note on why the option is set. Signed-off-by: Thomas Poignant --- .../gofeatureflag/wasm/EvaluationWasm.java | 42 ++++--------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 486f4c6c12..53d33dab0c 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -2,18 +2,13 @@ import com.dylibso.chicory.runtime.ByteArrayMemory; import com.dylibso.chicory.runtime.ExportFunction; -import com.dylibso.chicory.runtime.HostFunction; -import com.dylibso.chicory.runtime.ImportFunction; import com.dylibso.chicory.runtime.ImportValues; import com.dylibso.chicory.runtime.Instance; import com.dylibso.chicory.runtime.Memory; import com.dylibso.chicory.runtime.WasmException; -import com.dylibso.chicory.wasi.WasiExitException; import com.dylibso.chicory.wasi.WasiOptions; import com.dylibso.chicory.wasi.WasiPreview1; import com.dylibso.chicory.wasm.ChicoryException; -import com.dylibso.chicory.wasm.types.FunctionType; -import com.dylibso.chicory.wasm.types.ValType; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -21,9 +16,6 @@ import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.Reason; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; import lombok.Getter; import lombok.val; @@ -51,44 +43,24 @@ public final class EvaluationWasm { * @throws WasmFileNotFound - if the WASM file is not found */ public EvaluationWasm() throws WasmFileNotFound { - // We will create two output streams to capture stdout and stderr val wasi = WasiPreview1.builder() - .withOptions(WasiOptions.builder().inheritSystem().build()) + .withOptions(WasiOptions.builder() + .inheritSystem() + .withThrowOnExit0(false) + .build()) .build(); - List hostFunctions = - Arrays.stream(wasi.toHostFunctions()).map(this::replaceProcExit).collect(Collectors.toList()); this.instance = Instance.builder(Module.load()) .withMemoryFactory(ByteArrayMemory::new) .withMachineFactory(Module::create) - .withImportValues( - ImportValues.builder().withFunctions(hostFunctions).build()) + .withImportValues(ImportValues.builder() + .addFunction(wasi.toHostFunctions()) + .build()) .build(); this.evaluate = this.instance.export("evaluate"); this.malloc = this.instance.export("malloc"); this.free = this.instance.export("free"); } - private ImportFunction replaceProcExit(HostFunction hf) { - return hf.name().equals("proc_exit") ? getProcExitFunc() : hf; - } - - /** - * getProcExitFunc is a function that is called when the WASM module calls - * proc_exit. It throws a WasiExitException with the exit code. - * By default, the exit code is 0, and it raises an Exception. - * - * @return a HostFunction that is called when the WASM module calls proc_exit - */ - private ImportFunction getProcExitFunc() { - return new HostFunction( - "wasi_snapshot_preview1", "proc_exit", FunctionType.accepting(ValType.I32), (instance, args) -> { - if ((int) args[0] != 0) { - throw new WasiExitException((int) args[0]); - } - return null; - }); - } - /** * preWarmWasm is a function that is called to pre-warm the WASM module * It calls the malloc function to allocate memory for the WASM module From da5963d4b248a32efc0c5747d98efcc0669ea48b Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 23:01:43 +0200 Subject: [PATCH 17/93] fix(go-feature-flag): close the WASI instance when a WASM evaluator is discarded WasiPreview1 is AutoCloseable and owns the descriptors backing the guest's stdio, but it was built as a local in the EvaluationWasm constructor and never released. Every instance the pool discarded after a guest fault, and every instance still held at provider shutdown, kept its descriptors. It cannot be closed at the end of the constructor: the WASI object serves the module's imports for as long as the instance is alive, so wrapping the builder in try-with-resources would release it while evaluations still depend on it. Give it an owner instead. EvaluationWasm keeps it in a field and implements AutoCloseable, the pool closes an instance when it discards one and when it is itself closed, and InProcessEvaluator.destroy closes the pool, which the provider's shutdown already reaches through evalService.destroy. Releasing is best effort: a descriptor that refuses to close must not abort a shutdown, nor the rebuild of an instance whose guest faulted. Closing the pool also has to stop evaluations rather than starve them. A drained queue would leave pool.take blocking forever, so a closed pool answers with an error instead. The interrupted path already built that same response, so both now go through one helper. That inheritSystem allocates only non-Closeable stream descriptors today, which makes close a no-op, is a Chicory implementation detail and not something to rely on. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 1 + .../gofeatureflag/wasm/EvaluationWasm.java | 17 ++++-- .../gofeatureflag/wasm/WasmEvaluatorPool.java | 54 ++++++++++++++++--- .../wasm/WasmEvaluatorPoolTest.java | 46 ++++++++++++++++ 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 39f5c00b34..ab9a3610fd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -175,6 +175,7 @@ public void init() { @Override public void destroy() { stopPolling(); + this.evaluationPool.close(); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 53d33dab0c..fcfeed5ef3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -23,7 +23,8 @@ * EvaluationWasm is a class that represents the evaluation of a feature flag * it calls an external WASM module to evaluate the feature flag. */ -public final class EvaluationWasm { +public final class EvaluationWasm implements AutoCloseable { + private final WasiPreview1 wasi; private final Instance instance; private final ExportFunction evaluate; private final ExportFunction malloc; @@ -43,7 +44,7 @@ public final class EvaluationWasm { * @throws WasmFileNotFound - if the WASM file is not found */ public EvaluationWasm() throws WasmFileNotFound { - val wasi = WasiPreview1.builder() + this.wasi = WasiPreview1.builder() .withOptions(WasiOptions.builder() .inheritSystem() .withThrowOnExit0(false) @@ -53,7 +54,7 @@ public EvaluationWasm() throws WasmFileNotFound { .withMemoryFactory(ByteArrayMemory::new) .withMachineFactory(Module::create) .withImportValues(ImportValues.builder() - .addFunction(wasi.toHostFunctions()) + .addFunction(this.wasi.toHostFunctions()) .build()) .build(); this.evaluate = this.instance.export("evaluate"); @@ -61,6 +62,16 @@ public EvaluationWasm() throws WasmFileNotFound { this.free = this.instance.export("free"); } + /** + * close releases the descriptors the WASI instance owns. The WASI object serves the guest's + * imports for as long as the module is alive, so it can only be released once this evaluator is + * discarded, never at the end of the constructor. + */ + @Override + public void close() { + this.wasi.close(); + } + /** * preWarmWasm is a function that is called to pre-warm the WASM module * It calls the malloc function to allocate memory for the WASM module diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java index 86a79edd2d..416827cae0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -16,10 +16,12 @@ * evaluate() calls without interleaving memory operations. */ @Slf4j -public final class WasmEvaluatorPool { +public final class WasmEvaluatorPool implements AutoCloseable { private final BlockingQueue pool; private final Supplier instanceFactory; + private volatile boolean closed; + /** * Creates a pool of {@code size} independent EvaluationWasm instances. * All instances are allocated eagerly so that first-call latency is @@ -55,16 +57,15 @@ private EvaluationWasm newInstance() { * @return evaluation result */ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { + if (closed) { + return errorResponse("WASM evaluator pool is closed"); + } EvaluationWasm instance; try { instance = pool.take(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - GoFeatureFlagResponse err = new GoFeatureFlagResponse(); - err.setErrorCode(ErrorCode.GENERAL.name()); - err.setReason(Reason.ERROR.name()); - err.setErrorDetails("WASM evaluator pool interrupted while waiting for an available instance"); - return err; + return errorResponse("WASM evaluator pool interrupted while waiting for an available instance"); } try { return instance.evaluate(wasmInput); @@ -83,6 +84,7 @@ private void returnToPool(final EvaluationWasm instance) { EvaluationWasm toReturn = instance; if (instance.isPoisoned()) { log.warn("discarding a WASM instance whose guest faulted, and rebuilding it"); + closeQuietly(instance); try { toReturn = newInstance(); } catch (Exception e) { @@ -90,8 +92,48 @@ private void returnToPool(final EvaluationWasm instance) { return; } } + if (closed) { + closeQuietly(toReturn); + return; + } if (!pool.offer(toReturn)) { log.error("Failed to return WASM instance to pool - instance leaked, pool capacity may be compromised"); + closeQuietly(toReturn); + } + } + + /** + * close releases every instance the pool owns. An instance still serving an evaluation is not in + * the queue, so returnToPool closes it as soon as it comes back rather than handing it to a pool + * nobody will read again. + */ + @Override + public void close() { + closed = true; + EvaluationWasm instance = pool.poll(); + while (instance != null) { + closeQuietly(instance); + instance = pool.poll(); } } + + /** + * Releasing an instance is best effort: a descriptor that refuses to close must not abort a + * provider shutdown, nor the rebuild of an instance whose guest faulted. + */ + private void closeQuietly(final EvaluationWasm instance) { + try { + instance.close(); + } catch (Exception e) { + log.warn("failed to release a WASM instance", e); + } + } + + private GoFeatureFlagResponse errorResponse(final String details) { + GoFeatureFlagResponse err = new GoFeatureFlagResponse(); + err.setErrorCode(ErrorCode.GENERAL.name()); + err.setReason(Reason.ERROR.name()); + err.setErrorDetails(details); + return err; + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java index 0e8a1a862b..7bcf2c58d1 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -6,11 +6,15 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; @@ -140,4 +144,46 @@ void aGuardedInputShouldAnswerParseErrorWithoutPoisoningTheInstance() { assertEquals("PARSE_ERROR", got.getErrorCode()); assertFalse(instance.isPoisoned(), "a guarded input is not a guest fault, the instance is still healthy"); } + + @SneakyThrows + @DisplayName("closing the pool should release every instance it holds") + @Test + void closingThePoolShouldReleaseEveryInstanceItHolds() { + val first = instanceThatIsPoisonedAfterEvaluating(false); + val second = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(2, new RecordingFactory(List.of(first, second))); + + pool.close(); + + verify(first).close(); + verify(second).close(); + } + + @SneakyThrows + @DisplayName("a discarded instance should be released rather than left holding its descriptors") + @Test + void aDiscardedInstanceShouldBeReleasedRatherThanLeftHoldingItsDescriptors() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val replacement = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(trapped, replacement))); + + pool.evaluate(input().value); + + verify(trapped).close(); + verify(replacement, never()).close(); + } + + @SneakyThrows + @DisplayName("a closed pool should answer with an error instead of blocking on an empty queue") + @Test + void aClosedPoolShouldAnswerWithAnErrorInsteadOfBlockingOnAnEmptyQueue() { + val instance = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(instance))); + pool.close(); + + val got = pool.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } } From 3e35473eb22f6f955ac77f272e29884789000481 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Sep 2026 23:07:03 +0200 Subject: [PATCH 18/93] fix(go-feature-flag): do not free the input buffer of a trapped WASM instance GOFF-WASM-012 forbids calling free on a trapped instance: running further code on it faults inside malloc at a wrapped address and masks the original error. The finally block freed the input pointer unconditionally, and because the fault came from a finally it replaced the error response and escaped evaluate() entirely, past the pool and into the application. Skip the free when the instance is poisoned, and contain a fault raised by free itself so it can no longer discard the real error. Only the input pointer is ever freed; the output buffer belongs to the guest. Not covered by a test: the 0.2.4 binary answers a deeply nested context with a structured PARSE_ERROR and evaluates a 40MB payload successfully, so the trap path cannot be reached from a test. A test written for the healthy path passed with free disabled altogether - the module is TinyGo with its own garbage collector, so the host side free is advisory and a leak is not observable that way - and was dropped rather than kept as a test that cannot fail. Covering this needs a fixture module that traps on demand, which the Appendix B.3 engine ABI vectors will need as well. Signed-off-by: Thomas Poignant --- .../gofeatureflag/wasm/EvaluationWasm.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index fcfeed5ef3..5876d9903f 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -17,12 +17,14 @@ import dev.openfeature.sdk.Reason; import java.nio.charset.StandardCharsets; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import lombok.val; /** * EvaluationWasm is a class that represents the evaluation of a feature flag * it calls an external WASM module to evaluate the feature flag. */ +@Slf4j public final class EvaluationWasm implements AutoCloseable { private final WasiPreview1 wasi; private final Instance instance; @@ -121,9 +123,26 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { } catch (Exception e) { return errorResponse(e); } finally { - if (len > 0) { - this.free.apply(ptr, len); - } + freeInput(ptr, len); + } + } + + /** + * freeInput releases the input buffer, unless the guest faulted while serving this evaluation. + * + *

Calling into a trapped instance faults inside malloc at a wrapped address, which masks the + * original error; the instance is discarded by the pool anyway, so its memory is not worth + * reclaiming. A fault raised by free itself is contained here for the same reason.

+ */ + private void freeInput(final int ptr, final int len) { + if (len <= 0 || this.poisoned) { + return; + } + try { + this.free.apply(ptr, len); + } catch (ChicoryException | WasmException e) { + this.poisoned = true; + log.error("failed to free the WASM input buffer, the instance will be discarded", e); } } From afdc54aafa7c43327c3d971447e19f32f3dc385d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 09:17:07 +0200 Subject: [PATCH 19/93] fix(go-feature-flag): stop holding the buffer lock across the collector call GOFF-COLL-017 forbids achieving single-flight by holding a lock across the HTTP call: enqueuing an event runs inside an evaluation hook, so blocking it couples flag evaluation latency to data collector availability. publish() held the exclusive write lock, the same lock add() takes, for the whole duration of the POST, so a hung collector stalled evaluations for the full HTTP timeout. Make single-flight a separate lock that the buffer never takes, swap the buffer out under the write lock, release it, and only then post. publish() uses tryLock so a caller that finds a flush already running returns instead of queueing behind it; shutdown takes the lock blocking so the final drain cannot be skipped. Swapping the buffer up front would have silently regressed GOFF-COLL-018, which passed only because the old code never removed the events it failed to publish. A failed batch is now re-queued at the head explicitly, and a test pins the order. Signed-off-by: Thomas Poignant --- .../service/EventsPublisher.java | 53 +++++++-- .../service/EventsPublisherTest.java | 103 ++++++++++++++++++ 2 files changed, 147 insertions(+), 9 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index 1536b08f65..051a001bc9 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; @@ -27,6 +28,7 @@ public final class EventsPublisher { public final AtomicBoolean isShutdown = new AtomicBoolean(false); private final int maxPendingEvents; private final Consumer> publisher; + private final Lock publishLock = new ReentrantLock(); private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private final Lock readLock = readWriteLock.readLock(); @@ -109,23 +111,51 @@ public void add(T event) { * @return count of publish events */ public int publish() { - int publishedEvents = 0; + if (!publishLock.tryLock()) { + log.debug("a publish is already in progress, skipping this one"); + return 0; + } + try { + return drainAndPost(); + } finally { + publishLock.unlock(); + } + } + + /** + * drainAndPost swaps the buffer out under the lock, releases it, and only then posts, so the + * data collector's availability cannot hold up an evaluation. A batch that fails to publish goes + * back to the head of the buffer, keeping the events in chronological order. + * + *

Callers must hold {@link #publishLock}.

+ */ + private int drainAndPost() { + List batch; writeLock.lock(); try { if (eventsList.isEmpty()) { log.debug("Not publishing, no events"); - return publishedEvents; + return 0; } - log.info("publishing {} events", eventsList.size()); - publisher.accept(new ArrayList<>(eventsList)); - publishedEvents = eventsList.size(); + batch = new ArrayList<>(eventsList); eventsList.clear(); - return publishedEvents; + } finally { + writeLock.unlock(); + } + + try { + log.info("publishing {} events", batch.size()); + publisher.accept(batch); + return batch.size(); } catch (Exception e) { log.error("Error publishing events", e); + writeLock.lock(); + try { + eventsList.addAll(0, batch); + } finally { + writeLock.unlock(); + } return 0; - } finally { - writeLock.unlock(); } } @@ -133,7 +163,12 @@ public int publish() { public synchronized void shutdown() { log.info("shutdown, draining remaining events"); isShutdown.set(true); - publish(); + publishLock.lock(); + try { + drainAndPost(); + } finally { + publishLock.unlock(); + } if (scheduledExecutorService != null) { ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java index 842f49667b..4b98b6cfc9 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -6,6 +6,9 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import lombok.SneakyThrows; import lombok.val; import org.junit.jupiter.api.DisplayName; @@ -69,4 +72,104 @@ void startShouldBeANoOpOnARunningPublisher() { assertEquals(List.of("once"), published, "a second scheduler would publish the event twice"); } + + @SneakyThrows + @DisplayName("adding an event should not wait for an in-flight publish") + @Test + void addingAnEventShouldNotWaitForAnInFlightPublish() { + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + val flush = new Thread(publisher::publish); + flush.start(); + assertTrue(posting.await(5, TimeUnit.SECONDS), "the publish should have reached the collector"); + + // the collector is still hanging: enqueuing must not block behind it + val added = new CountDownLatch(1); + new Thread(() -> { + publisher.add("while-posting"); + added.countDown(); + }) + .start(); + assertTrue(added.await(2, TimeUnit.SECONDS), "add() blocked behind the data collector"); + + releasePost.countDown(); + flush.join(5000); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("publishing should be single flight") + @Test + void publishingShouldBeSingleFlight() { + val concurrentPosts = new AtomicInteger(); + val maxConcurrentPosts = new AtomicInteger(); + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + maxConcurrentPosts.accumulateAndGet(concurrentPosts.incrementAndGet(), Math::max); + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + concurrentPosts.decrementAndGet(); + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + val flush = new Thread(publisher::publish); + flush.start(); + assertTrue(posting.await(5, TimeUnit.SECONDS)); + + publisher.add("second"); + publisher.publish(); + + releasePost.countDown(); + flush.join(5000); + assertEquals(1, maxConcurrentPosts.get(), "two publishes overlapped"); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("a failed batch should be re-queued in chronological order") + @Test + void aFailedBatchShouldBeRequeuedInChronologicalOrder() { + val attempts = new CopyOnWriteArrayList>(); + val publisher = new EventsPublisher( + batch -> { + attempts.add(List.copyOf(batch)); + if (attempts.size() == 1) { + throw new IllegalStateException("collector is down"); + } + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + publisher.add("second"); + publisher.publish(); + + publisher.add("third"); + publisher.publish(); + + assertEquals(List.of("first", "second"), attempts.get(0)); + assertEquals(List.of("first", "second", "third"), attempts.get(1), "order was not preserved"); + publisher.shutdown(); + } } From 5889dfb565f6b84ec4fe5b8b8fb2320287f6985a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 14:32:17 +0200 Subject: [PATCH 20/93] fix(go-feature-flag): cap the events buffer at twice maxPendingEvents GOFF-COLL-019 requires the buffer to be capped at twice maxPendingEvents, discarding the oldest events on overflow. maxPendingEvents was only a flush trigger and the buffer itself was unbounded, so a data collector outage grew it without limit - especially now that a failed batch is re-queued rather than dropped. Trim the buffer from the head after every insertion, at both entry points: adding an event and re-queueing a batch that failed to publish. Signed-off-by: Thomas Poignant --- .../service/EventsPublisher.java | 17 ++++++++++ .../service/EventsPublisherTest.java | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index 051a001bc9..ea69d107c9 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -99,12 +99,28 @@ public void add(T event) { writeLock.lock(); if (eventsList != null) { eventsList.add(event); + discardOverflow(); } } finally { writeLock.unlock(); } } + /** + * discardOverflow keeps the buffer within twice maxPendingEvents, dropping the oldest events + * first. Without a cap a data collector outage is an unbounded memory leak, and the oldest + * events are the least useful to keep. + * + *

Callers must hold {@link #writeLock}.

+ */ + private void discardOverflow() { + int overflow = eventsList.size() - (2 * maxPendingEvents); + if (overflow > 0) { + log.warn("events buffer is full, discarding the {} oldest events", overflow); + eventsList.subList(0, overflow).clear(); + } + } + /** * publish events. * @@ -152,6 +168,7 @@ private int drainAndPost() { writeLock.lock(); try { eventsList.addAll(0, batch); + discardOverflow(); } finally { writeLock.unlock(); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java index 4b98b6cfc9..7a3d8d8e59 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -8,6 +8,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import lombok.SneakyThrows; import lombok.val; @@ -172,4 +173,36 @@ void aFailedBatchShouldBeRequeuedInChronologicalOrder() { assertEquals(List.of("first", "second", "third"), attempts.get(1), "order was not preserved"); publisher.shutdown(); } + + @SneakyThrows + @DisplayName("the buffer should be capped at twice maxPendingEvents, discarding the oldest") + @Test + void theBufferShouldBeCappedAtTwiceMaxPendingEventsDiscardingTheOldest() { + val attempts = new CopyOnWriteArrayList>(); + val collectorIsDown = new AtomicBoolean(true); + val maxPendingEvents = 4; + val publisher = new EventsPublisher( + batch -> { + attempts.add(List.copyOf(batch)); + if (collectorIsDown.get()) { + throw new IllegalStateException("collector is down"); + } + }, + FLUSH_INTERVAL_MS, + maxPendingEvents); + + // the collector refuses every batch, so nothing ever leaves the buffer + for (int i = 0; i < 40; i++) { + publisher.add("event-" + i); + } + + collectorIsDown.set(false); + publisher.publish(); + + val delivered = attempts.get(attempts.size() - 1); + assertEquals(2 * maxPendingEvents, delivered.size(), "the buffer grew past twice maxPendingEvents"); + assertEquals("event-39", delivered.get(delivered.size() - 1), "the newest event should be kept"); + assertEquals("event-32", delivered.get(0), "the oldest events should be the ones discarded"); + publisher.shutdown(); + } } From 7d52e17a5b45fd4dbe6bc4fd65dc5dbd44e2d8a3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 14:58:29 +0200 Subject: [PATCH 21/93] fix(go-feature-flag): send X-API-Key instead of Authorization Bearer GOFF-AUTH-001 requires the provider to send X-API-Key when an apiKey is configured. It sent Authorization: Bearer instead. The relay proxy accepts both and resolves X-API-Key first, so the switch needs no coordinated server release and does not break existing users. Build every request through prepareHttpRequest, which now takes the optional per-route headers as varargs, so the authentication header cannot drift between the three authenticated endpoints - the reason GOFF-AUTH-002 is assessed separately from GOFF-AUTH-001. This also gives the flag configuration request the per-request timeout it was missing, which GOFF-REM-006 requires. The three tests that asserted the api key now assert the X-API-Key header and that no Authorization header is sent alongside it. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 34 +++++++------------ .../providers/gofeatureflag/util/Const.java | 3 +- .../api/GoFeatureFlagApiTest.java | 21 +++++++----- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index fd4c2c055f..c7361a4266 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -125,9 +125,8 @@ private GoFeatureFlagResponse evaluateFlag( .context(evaluationContext.asObjectMap()) .build(); - HttpRequest request = prepareHttpRequest(url, requestBody); - - HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = + this.httpClient.send(prepareHttpRequest(url, requestBody), HttpResponse.BodyHandlers.ofString()); String body = response.body(); switch (response.statusCode()) { @@ -176,22 +175,11 @@ public Optional retrieveFlagConfiguration(final String etag, val request = new FlagConfigApiRequest(flags == null ? Collections.emptyList() : flags); final URI url = route(Const.PATH_FLAG_CONFIGURATION); - HttpRequest.Builder reqBuilder = - HttpRequest.newBuilder().uri(url).header(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON); - - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); - } - - if (etag != null && !etag.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_IF_NONE_MATCH, etag); - } - - reqBuilder.POST( - HttpRequest.BodyPublishers.ofByteArray(Const.SERIALIZE_OBJECT_MAPPER.writeValueAsBytes(request))); + final HttpRequest httpRequest = etag != null && !etag.isEmpty() + ? prepareHttpRequest(url, request, Const.HTTP_HEADER_IF_NONE_MATCH, etag) + : prepareHttpRequest(url, request); - HttpResponse response = - this.httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()); + HttpResponse response = this.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); String body = response.body(); switch (response.statusCode()) { case HttpURLConnection.HTTP_OK: @@ -329,7 +317,8 @@ private URI route(final String path) { * @return HttpRequest ready to be sent * @throws JsonProcessingException - if an error occurred while processing the json */ - private HttpRequest prepareHttpRequest(final URI url, final T requestBody) throws JsonProcessingException { + private HttpRequest prepareHttpRequest(final URI url, final T requestBody, final String... customHeaders) + throws JsonProcessingException { HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(url) .timeout(Duration.ofMillis(timeout)) @@ -337,10 +326,13 @@ private HttpRequest prepareHttpRequest(final URI url, final T requestBody) t .POST(HttpRequest.BodyPublishers.ofByteArray( Const.SERIALIZE_OBJECT_MAPPER.writeValueAsBytes(requestBody))); - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); + if (customHeaders != null && customHeaders.length > 0) { + reqBuilder.headers(customHeaders); } + if (this.apiKey != null && !this.apiKey.isEmpty()) { + reqBuilder.header(Const.HTTP_HEADER_API_KEY, this.apiKey); + } return reqBuilder.build(); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index da57d5ae46..ae1784f765 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -10,10 +10,9 @@ */ public class Const { // HTTP - public static final String BEARER_TOKEN = "Bearer "; public static final String APPLICATION_JSON = "application/json; charset=utf-8"; public static final String HTTP_HEADER_CONTENT_TYPE = "Content-Type"; - public static final String HTTP_HEADER_AUTHORIZATION = "Authorization"; + public static final String HTTP_HEADER_API_KEY = "X-API-Key"; public static final String HTTP_HEADER_ETAG = "ETag"; public static final String HTTP_HEADER_IF_NONE_MATCH = "If-None-Match"; public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 7a24e4763a..c54a957fa4 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -133,8 +133,9 @@ public void requestShouldHaveAnAPIKey() { val api = GoFeatureFlagApi.builder().options(options).build(); api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); + val request = server.takeRequest(); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + assertNull(request.getHeader("Authorization")); } @SneakyThrows @@ -148,7 +149,7 @@ public void requestShouldNotSetAnAPIKeyIfEmpty() { .build(); val api = GoFeatureFlagApi.builder().options(options).build(); api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - assertNull(server.takeRequest().getHeader("Authorization")); + assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows @@ -307,8 +308,9 @@ public void requestShouldHaveAnAPIKey() { Map exporterMetadata = new HashMap<>(); api.sendEventToDataCollector(events, exporterMetadata); - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); + val request = server.takeRequest(); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + assertNull(request.getHeader("Authorization")); } @SneakyThrows @@ -340,7 +342,7 @@ public void requestShouldNotSetAnAPIKeyIfEmpty() { List events = new ArrayList<>(); Map exporterMetadata = new HashMap<>(); api.sendEventToDataCollector(events, exporterMetadata); - assertNull(server.takeRequest().getHeader("Authorization")); + assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows @@ -487,8 +489,9 @@ public void requestShouldHaveAnAPIKey() { val api = GoFeatureFlagApi.builder().options(options).build(); api.retrieveFlagConfiguration(null, Collections.emptyList()); - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); + val request = server.takeRequest(); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + assertNull(request.getHeader("Authorization")); } @SneakyThrows @@ -646,7 +649,7 @@ public void requestShouldNotSetAnAPIKeyIfEmpty() { .build(); val api = GoFeatureFlagApi.builder().options(options).build(); api.retrieveFlagConfiguration(null, Collections.emptyList()); - assertNull(server.takeRequest().getHeader("Authorization")); + assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows From d0e6e69a0f6d67cba8627dd30a67c12114366f49 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 15:05:11 +0200 Subject: [PATCH 22/93] fix(go-feature-flag): preserve the endpoint path prefix on the evaluation route GOFF-REM-001 requires single flag evaluation to POST to {endpoint}/ofrep/v1/evaluate/flags/{flagKey}, with any path prefix on the endpoint preserved. The URL was built from an absolute path, which RFC 3986 resolves by replacing the base path, so a proxy mounted under a prefix was bypassed. Build it through the route() helper instead. Escape the flag key as well. An unescaped key containing a space made URI resolution throw a raw IllegalArgumentException out of the provider, an unmapped language level exception that GOFF-EVAL-011 forbids, and a key containing a slash addressed a different route entirely. The data collector route still builds its URL the old way; that is GOFF-COLL-001. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 16 +++++++++- .../providers/gofeatureflag/util/Const.java | 1 + .../api/GoFeatureFlagApiTest.java | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index c7361a4266..5adad7efc2 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -28,9 +28,11 @@ import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Collections; @@ -119,7 +121,7 @@ public GoFeatureFlagResponse evaluateFlag(final String key, final EvaluationCont private GoFeatureFlagResponse evaluateFlag( final String key, final EvaluationContext evaluationContext, final int retryCount) throws OpenFeatureError { try { - URI url = this.endpoint.resolve("/ofrep/v1/evaluate/flags/" + key); + URI url = route(Const.PATH_OFREP_EVALUATE + encodePathSegment(key)); val requestBody = OfrepRequest.builder() .context(evaluationContext.asObjectMap()) @@ -297,6 +299,18 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) } } + /** + * encodePathSegment escapes a flag key so that it cannot alter the shape of the URL. A key + * containing a space would otherwise make the URI unparseable, and one containing a slash would + * address a different route. + * + * @param segment - the raw flag key + * @return the key, safe to place in a path + */ + private static String encodePathSegment(final String segment) { + return URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"); + } + /** * route builds the URL of an API route from the configured endpoint, preserving any path prefix * the endpoint carries. Resolving an absolute path such as {@code /v1/flag/configuration} would diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index ae1784f765..e866c9a7bd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -18,6 +18,7 @@ public class Const { public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; + public static final String PATH_OFREP_EVALUATE = "ofrep/v1/evaluate/flags/"; // FLAG CONFIGURATION FIELDS // the only field of a flag configuration a provider may read, everything else is the engine's public static final String FIELD_TRACK_EVENTS = "trackEvents"; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index c54a957fa4..e00bee5bb3 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -22,6 +22,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.MutableTrackingEventDetails; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.InvalidContextError; import java.io.IOException; @@ -121,6 +122,37 @@ public void requestShouldCallTheOfrepEndpoint() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("request should keep the path prefix of the endpoint") + @Test + public void requestShouldKeepThePathPrefixOfTheEndpoint() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); + + val want = "/gofeatureflagproxy/ofrep/v1/evaluate/flags/flag-key"; + assertEquals(want, server.takeRequest().getPath()); + } + + @SneakyThrows + @DisplayName("request should escape a flag key that would otherwise alter the url") + @Test + public void requestShouldEscapeAFlagKeyThatWouldOtherwiseAlterTheUrl() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + // the mock has no fixture under this name, only the shape of the url matters here + assertThrows( + FlagNotFoundError.class, + () -> api.evaluateFlag("my flag/with slash", TestUtils.defaultEvaluationContext)); + + val want = "/ofrep/v1/evaluate/flags/my%20flag%2Fwith%20slash"; + assertEquals(want, server.takeRequest().getPath()); + } + @SneakyThrows @DisplayName("request should have an api key") @Test From 4f97a2f28f76310f2d21ac37ff438b271b7e347e Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 17:54:54 +0200 Subject: [PATCH 23/93] build(go-feature-flag): declare the ofrep and guava dependencies RemoteEvaluator builds an OfrepProviderOptions and passes the request headers as Guava immutable collections, but neither dependency was declared, so the module only compiled by accident of the local repository state. Also bump the test-scoped log4j-slf4j2-impl to 2.26.1. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/pom.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index 8adadac5b9..f84974b86d 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -72,7 +72,7 @@ org.apache.logging.log4j log4j-slf4j2-impl - 2.25.2 + 2.26.1 test @@ -109,6 +109,18 @@ 4.9.8 provided + + + dev.openfeature.contrib.providers + ofrep + 0.0.2 + + + + com.google.guava + guava + 33.4.8-jre + From fef612db2cb4957a91a71e301df821e6758eb2e5 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 17:55:00 +0200 Subject: [PATCH 24/93] refactor(go-feature-flag): resolve the option defaults in the getters Every optional field of GoFeatureFlagProviderOptions shipped as a Java zero value, so each default was applied at the consumption site instead, scattered across four files. Nothing in the options class told a reader what a field would resolve to, the timeout had two divergent defaults - 10000 ms in GoFeatureFlagApi, the OFREP library default in RemoteEvaluator - and the flushIntervalMs javadoc claimed 1000 ms where the code used 60000 ms. Hand-write the getters so they return the documented default when the field is unset. Lombok skips generating a getter that already exists, so the field types and every builder signature are unchanged. The existing Const.DEFAULT_* values are reused; the timeout and the two connexion pool options get a constant on the class, as Const has none. validate() now reads the fields rather than the getters: a defaulted wasmEvaluatorPoolSize is never null, which would have made its guard - and the exporterMetadata type check - dead. Two consequences at sites that keep their old code: RemoteEvaluator only set the OFREP timeout when it was greater than zero, to avoid handing OFREP the unset 0 it rejects. The getter now resolves to 10000 ms, so both evaluation paths share the documented timeout instead of the remote one falling back to the OFREP default. GoFeatureFlagProvider used exporterMetadata being null as the signal that the user configured nothing, and only then stamped provider=java and openfeature=true into the payload sent to the data collector - so the default configuration exported events that identified no provider at all. The getter returns an empty map, the branch collapses, and the markers are always sent. getEndpoint, getApiKey and isDisableDataCollection stay Lombok-generated: the endpoint is mandatory, a null apiKey means no authentication header, and the primitive boolean already defaults to false. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 23 +--- .../GoFeatureFlagProviderOptions.java | 129 ++++++++++++++++-- 2 files changed, 128 insertions(+), 24 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 481405bcff..4a35ff2b37 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -13,7 +13,6 @@ import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; -import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -68,22 +67,14 @@ public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws this.api = GoFeatureFlagApi.builder().options(options).build(); this.evalService = new EvaluationService(getEvaluator(this.api)); - long flushIntervalMs = - (options.getFlushIntervalMs() == null) ? Const.DEFAULT_FLUSH_INTERVAL_MS : options.getFlushIntervalMs(); - int maxPendingEvents = (options.getMaxPendingEvents() == null) - ? Const.DEFAULT_MAX_PENDING_EVENTS - : options.getMaxPendingEvents(); Consumer> publisher = this::publishEvents; - this.eventsPublisher = new EventsPublisher<>(publisher, flushIntervalMs, maxPendingEvents); - - if (options.getExporterMetadata() == null) { - this.exporterMetadata = new HashMap<>(); - } else { - val exp = new HashMap<>(options.getExporterMetadata()); - exp.put("provider", "java"); - exp.put("openfeature", true); - this.exporterMetadata = exp; - } + this.eventsPublisher = + new EventsPublisher<>(publisher, options.getFlushIntervalMs(), options.getMaxPendingEvents()); + + val exp = new HashMap<>(options.getExporterMetadata()); + exp.put("provider", "java"); + exp.put("openfeature", true); + this.exporterMetadata = exp; } @Override diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index 9cd620553b..691a32278b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -4,8 +4,10 @@ import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import java.net.MalformedURLException; import java.net.URL; +import java.util.Collections; import java.util.List; import java.util.Map; import lombok.Builder; @@ -14,10 +16,20 @@ /** * GoFeatureFlagProviderOptions contains the options to initialise the provider. + * + *

Every optional field is unset by default, and its getter resolves the documented default value, + * so the provider and the evaluators can read the options without repeating the fallbacks.

*/ @Builder @Getter public class GoFeatureFlagProviderOptions { + /** Default timeout in millisecond when calling the GO Feature Flag relay proxy API. */ + private static final int DEFAULT_TIMEOUT_MS = 10000; + /** Default maximum number of connexions in the connexion pool. */ + private static final int DEFAULT_MAX_IDLE_CONNECTIONS = 1000; + /** Default time in millisecond we keep the connexion open (2 hours). */ + private static final long DEFAULT_KEEP_ALIVE_DURATION_MS = 7200000L; + /** * evaluationType is the type of evaluation you want to use. * - If you want to have a local evaluation, you should use IN_PROCESS. @@ -38,11 +50,17 @@ public class GoFeatureFlagProviderOptions { /** * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. * Default: 1000 + * + *

This option is currently not used by the provider, the underlying HTTP client does not + * expose this setting.

*/ private int maxIdleConnections; /** * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. Default: * 7200000 (2 hours) + * + *

This option is currently not used by the provider, the underlying HTTP client does not + * expose this setting.

*/ private Long keepAliveDuration; /** @@ -55,7 +73,7 @@ public class GoFeatureFlagProviderOptions { /** * (optional) interval time we publish statistics collection data to the proxy. The parameter is * used only if the cache is enabled, otherwise the collection of the data is done directly when - * calling the evaluation API. default: 1000 ms + * calling the evaluation API. default: 60000 ms (1 minute) */ private Long flushIntervalMs; /** @@ -71,7 +89,7 @@ public class GoFeatureFlagProviderOptions { /** * (optional) exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the - * evaluation data usage. + * evaluation data usage. default: empty */ private Map exporterMetadata; @@ -99,29 +117,124 @@ public class GoFeatureFlagProviderOptions { */ private Integer wasmEvaluatorPoolSize; + /** + * Get the type of evaluation to use. + * + * @return the configured evaluation type, IN_PROCESS if none was set + */ + public EvaluationType getEvaluationType() { + return evaluationType == null ? EvaluationType.IN_PROCESS : evaluationType; + } + + /** + * Get the timeout in millisecond when calling the GO Feature Flag relay proxy API. + * + * @return the configured timeout, 10000 ms if none was set + */ + public int getTimeout() { + return timeout == 0 ? DEFAULT_TIMEOUT_MS : timeout; + } + + /** + * Get the maximum number of connexions in the connexion pool. + * + * @return the configured maximum, 1000 if none was set + */ + public int getMaxIdleConnections() { + return maxIdleConnections == 0 ? DEFAULT_MAX_IDLE_CONNECTIONS : maxIdleConnections; + } + + /** + * Get the time in millisecond we keep the connexion open. + * + * @return the configured duration, 7200000 ms if none was set + */ + public Long getKeepAliveDuration() { + return keepAliveDuration == null ? DEFAULT_KEEP_ALIVE_DURATION_MS : keepAliveDuration; + } + + /** + * Get the interval time we publish statistics collection data to the proxy. + * + * @return the configured interval, 60000 ms if none was set + */ + public Long getFlushIntervalMs() { + return flushIntervalMs == null ? Const.DEFAULT_FLUSH_INTERVAL_MS : flushIntervalMs; + } + + /** + * Get the maximum number of events aggregated before publishing them to the proxy. + * + * @return the configured maximum, 10000 if none was set + */ + public Integer getMaxPendingEvents() { + return maxPendingEvents == null ? Const.DEFAULT_MAX_PENDING_EVENTS : maxPendingEvents; + } + + /** + * Get the metadata sent to the relay proxy when reporting the evaluation data usage. + * + * @return the configured metadata, an empty map if none was set + */ + public Map getExporterMetadata() { + return exporterMetadata == null ? Collections.emptyMap() : exporterMetadata; + } + + /** + * Get the list of flags to load for in process evaluation. + * + * @return the configured list, an empty list if none was set, meaning all the flags are loaded + */ + public List getEvaluationFlagList() { + return evaluationFlagList == null ? Collections.emptyList() : evaluationFlagList; + } + + /** + * Get the interval time we poll the proxy to check if the configuration has changed. + * + * @return the configured interval, 120000 ms if none was set + */ + public Long getFlagChangePollingIntervalMs() { + return flagChangePollingIntervalMs == null + ? Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS + : flagChangePollingIntervalMs; + } + + /** + * Get the number of WASM instances kept in the evaluation pool. + * + * @return the configured pool size, the number of available CPU cores if none was set + */ + public Integer getWasmEvaluatorPoolSize() { + return wasmEvaluatorPoolSize == null ? Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE : wasmEvaluatorPoolSize; + } + /** * Validate the options provided to the provider. * + *

Validation reads the fields directly and not the getters, to check what the caller has + * really set and not the resolved default values.

+ * * @throws InvalidOptions - if options are invalid */ public void validate() throws InvalidOptions { - if (getEndpoint() == null || getEndpoint().isEmpty()) { + if (endpoint == null || endpoint.isEmpty()) { throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider"); } try { - new URL(getEndpoint()); + new URL(endpoint); } catch (MalformedURLException e) { - throw new InvalidEndpoint("malformed endpoint: " + getEndpoint()); + throw new InvalidEndpoint("malformed endpoint: " + endpoint); } - if (getWasmEvaluatorPoolSize() != null && getWasmEvaluatorPoolSize() < 1) { + if (wasmEvaluatorPoolSize != null && wasmEvaluatorPoolSize < 1) { throw new InvalidOptions("wasmEvaluatorPoolSize must be at least 1"); } - if (getExporterMetadata() != null) { + if (exporterMetadata != null) { val acceptableExporterMetadataTypes = List.of("String", "Boolean", "Integer", "Double"); - for (Map.Entry entry : getExporterMetadata().entrySet()) { + for (Map.Entry entry : exporterMetadata.entrySet()) { if (!acceptableExporterMetadataTypes.contains( entry.getValue().getClass().getSimpleName())) { throw new InvalidExporterMetadata( From 5b3227fc7f7085a2541c0ec6781c0fff94de16d3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 17:55:02 +0200 Subject: [PATCH 25/93] style(go-feature-flag): mark the local variables of the enrich hook final Matches how the rest of the hook and the surrounding classes declare locals. Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/EnrichEvaluationContextHook.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java index a6449d6161..9300f6b162 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java @@ -32,10 +32,10 @@ public Optional before(HookContext ctx, Map entry : exporterMetadata.entrySet()) { switch (entry.getValue().getClass().getSimpleName()) { case "String": From 5ae2ca83ce987b27e8892a6414e43fea65a24eb1 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 18:56:35 +0200 Subject: [PATCH 26/93] refactor(go-feature-flag)!: evaluate flags in the evaluators instead of a service layer IEvaluator returned a raw GoFeatureFlagResponse and EvaluationService owned the mapping to ProviderEvaluation. That shape stopped working once remote evaluation moved to OfrepProvider, which returns a ProviderEvaluation directly and never produces a GoFeatureFlagResponse. IEvaluator now exposes the five typed resolvers, so each implementation answers in the SDK's own currency: InProcessEvaluator maps the engine response itself, and RemoteEvaluator delegates. GoFeatureFlagApi.evaluateFlag and the OfrepRequest / OfrepResponse beans go with the service layer, since OFREP now owns the remote evaluation call. An unrecognised engine error code -- including GO Feature Flag's own FLAG_CONFIG -- now maps to GENERAL rather than null (GOFF-ERR-002). The test suite does not build at this commit; it is adapted in the next one. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 30 +-- .../gofeatureflag/api/GoFeatureFlagApi.java | 86 ------- .../gofeatureflag/api/bean/OfrepRequest.java | 14 -- .../gofeatureflag/api/bean/OfrepResponse.java | 53 ----- .../gofeatureflag/evaluator/IEvaluator.java | 36 +-- .../evaluator/InProcessEvaluator.java | 173 ++++++++++++-- .../evaluator/RemoteEvaluator.java | 72 ++++-- .../gofeatureflag/hook/DataCollectorHook.java | 10 +- .../hook/DataCollectorHookOptions.java | 4 +- .../service/EvaluationService.java | 163 ------------- .../api/GoFeatureFlagApiTest.java | 217 ------------------ .../service/EvaluationServiceTest.java | 204 ---------------- 12 files changed, 251 insertions(+), 811 deletions(-) delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java delete mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java delete mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 4a35ff2b37..41a47dddff 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -11,7 +11,6 @@ import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook; import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHookOptions; import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.EvaluationContext; @@ -40,7 +39,7 @@ public final class GoFeatureFlagProvider extends EventProvider implements Tracki /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; /** Service to evaluate the flags. */ - private final EvaluationService evalService; + private final IEvaluator evaluator; /** List of the hooks used by the provider. */ private final List hooks = new ArrayList<>(); /** API layer to contact GO Feature Flag. */ @@ -65,7 +64,7 @@ public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws options.validate(); this.options = options; this.api = GoFeatureFlagApi.builder().options(options).build(); - this.evalService = new EvaluationService(getEvaluator(this.api)); + this.evaluator = getEvaluator(this.api); Consumer> publisher = this::publishEvents; this.eventsPublisher = @@ -90,40 +89,45 @@ public List getProviderHooks() { @Override public ProviderEvaluation getBooleanEvaluation( String key, Boolean defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Boolean.class); + return this.evaluator.getBooleanEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getStringEvaluation( String key, String defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, String.class); + return this.evaluator.getStringEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getIntegerEvaluation( String key, Integer defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Integer.class); + return this.evaluator.getIntegerEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getDoubleEvaluation( String key, Double defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Double.class); + return this.evaluator.getDoubleEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getObjectEvaluation( String key, Value defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Value.class); + return this.evaluator.getObjectEvaluation(key, defaultValue, evaluationContext); } @Override public void initialize(EvaluationContext evaluationContext) throws Exception { + this.initialize(evaluationContext, ""); + } + + @Override + public void initialize(EvaluationContext evaluationContext, String domain) throws Exception { super.initialize(evaluationContext); // re-initialization must reset the publisher: its shutdown flag and its scheduler are both // one-shot, so without this a provider that is shut down and initialized again never flushes. this.eventsPublisher.start(); - this.evalService.init(); + this.evaluator.initialize(evaluationContext, domain); this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); // In case of remote evaluation, we don't need to send the data to the collector // because the relay-proxy will collect events directly server side. @@ -131,7 +135,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { this.dataCollectorHook = new DataCollectorHook(DataCollectorHookOptions.builder() .eventsPublisher(this.eventsPublisher) .collectUnCachedEvaluation(true) - .evalService(this.evalService) + .evaluator(this.evaluator) .build()); this.hooks.add(this.dataCollectorHook); @@ -142,7 +146,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { @Override public void shutdown() { super.shutdown(); - this.evalService.destroy(); + this.evaluator.shutdown(); if (this.dataCollectorHook != null) { this.dataCollectorHook.shutdown(); } @@ -183,13 +187,13 @@ public void track(final String eventName, final EvaluationContext context, final * * @return the evaluator */ - private IEvaluator getEvaluator(GoFeatureFlagApi api) { + private IEvaluator getEvaluator(final GoFeatureFlagApi api) { // Select the evaluator based on the evaluation type if (options.getEvaluationType() == null || options.getEvaluationType() == EvaluationType.IN_PROCESS) { Consumer emitProviderConfigurationChanged = this::emitProviderConfigurationChanged; return new InProcessEvaluator(api, this.options, emitProviderConfigurationChanged); } - return new RemoteEvaluator(api); + return new RemoteEvaluator(this.options); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 5adad7efc2..023e1b08d1 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -7,10 +7,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.api.bean.ExporterRequest; import dev.openfeature.contrib.providers.gofeatureflag.api.bean.FlagConfigApiRequest; import dev.openfeature.contrib.providers.gofeatureflag.api.bean.FlagConfigApiResponse; -import dev.openfeature.contrib.providers.gofeatureflag.api.bean.OfrepRequest; -import dev.openfeature.contrib.providers.gofeatureflag.api.bean.OfrepResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; @@ -19,20 +16,14 @@ import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.InvalidContextError; -import dev.openfeature.sdk.exceptions.OpenFeatureError; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; -import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Collections; @@ -95,71 +86,6 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva .build(); } - /** - * evaluateFlag is calling the GO Feature Flag relay proxy to evaluate the feature flag. - * - * @param key - name of the flag - * @param evaluationContext - context of the evaluation - * @return EvaluationResponse with the evaluation of the flag - * @throws OpenFeatureError - if an error occurred while evaluating the flag - */ - public GoFeatureFlagResponse evaluateFlag(final String key, final EvaluationContext evaluationContext) - throws OpenFeatureError { - return this.evaluateFlag(key, evaluationContext, 0); - } - - /** - * evaluateFlag is calling the GO Feature Flag relay proxy to evaluate the feature flag.\ - * It will retry once if the relay proxy is unavailable. - * - * @param key - name of the flag - * @param evaluationContext - context of the evaluation - * @param retryCount - number of retries already done - * @return EvaluationResponse with the evaluation of the flag - * @throws OpenFeatureError - if an error occurred while evaluating the flag - */ - private GoFeatureFlagResponse evaluateFlag( - final String key, final EvaluationContext evaluationContext, final int retryCount) throws OpenFeatureError { - try { - URI url = route(Const.PATH_OFREP_EVALUATE + encodePathSegment(key)); - - val requestBody = OfrepRequest.builder() - .context(evaluationContext.asObjectMap()) - .build(); - - HttpResponse response = - this.httpClient.send(prepareHttpRequest(url, requestBody), HttpResponse.BodyHandlers.ofString()); - String body = response.body(); - - switch (response.statusCode()) { - case HttpURLConnection.HTTP_OK: - val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, OfrepResponse.class); - return goffResp.toGoFeatureFlagResponse(); - case HttpURLConnection.HTTP_UNAUTHORIZED: - case HttpURLConnection.HTTP_FORBIDDEN: - throw new GeneralError("authentication/authorization error"); - case HttpURLConnection.HTTP_BAD_REQUEST: - throw new InvalidContextError("Invalid context: " + body); - case HttpURLConnection.HTTP_UNAVAILABLE: - // If the relay proxy is unavailable, we can retry once. - if (retryCount < 1) { - log.warn("GO Feature Flag relay proxy is unavailable, retrying evaluation for flag: {}", key); - return this.evaluateFlag(key, evaluationContext, retryCount + 1); - } - throw new GeneralError("Service Unavailable: " + body); - case HttpURLConnection.HTTP_NOT_FOUND: - throw new FlagNotFoundError("Flag " + key + " not found"); - default: - throw new GeneralError("Unknown error while retrieving flag " + body); - } - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new GeneralError("unknown error while retrieving flag " + key, e); - } - } - /** * retrieveFlagConfiguration is calling the GO Feature Flag relay proxy to retrieve the flags' * configuration. @@ -299,18 +225,6 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) } } - /** - * encodePathSegment escapes a flag key so that it cannot alter the shape of the URL. A key - * containing a space would otherwise make the URI unparseable, and one containing a slash would - * address a different route. - * - * @param segment - the raw flag key - * @return the key, safe to place in a path - */ - private static String encodePathSegment(final String segment) { - return URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"); - } - /** * route builds the URL of an API route from the configured endpoint, preserving any path prefix * the endpoint carries. Resolving an absolute path such as {@code /v1/flag/configuration} would diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java deleted file mode 100644 index 479e5f79e8..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import java.util.Map; -import lombok.Builder; -import lombok.Data; - -/** - * Represents the request body for the OFREP API request. - */ -@Data -@Builder -public class OfrepRequest { - private Map context; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java deleted file mode 100644 index e1246aa26b..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import java.util.Map; -import lombok.Data; -import lombok.val; - -/** - * This class represents the response from an OFREP response. - */ -@Data -public class OfrepResponse { - private Object value; - private String key; - private String variant; - private String reason; - private boolean cacheable; - private Map metadata; - - private String errorCode; - private String errorDetails; - - /** - * Converts the OFREP response to a GO Feature Flag response. - * - * @return the converted GO Feature Flag response - */ - public GoFeatureFlagResponse toGoFeatureFlagResponse() { - val goff = new GoFeatureFlagResponse(); - goff.setValue(value); - goff.setVariationType(variant); - goff.setReason(reason); - goff.setErrorCode(errorCode); - goff.setErrorDetails(errorDetails); - goff.setFailed(errorCode != null); - - if (metadata != null) { - val cacheable = metadata.get("gofeatureflag_cacheable"); - if (cacheable instanceof Boolean) { - goff.setCacheable((Boolean) cacheable); - metadata.remove("gofeatureflag_cacheable"); - } - - val version = metadata.get("gofeatureflag_version"); - if (version instanceof String) { - goff.setVersion((String) version); - metadata.remove("gofeatureflag_version"); - } - goff.setMetadata(metadata); - } - return goff; - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java index 78f1d7aed7..0a04e9046b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java @@ -2,37 +2,21 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; /** * IEvaluator is an interface that represents the evaluation of a feature flag. * It can have multiple implementations: REMOTE or IN-PROCESS. */ public interface IEvaluator { - /** - * Initialize the evaluator. - */ - void init(); - - /** - * Destroy the evaluator. - */ - void destroy(); - - /** - * Evaluate the flag. - * - * @param key - name of the flag - * @param defaultValue - default value - * @param evaluationContext - evaluation context - * @return the evaluation response - */ - GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext); - - /** - * Check if the flag is trackable or not. - * - * @param flagKey - name of the flag - * @return true if the flag is trackable, false otherwise - */ + void initialize(final EvaluationContext ctx, final String domain) throws Exception; + void initialize(final EvaluationContext ctx) throws Exception; + void shutdown(); + ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx); + ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx); + ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx); + ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); + ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); boolean isFlagTrackable(String flagKey); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index ab9a3610fd..6ccb2917cc 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -1,18 +1,25 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; +import static dev.openfeature.sdk.Value.objectToValue; + import com.fasterxml.jackson.databind.JsonNode; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.MetadataUtil; import dev.openfeature.contrib.providers.gofeatureflag.wasm.WasmEvaluatorPool; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -108,8 +115,7 @@ public InProcessEvaluator( this.evaluationPool = new WasmEvaluatorPool(poolSize); } - @Override - public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { + private GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { EvaluatorState current = this.state; // With no configuration ever loaded every key is absent, and answering FLAG_NOT_FOUND would // blame the caller's flag key for an infrastructure failure. @@ -143,19 +149,12 @@ public GoFeatureFlagResponse evaluate(String key, Object defaultValue, Evaluatio } @Override - public boolean isFlagTrackable(final String flagKey) { - // trackEvents is the only field of the flag configuration a provider may read: everything - // else belongs to the evaluation engine and is passed through untouched. - JsonNode flag = this.state.flags.get(flagKey); - if (flag == null) { - return false; - } - JsonNode trackEvents = flag.get(Const.FIELD_TRACK_EVENTS); - return trackEvents == null || trackEvents.isNull() || trackEvents.asBoolean(true); + public void initialize(EvaluationContext ctx, String domain) throws Exception { + this.initialize(ctx); } @Override - public void init() { + public void initialize(EvaluationContext ctx) throws Exception { // We ensure that no polling is happening before starting the initialization. stopPolling(); @@ -173,11 +172,159 @@ public void init() { } @Override - public void destroy() { + public void shutdown() { stopPolling(); this.evaluationPool.close(); } + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Boolean.class); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, String.class); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Integer.class); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Double.class); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Value.class); + } + + /** + * genericEvaluation evaluates the flag and converts the engine response into the resolution + * structure expected by the OpenFeature SDK. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @param expectedType - type the resolver called by the SDK is contracted to return + * @param - type of the flag value + * @return the evaluation result for this flag + */ + private ProviderEvaluation genericEvaluation( + final String key, final T defaultValue, final EvaluationContext ctx, final Class expectedType) { + val response = this.evaluate(key, defaultValue, ctx); + + if (ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(response.getErrorCode())) { + throw new FlagNotFoundError("Flag " + key + " was not found in your configuration"); + } + + // the engine reports a successful evaluation with an empty error code, not a null one. + if (response.getErrorCode() != null && !response.getErrorCode().isEmpty()) { + return ProviderEvaluation.builder() + .errorCode(mapErrorCode(response.getErrorCode())) + .errorMessage(response.getErrorDetails()) + .reason(Reason.ERROR.name()) + .value(defaultValue) + .build(); + } + + if (Reason.DISABLED.name().equalsIgnoreCase(response.getReason())) { + // we don't set a variant since we are using the default value, + // and we are not able to know which variant it is. + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(response.getVariationType()) + .reason(Reason.DISABLED.name()) + .build(); + } + + if (response.getValue() == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) + .build(); + } + + T flagValue = convertValue(response.getValue(), expectedType); + if (flagValue.getClass() != expectedType) { + throw new TypeMismatchError(String.format( + "Flag value %s had unexpected type %s, expected %s.", key, flagValue.getClass(), expectedType)); + } + + return ProviderEvaluation.builder() + .errorCode(mapErrorCode(response.getErrorCode())) + .reason(response.getReason()) + .value(flagValue) + .variant(response.getVariationType()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) + .build(); + } + + /** + * convertValue is converting the value returned by the evaluation engine in the right type. + * + * @param value - the value we have received + * @param expectedType - the type we expect for this value + * @param - the type we want to convert to + * @return a converted object + */ + // The cast to T is unchecked on purpose: genericEvaluation compares the runtime class with + // expectedType right after and raises a TypeMismatchError if the engine returned another type. + @SuppressWarnings("unchecked") + private T convertValue(final Object value, final Class expectedType) { + boolean isPrimitive = expectedType == Boolean.class + || expectedType == String.class + || expectedType == Integer.class + || expectedType == Double.class; + + if (isPrimitive) { + // JSON does not distinguish 100 from 100.0, and a number too large for an int decodes to + // Long, so the float resolver accepts any number rather than only Integer. + if (expectedType == Double.class && value instanceof Number) { + return (T) Double.valueOf(((Number) value).doubleValue()); + } + return (T) value; + } + return (T) objectToValue(value); + } + + /** + * mapErrorCode is mapping the error code in string received from the evaluation engine to the + * SDK ErrorCode enum. + * + * @param errorCode - string of the error code received from the evaluation engine + * @return an item from the enum, null if the engine reported no error + */ + private ErrorCode mapErrorCode(final String errorCode) { + if (errorCode == null || errorCode.isEmpty()) { + return null; + } + + try { + return ErrorCode.valueOf(errorCode); + } catch (IllegalArgumentException e) { + // an error the SDK does not know about, such as GO Feature Flag's own FLAG_CONFIG, is + // still an error: reporting it as GENERAL keeps the evaluation from looking successful. + return ErrorCode.GENERAL; + } + } + + @Override + public boolean isFlagTrackable(final String flagKey) { + // trackEvents is the only field of the flag configuration a provider may read: everything + // else belongs to the evaluation engine and is passed through untouched. + JsonNode flag = this.state.flags.get(flagKey); + if (flag == null) { + return false; + } + JsonNode trackEvents = flag.get(Const.FIELD_TRACK_EVENTS); + return trackEvents == null || trackEvents.isNull() || trackEvents.asBoolean(true); + } + /** * stopPolling cancels the configuration polling task, if one is running. * Once dispose() returns, the subscription is guaranteed to deliver no further emission to the diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 15b60c5f85..0a583148ca 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -1,9 +1,17 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; -import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.ofrep.OfrepProvider; +import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; import lombok.extern.slf4j.Slf4j; +import lombok.val; +import java.time.Duration; /** * RemoteEvaluator is an implementation of the IEvaluator interface. @@ -11,21 +19,25 @@ */ @Slf4j public class RemoteEvaluator implements IEvaluator { - /** API to contact GO Feature Flag. */ - public final GoFeatureFlagApi api; + public final OfrepProvider ofrep; /** * Constructor of the evaluator. * - * @param api - api service to evaluate the flags + * @param opts - options to configure the provider */ - public RemoteEvaluator(GoFeatureFlagApi api) { - this.api = api; - } + public RemoteEvaluator(GoFeatureFlagProviderOptions opts) { + val headers = ImmutableMap.>builder(); + if (opts.getApiKey() != null && !opts.getApiKey().isEmpty()) { + headers.put(Const.HTTP_HEADER_API_KEY, ImmutableList.of(opts.getApiKey())); + } - @Override - public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { - return this.api.evaluateFlag(key, evaluationContext); + this.ofrep = OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl(opts.getEndpoint().replaceAll("/+$", "")) + .connectTimeout(Duration.ofMillis(opts.getTimeout())) + .requestTimeout(Duration.ofMillis(opts.getTimeout())) + .headers(headers.build()) + .build()); } @Override @@ -34,12 +46,42 @@ public boolean isFlagTrackable(String flagKey) { } @Override - public void init() { - // do nothing + public void initialize(final EvaluationContext ctx, final String domain) throws Exception { + this.ofrep.initialize(ctx, domain); + } + + @Override + public void initialize(final EvaluationContext ctx) throws Exception { + this.ofrep.initialize(ctx); + } + + @Override + public void shutdown() { + this.ofrep.shutdown(); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return this.ofrep.getBooleanEvaluation(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return this.ofrep.getStringEvaluation(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return this.ofrep.getIntegerEvaluation(key,defaultValue,ctx); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return this.ofrep.getDoubleEvaluation(key, defaultValue, ctx); } @Override - public void destroy() { - // do nothing + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return this.ofrep.getObjectEvaluation(key, defaultValue, ctx); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 4ea6101e40..26f056fc12 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -2,8 +2,8 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.FlagEvaluationDetails; @@ -23,8 +23,8 @@ public final class DataCollectorHook implements Hook> { private final DataCollectorHookOptions options; /** eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ private final EventsPublisher eventsPublisher; - /** evalService is the service to evaluate the flags. */ - private final EvaluationService evalService; + /** evaluator is the service to evaluate the flags. */ + private final IEvaluator evaluator; /** * Constructor of the hook. @@ -38,13 +38,13 @@ public DataCollectorHook(final DataCollectorHookOptions options) throws InvalidO } options.validate(); eventsPublisher = options.getEventsPublisher(); - evalService = options.getEvalService(); + evaluator = options.getEvaluator(); this.options = options; } @Override public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (!this.evalService.isFlagTrackable(ctx.getFlagKey()) + if (!this.evaluator.isFlagTrackable(ctx.getFlagKey()) || (!Boolean.TRUE.equals(this.options.getCollectUnCachedEvaluation()) && !Reason.CACHED.name().equals(details.getReason()))) { return; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java index ffb67400ae..78ab31ec5f 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java @@ -1,8 +1,8 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; import lombok.Builder; import lombok.Getter; @@ -27,7 +27,7 @@ public class DataCollectorHookOptions { /** * evalService is the service to evaluate the flags. */ - private EvaluationService evalService; + private IEvaluator evaluator; /** * Validate the options provided to the data collector hook. diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java deleted file mode 100644 index 1aa17c8a4b..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java +++ /dev/null @@ -1,163 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.service; - -import static dev.openfeature.sdk.Value.objectToValue; - -import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; -import dev.openfeature.contrib.providers.gofeatureflag.util.MetadataUtil; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; -import lombok.AllArgsConstructor; -import lombok.val; - -/** - * EvaluationService is responsible for evaluating feature flags using the provided evaluator. - * It can use different evaluators based on the configuration and context. - */ -@AllArgsConstructor -public class EvaluationService { - /** - * The evaluator used to evaluate the flags. - */ - private IEvaluator evaluator; - - /** - * Return true if we should track the usage of the flag. - * - * @param flagKey - name of the flag - * @return true if the flag is trackable, false otherwise - */ - public boolean isFlagTrackable(final String flagKey) { - return this.evaluator.isFlagTrackable(flagKey); - } - - /** - * Init the evaluator. - */ - public void init() { - this.evaluator.init(); - } - - /** - * Destroy the evaluator. - */ - public void destroy() { - this.evaluator.destroy(); - } - - /** - * Get the evaluation response from the evaluator. - * - * @param flagKey - name of the flag - * @param defaultValue - default value - * @param evaluationContext - evaluation context - * @param expectedType - expected type of the value - * @param - type of the value - * @return the evaluation response - */ - public ProviderEvaluation getEvaluation( - String flagKey, T defaultValue, EvaluationContext evaluationContext, Class expectedType) { - - val goffResp = evaluator.evaluate(flagKey, defaultValue, evaluationContext); - - // Check for FLAG_NOT_FOUND error first, before general error handling - if (goffResp.getErrorCode() != null - && ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(goffResp.getErrorCode())) { - throw new FlagNotFoundError("Flag " + flagKey + " was not found in your configuration"); - } - - // If we have an error code, we return the error directly. - if (goffResp.getErrorCode() != null && !goffResp.getErrorCode().isEmpty()) { - return ProviderEvaluation.builder() - .errorCode(mapErrorCode(goffResp.getErrorCode())) - .errorMessage(goffResp.getErrorDetails()) - .reason(Reason.ERROR.name()) - .value(defaultValue) - .build(); - } - - if (Reason.DISABLED.name().equalsIgnoreCase(goffResp.getReason())) { - // we don't set a variant since we are using the default value, - // and we are not able to know which variant it is. - return ProviderEvaluation.builder() - .value(defaultValue) - .variant(goffResp.getVariationType()) - .reason(Reason.DISABLED.name()) - .build(); - } - - if (goffResp.getValue() == null) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .flagMetadata(MetadataUtil.convertFlagMetadata(goffResp.getMetadata())) - .build(); - } - - // Convert the value received from the API. - T flagValue = convertValue(goffResp.getValue(), expectedType); - - if (flagValue.getClass() != expectedType) { - throw new TypeMismatchError(String.format( - "Flag value %s had unexpected type %s, expected %s.", flagKey, flagValue.getClass(), expectedType)); - } - - return ProviderEvaluation.builder() - .errorCode(mapErrorCode(goffResp.getErrorCode())) - .reason(goffResp.getReason()) - .value(flagValue) - .variant(goffResp.getVariationType()) - .flagMetadata(MetadataUtil.convertFlagMetadata(goffResp.getMetadata())) - .build(); - } - - /** - * convertValue is converting the object return by the proxy response in the right type. - * - * @param value - The value we have received - * @param expectedType - the type we expect for this value - * @param the type we want to convert to. - * @return A converted object - */ - // The cast to T is unchecked on purpose: getEvaluation compares the runtime class with - // expectedType right after and raises a TypeMismatchError if the API returned another type. - @SuppressWarnings("unchecked") - private T convertValue(Object value, Class expectedType) { - boolean isPrimitive = expectedType == Boolean.class - || expectedType == String.class - || expectedType == Integer.class - || expectedType == Double.class; - - if (isPrimitive) { - // JSON does not distinguish 100 from 100.0, and a number too large for an int decodes to - // Long, so the float resolver accepts any number rather than only Integer. - if (expectedType == Double.class && value instanceof Number) { - return (T) Double.valueOf(((Number) value).doubleValue()); - } - return (T) value; - } - return (T) objectToValue(value); - } - - /** - * mapErrorCode is mapping the errorCode in string received by the API to our internal SDK - * ErrorCode enum. - * - * @param errorCode - string of the errorCode received from the API - * @return an item from the enum - */ - private ErrorCode mapErrorCode(String errorCode) { - if (errorCode == null || errorCode.isEmpty()) { - return null; - } - - try { - return ErrorCode.valueOf(errorCode); - } catch (IllegalArgumentException e) { - return null; - } - } -} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index e00bee5bb3..d0f8d4153b 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -106,223 +106,6 @@ public void shouldThrowInvalidOptionsIfEndpointInvalid() { } } - @Nested - class EvaluateFlag { - @SneakyThrows - @DisplayName("request should call the ofrep endpoint") - @Test - public void requestShouldCallTheOfrepEndpoint() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = "/ofrep/v1/evaluate/flags/flag-key"; - assertEquals(want, server.takeRequest().getPath()); - } - - @SneakyThrows - @DisplayName("request should keep the path prefix of the endpoint") - @Test - public void requestShouldKeepThePathPrefixOfTheEndpoint() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(server.url("/gofeatureflagproxy/").toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = "/gofeatureflagproxy/ofrep/v1/evaluate/flags/flag-key"; - assertEquals(want, server.takeRequest().getPath()); - } - - @SneakyThrows - @DisplayName("request should escape a flag key that would otherwise alter the url") - @Test - public void requestShouldEscapeAFlagKeyThatWouldOtherwiseAlterTheUrl() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - // the mock has no fixture under this name, only the shape of the url matters here - assertThrows( - FlagNotFoundError.class, - () -> api.evaluateFlag("my flag/with slash", TestUtils.defaultEvaluationContext)); - - val want = "/ofrep/v1/evaluate/flags/my%20flag%2Fwith%20slash"; - assertEquals(want, server.takeRequest().getPath()); - } - - @SneakyThrows - @DisplayName("request should have an api key") - @Test - public void requestShouldHaveAnAPIKey() { - val apiKey = "my-api-key"; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val request = server.takeRequest(); - assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); - assertNull(request.getHeader("Authorization")); - } - - @SneakyThrows - @DisplayName("request should not set an api key if empty") - @Test - public void requestShouldNotSetAnAPIKeyIfEmpty() { - val apiKey = ""; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); - } - - @SneakyThrows - @DisplayName("request should have the evaluation context in the body") - @Test - public void requestShouldHaveTheEvaluationContextInTheBody() { - val apiKey = "my-api-key"; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val wantStr = "{\"context\":{" - + " \"targetingKey\": \"d45e303a-38c2-11ed-a261-0242ac120002\"," - + " \"email\": \"john.doe@gofeatureflag.org\"," - + " \"firstname\": \"john\"," - + " \"lastname\": \"doe\"," - + " \"anonymous\": false," - + " \"professional\": true," - + " \"rate\": 3.14," - + " \"age\": 30," - + " \"company_info\": {\"name\": \"my_company\", \"size\": 120}," - + " \"labels\": [\"pro\", \"beta\"]" - + "}}"; - val gotStr = goffAPIMock.getLastRequestBody(); - ObjectMapper objectMapper = new ObjectMapper(); - Object want = objectMapper.readTree(wantStr); - Object got = objectMapper.readTree(gotStr); - assertEquals(want, got, "The JSON strings are not equal"); - } - - @SneakyThrows - @DisplayName("request should have the default headers") - @Test - public void requestShouldHaveDefaultHeaders() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val got = server.takeRequest().getHeaders(); - assertEquals("application/json; charset=utf-8", got.get(Const.HTTP_HEADER_CONTENT_TYPE)); - } - - @SneakyThrows - @DisplayName("should error if timeout is reached") - @Test - public void shouldErrorIfTimeoutIsReached() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .timeout(200) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("timeout", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should error if response is a 401") - @Test - public void shouldErrorIfResponseIsA401() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("401", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should error if response is a 403") - @Test - public void shouldErrorIfResponseIsA403() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("403", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should error if response has invalid JSON") - @Test - public void shouldErrorIfResponseHasInvalidJson() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows( - GeneralError.class, () -> api.evaluateFlag("invalid-json", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should error if response is a 400") - @Test - public void shouldErrorIfResponseIsA400() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(InvalidContextError.class, () -> api.evaluateFlag("400", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should error if response is a 500") - @Test - public void shouldErrorIfResponseIsA500() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("500", TestUtils.defaultEvaluationContext)); - } - - @SneakyThrows - @DisplayName("should have a valid evaluate response") - @Test - public void shouldHaveAValidEvaluateResponse() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - val got = api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = new GoFeatureFlagResponse(); - want.setVariationType("off"); - want.setValue(false); - want.setReason("STATIC"); - want.setCacheable(true); - val metadata = new HashMap(); - metadata.put("description", "A flag that is always off"); - want.setMetadata(metadata); - want.setErrorCode(null); - want.setErrorDetails(null); - want.setFailed(false); - - assertEquals(want, got); - } - } - @Nested class SendEventToDataCollector { @SneakyThrows diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java deleted file mode 100644 index 3438f53f7e..0000000000 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -@DisplayName("EvaluationService tests") -class EvaluationServiceTest { - private IEvaluator mockEvaluator; - private EvaluationService evaluationService; - private EvaluationContext evaluationContext; - - @BeforeEach - void setUp() { - mockEvaluator = mock(IEvaluator.class); - evaluationService = new EvaluationService(mockEvaluator); - evaluationContext = new ImmutableContext("test-targeting-key"); - } - - @DisplayName("Should throw FlagNotFoundError when flag is not found") - @Test - void shouldThrowFlagNotFoundErrorWhenFlagIsNotFound() { - // Given: evaluator returns a response with FLAG_NOT_FOUND error code - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); - response.setErrorDetails("Flag test-flag was not found in your configuration"); - response.setValue(false); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When/Then: getEvaluation should throw FlagNotFoundError - FlagNotFoundError exception = assertThrows( - FlagNotFoundError.class, - () -> evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class)); - - assertEquals("Flag test-flag was not found in your configuration", exception.getMessage()); - } - - @DisplayName("Should return error response for other error codes") - @Test - void shouldReturnErrorResponseForOtherErrorCodes() { - // Given: evaluator returns a response with a different error code - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.GENERAL.name()); - response.setErrorDetails("Some other error occurred"); - response.setValue(false); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When: getEvaluation is called - ProviderEvaluation result = - evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class); - - // Then: should return error response, not throw exception - assertEquals(ErrorCode.GENERAL, result.getErrorCode()); - assertEquals("Some other error occurred", result.getErrorMessage()); - assertEquals(Reason.ERROR.name(), result.getReason()); - assertEquals(false, result.getValue()); - } - - @DisplayName("Should handle successful evaluation") - @Test - void shouldHandleSuccessfulEvaluation() { - // Given: evaluator returns a successful response - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setValue(true); - response.setReason(Reason.TARGETING_MATCH.name()); - response.setVariationType("enabled"); - response.setErrorCode(null); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When: getEvaluation is called - ProviderEvaluation result = - evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class); - - // Then: should return successful evaluation - assertEquals(true, result.getValue()); - assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); - assertEquals("enabled", result.getVariant()); - assertEquals(null, result.getErrorCode()); - } - - @DisplayName("Should accept an int number for the double resolver") - @Test - void shouldAcceptAnIntegralNumberForTheDoubleResolver() { - assertEquals(100.0, doubleEvaluationOf(100)); - } - - @DisplayName("Should accept an int number larger than an int for the double resolver") - @Test - void shouldAcceptAnIntegralNumberLargerThanAnIntForTheDoubleResolver() { - // above Integer.MAX_VALUE Jackson decodes a JSON integer to Long - assertEquals(3000000000.0, doubleEvaluationOf(3000000000L)); - } - - @DisplayName("Should accept a decimal number for the double resolver") - @Test - void shouldAcceptADecimalNumberForTheDoubleResolver() { - assertEquals(101.25, doubleEvaluationOf(101.25)); - } - - @DisplayName("Should not let a boolean satisfy the double resolver") - @Test - void shouldNotLetABooleanSatisfyTheDoubleResolver() { - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(responseWithValue(true)); - - assertThrows( - TypeMismatchError.class, - () -> evaluationService.getEvaluation("test-flag", 0.0, evaluationContext, Double.class)); - } - - @DisplayName("Should not let a decimal number satisfy the integer resolver") - @Test - void shouldNotLetADecimalNumberSatisfyTheIntegerResolver() { - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(responseWithValue(101.25)); - - assertThrows( - TypeMismatchError.class, - () -> evaluationService.getEvaluation("test-flag", 0, evaluationContext, Integer.class)); - } - - private Double doubleEvaluationOf(Object engineValue) { - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(responseWithValue(engineValue)); - return evaluationService - .getEvaluation("test-flag", 0.0, evaluationContext, Double.class) - .getValue(); - } - - private GoFeatureFlagResponse responseWithValue(Object value) { - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setValue(value); - response.setReason(Reason.TARGETING_MATCH.name()); - response.setVariationType("enabled"); - return response; - } - - @DisplayName("Should return the caller default and keep the engine details when the value is null") - @Test - void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { - GoFeatureFlagResponse response = responseWithValue(null); - response.setMetadata(Map.of("description", "a flag with no value")); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - ProviderEvaluation result = - evaluationService.getEvaluation("test-flag", true, evaluationContext, Boolean.class); - - assertEquals(true, result.getValue()); - assertEquals(Reason.DEFAULT.name(), result.getReason()); - assertNull(result.getVariant()); - assertEquals("a flag with no value", result.getFlagMetadata().getString("description")); - assertNull(result.getErrorCode()); - } - - @DisplayName("Should not return a zero value when the value is null") - @Test - void shouldNotReturnAZeroValueWhenTheValueIsNull() { - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(responseWithValue(null)); - - assertEquals( - 42, - evaluationService - .getEvaluation("test-flag", 42, evaluationContext, Integer.class) - .getValue()); - assertEquals( - "caller-default", - evaluationService - .getEvaluation("test-flag", "caller-default", evaluationContext, String.class) - .getValue()); - assertEquals( - new Value("caller-default"), - evaluationService - .getEvaluation("test-flag", new Value("caller-default"), evaluationContext, Value.class) - .getValue()); - } -} From 7584758523264d4136a22e881228b617352ce666 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 18:59:16 +0200 Subject: [PATCH 27/93] test(go-feature-flag): move the evaluation specs onto the evaluators EvaluationServiceTest specified the response-to-resolution conversion by mocking IEvaluator.evaluate. With that method gone the specs had no home, so the conversion is split out of genericEvaluation into a package-private toProviderEvaluation and the specs address it directly: a response the WASM engine cannot be made to emit -- a null value, a Long above Integer.MAX_VALUE, an error code the SDK does not know -- is still worth pinning down. InProcessEvaluatorTest moves to the typed resolvers. FLAG_NOT_FOUND now leaves as a thrown FlagNotFoundError rather than an error code on the response, so the three tests that asserted on it assert on the throw instead. Remote evaluation runs through OfrepProvider now, which changes what the provider tests see. Its messages are its own, and it passes the relay proxy's metadata through untouched, so gofeatureflag_cacheable reaches the application instead of being stripped (GOFF-EVAL-010). GoffApiMock gives its OFREP error responses a body: an OFREP client reads the body of every response before looking at the status, so a bodiless 401 is not a representative relay proxy. OfrepResponseTest goes with the OfrepResponse bean it covered, and Const.PATH_OFREP_EVALUATE with the route the provider no longer builds. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 9 +- .../gofeatureflag/evaluator/IEvaluator.java | 71 +++++- .../evaluator/InProcessEvaluator.java | 25 +- .../evaluator/RemoteEvaluator.java | 7 +- .../hook/DataCollectorHookOptions.java | 2 +- .../providers/gofeatureflag/util/Const.java | 1 - .../GoFeatureFlagProviderTest.java | 20 +- .../api/GoFeatureFlagApiTest.java | 3 - .../api/bean/OfrepResponseTest.java | 78 ------ .../evaluator/InProcessEvaluatorTest.java | 241 +++++++++++++++--- .../gofeatureflag/util/GoffApiMock.java | 21 +- 11 files changed, 327 insertions(+), 151 deletions(-) delete mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 41a47dddff..e2c9cfc415 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -64,7 +64,7 @@ public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws options.validate(); this.options = options; this.api = GoFeatureFlagApi.builder().options(options).build(); - this.evaluator = getEvaluator(this.api); + this.evaluator = getEvaluator(); Consumer> publisher = this::publishEvents; this.eventsPublisher = @@ -118,7 +118,7 @@ public ProviderEvaluation getObjectEvaluation( @Override public void initialize(EvaluationContext evaluationContext) throws Exception { - this.initialize(evaluationContext, ""); + this.initialize(evaluationContext, ""); } @Override @@ -183,15 +183,14 @@ public void track(final String eventName, final EvaluationContext context, final /** * Get the evaluator based on the evaluation type. - * It will initialize the evaluator based on the evaluation type. * * @return the evaluator */ - private IEvaluator getEvaluator(final GoFeatureFlagApi api) { + private IEvaluator getEvaluator() { // Select the evaluator based on the evaluation type if (options.getEvaluationType() == null || options.getEvaluationType() == EvaluationType.IN_PROCESS) { Consumer emitProviderConfigurationChanged = this::emitProviderConfigurationChanged; - return new InProcessEvaluator(api, this.options, emitProviderConfigurationChanged); + return new InProcessEvaluator(this.api, this.options, emitProviderConfigurationChanged); } return new RemoteEvaluator(this.options); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java index 0a04e9046b..aeabe6fd01 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java @@ -1,6 +1,5 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; @@ -10,13 +9,83 @@ * It can have multiple implementations: REMOTE or IN-PROCESS. */ public interface IEvaluator { + /** + * initialize is called when the provider is initialized for a specific domain. + * + * @param ctx - evaluation context + * @param domain - domain the provider is bound to + * @throws Exception - if the evaluator cannot be initialized + */ void initialize(final EvaluationContext ctx, final String domain) throws Exception; + + /** + * initialize is called when the provider is initialized. + * + * @param ctx - evaluation context + * @throws Exception - if the evaluator cannot be initialized + */ void initialize(final EvaluationContext ctx) throws Exception; + + /** + * shutdown releases everything the evaluator holds, so that it stops doing background work. + */ void shutdown(); + + /** + * getBooleanEvaluation resolves the value of a boolean flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx); + + /** + * getStringEvaluation resolves the value of a string flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx); + + /** + * getIntegerEvaluation resolves the value of an integer flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx); + + /** + * getDoubleEvaluation resolves the value of a float flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); + + /** + * getObjectEvaluation resolves the value of a flag holding a structure. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); + + /** + * isFlagTrackable returns true if we should collect the usage of this flag. + * + * @param flagKey - name of the flag + * @return true if the flag is trackable, false otherwise + */ boolean isFlagTrackable(String flagKey); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 6ccb2917cc..206b45b534 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -215,8 +215,25 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa */ private ProviderEvaluation genericEvaluation( final String key, final T defaultValue, final EvaluationContext ctx, final Class expectedType) { - val response = this.evaluate(key, defaultValue, ctx); + return toProviderEvaluation(key, defaultValue, this.evaluate(key, defaultValue, ctx), expectedType); + } + /** + * toProviderEvaluation converts an engine response into the resolution structure expected by + * the OpenFeature SDK. + * + *

It is separate from genericEvaluation so that the conversion can be specified against a + * response directly, independently of what the WASM engine can be made to emit. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param response - response returned by the evaluation engine + * @param expectedType - type the resolver called by the SDK is contracted to return + * @param - type of the flag value + * @return the evaluation result for this flag + */ + static ProviderEvaluation toProviderEvaluation( + final String key, final T defaultValue, final GoFeatureFlagResponse response, final Class expectedType) { if (ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(response.getErrorCode())) { throw new FlagNotFoundError("Flag " + key + " was not found in your configuration"); } @@ -272,10 +289,10 @@ private ProviderEvaluation genericEvaluation( * @param - the type we want to convert to * @return a converted object */ - // The cast to T is unchecked on purpose: genericEvaluation compares the runtime class with + // The cast to T is unchecked on purpose: toProviderEvaluation compares the runtime class with // expectedType right after and raises a TypeMismatchError if the engine returned another type. @SuppressWarnings("unchecked") - private T convertValue(final Object value, final Class expectedType) { + private static T convertValue(final Object value, final Class expectedType) { boolean isPrimitive = expectedType == Boolean.class || expectedType == String.class || expectedType == Integer.class @@ -299,7 +316,7 @@ private T convertValue(final Object value, final Class expectedType) { * @param errorCode - string of the error code received from the evaluation engine * @return an item from the enum, null if the engine reported no error */ - private ErrorCode mapErrorCode(final String errorCode) { + private static ErrorCode mapErrorCode(final String errorCode) { if (errorCode == null || errorCode.isEmpty()) { return null; } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 0a583148ca..7331aba068 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -9,9 +9,9 @@ import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; +import java.time.Duration; import lombok.extern.slf4j.Slf4j; import lombok.val; -import java.time.Duration; /** * RemoteEvaluator is an implementation of the IEvaluator interface. @@ -19,7 +19,8 @@ */ @Slf4j public class RemoteEvaluator implements IEvaluator { - public final OfrepProvider ofrep; + /** OFREP provider doing the actual remote evaluation. */ + private final OfrepProvider ofrep; /** * Constructor of the evaluator. @@ -72,7 +73,7 @@ public ProviderEvaluation getStringEvaluation(String key, String default @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return this.ofrep.getIntegerEvaluation(key,defaultValue,ctx); + return this.ofrep.getIntegerEvaluation(key, defaultValue, ctx); } @Override diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java index 78ab31ec5f..369a4a4d51 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java @@ -25,7 +25,7 @@ public class DataCollectorHookOptions { private EventsPublisher eventsPublisher; /** - * evalService is the service to evaluate the flags. + * evaluator is used to know whether the usage of a flag should be collected. */ private IEvaluator evaluator; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index e866c9a7bd..ae1784f765 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -18,7 +18,6 @@ public class Const { public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; - public static final String PATH_OFREP_EVALUATE = "ofrep/v1/evaluate/flags/"; // FLAG CONFIGURATION FIELDS // the only field of a flag configuration a provider may read, everything else is the engine's public static final String FIELD_TRACK_EVENTS = "trackEvents"; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 23e8a8c78b..746a6dfe6d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -941,7 +941,7 @@ void shouldErrorIfEndpointNotAvailable() { .flagKey("bool_flag") .reason(Reason.ERROR.name()) .errorCode(ErrorCode.GENERAL) - .errorMessage("Unknown error while retrieving flag ") + .errorMessage("Unknown error while retrieving flag: bool_flag, status code: 500") .build(); assertEquals(want, got); } @@ -967,7 +967,7 @@ void shouldErrorIfApiKeyIsMissing() { .flagKey("bool_flag") .reason(Reason.ERROR.name()) .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error") + .errorMessage("authentication/authorization error for flag: bool_flag") .build(); assertEquals(want, got); } @@ -994,7 +994,7 @@ void shouldErrorIfApiKeyIsInvalid() { .flagKey("bool_flag") .reason(Reason.ERROR.name()) .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error") + .errorMessage("authentication/authorization error for flag: bool_flag") .build(); assertEquals(want, got); } @@ -1016,7 +1016,7 @@ void shouldErrorIfFlagNotFound() { .flagKey("does-not-exists") .reason(Reason.ERROR.name()) .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag does-not-exists not found") + .errorMessage("flag: does-not-exists not found") .build(); assertEquals(want, got); } @@ -1036,9 +1036,12 @@ void shouldErrorIfEvaluatingTheWrongType() { .value("default") .flagKey("bool_flag") .reason(Reason.ERROR.name()) - .errorMessage( - "Flag value bool_flag had unexpected type class java.lang.Boolean, expected class java.lang.String.") + .errorMessage("Type mismatch: expected String but got Boolean") .errorCode(ErrorCode.TYPE_MISMATCH) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) .build(); assertEquals(want, got); } @@ -1061,6 +1064,7 @@ void shouldResolveAValidBooleanFlag() { .reason(Reason.TARGETING_MATCH.name()) .flagMetadata(ImmutableMetadata.builder() .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) .build()) .build(); assertEquals(want, got); @@ -1084,6 +1088,7 @@ void shouldResolveAValidStringFlag() { .reason(Reason.TARGETING_MATCH.name()) .flagMetadata(ImmutableMetadata.builder() .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) .build()) .build(); assertEquals(want, got); @@ -1107,6 +1112,7 @@ void shouldResolveAValidIntFlag() { .reason(Reason.TARGETING_MATCH.name()) .flagMetadata(ImmutableMetadata.builder() .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) .build()) .build(); assertEquals(want, got); @@ -1130,6 +1136,7 @@ void shouldResolveAValidDoubleFlag() { .reason(Reason.TARGETING_MATCH.name()) .flagMetadata(ImmutableMetadata.builder() .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) .build()) .build(); assertEquals(want, got); @@ -1154,6 +1161,7 @@ void shouldResolveAValidObjectFlag() { .reason(Reason.TARGETING_MATCH.name()) .flagMetadata(ImmutableMetadata.builder() .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) .build()) .build(); assertEquals(want, got); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index d0f8d4153b..bbd910857a 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -11,7 +11,6 @@ import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.TrackingEvent; import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; @@ -22,9 +21,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.MutableTrackingEventDetails; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.InvalidContextError; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java deleted file mode 100644 index b5aa5f0e7c..0000000000 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import java.util.HashMap; -import java.util.Map; -import lombok.val; -import org.junit.jupiter.api.Test; - -class OfrepResponseTest { - @Test - void testToGoFeatureFlagResponse_AllFieldsMappedCorrectly() { - val ofrepResponse = new OfrepResponse(); - ofrepResponse.setValue("testValue"); - ofrepResponse.setKey("testKey"); - ofrepResponse.setVariant("testVariant"); - ofrepResponse.setReason("testReason"); - ofrepResponse.setErrorCode("testErrorCode"); - ofrepResponse.setErrorDetails("testErrorDetails"); - - Map metadata = new HashMap<>(); - metadata.put("gofeatureflag_cacheable", true); - metadata.put("gofeatureflag_version", "v1.0"); - metadata.put("extra_metadata", "extraValue"); - ofrepResponse.setMetadata(metadata); - - // Act - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - // Assert - assertEquals("testValue", goFeatureFlagResponse.getValue()); - assertEquals("testVariant", goFeatureFlagResponse.getVariationType()); - assertEquals("testReason", goFeatureFlagResponse.getReason()); - assertEquals("testErrorCode", goFeatureFlagResponse.getErrorCode()); - assertEquals("testErrorDetails", goFeatureFlagResponse.getErrorDetails()); - assertTrue(goFeatureFlagResponse.isFailed()); - assertTrue(goFeatureFlagResponse.isCacheable()); - assertEquals("v1.0", goFeatureFlagResponse.getVersion()); - assertEquals(1, goFeatureFlagResponse.getMetadata().size()); - assertEquals("extraValue", goFeatureFlagResponse.getMetadata().get("extra_metadata")); - } - - @Test - void testToGoFeatureFlagResponse_NoCacheableOrVersionInMetadata() { - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setMetadata(new HashMap<>()); - - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - assertFalse(goFeatureFlagResponse.isCacheable()); - assertNull(goFeatureFlagResponse.getVersion()); - } - - @Test - void testToGoFeatureFlagResponse_NullMetadata() { - // Arrange - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setMetadata(null); - - // Act - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - // Assert - assertNull(goFeatureFlagResponse.getMetadata()); - } - - @Test - void testToGoFeatureFlagResponse_ErrorCodeIsNull() { - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setErrorCode(null); - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - assertFalse(goFeatureFlagResponse.isFailed()); - } -} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index d583ac07ff..a2068ddcfc 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -1,24 +1,33 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; +import static dev.openfeature.contrib.providers.gofeatureflag.evaluator.InProcessEvaluator.toProviderEvaluation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; +import java.util.Map; import lombok.SneakyThrows; import lombok.val; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class InProcessEvaluatorTest { @@ -56,30 +65,31 @@ private InProcessEvaluator evaluator(MockWebServer srv) { @Test void aSecondInitShouldNotLeaveASecondPollerRunning() { val evaluator = evaluator(this.server); - evaluator.init(); - evaluator.init(); + evaluator.initialize(new ImmutableContext()); + evaluator.initialize(new ImmutableContext()); // let both a stranded poller and the live one have several chances to fire Thread.sleep(POLLING_INTERVAL_MS * 5); - evaluator.destroy(); + evaluator.shutdown(); - // whatever was in flight when destroy() was called may still land, so settle first + // whatever was in flight when shutdown() was called may still land, so settle first Thread.sleep(POLLING_INTERVAL_MS * 2); - val afterDestroy = goffApiMock.getConfigurationCallCount(); - assertTrue(afterDestroy > 0, "the evaluator should have polled at least once"); + val afterShutdown = goffApiMock.getConfigurationCallCount(); + assertTrue(afterShutdown > 0, "the evaluator should have polled at least once"); // a poller stranded by the second init would keep calling the API forever Thread.sleep(POLLING_INTERVAL_MS * 5); - assertEquals(afterDestroy, goffApiMock.getConfigurationCallCount(), "polling continued after destroy()"); + assertEquals(afterShutdown, goffApiMock.getConfigurationCallCount(), "polling continued after shutdown()"); } @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test void shouldReportProviderNotReadyBeforeAnyConfigurationIsLoaded() { - val got = evaluator(this.server).evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + val got = evaluator(this.server) + .getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key")); - assertEquals(ErrorCode.PROVIDER_NOT_READY.name(), got.getErrorCode()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); assertEquals(Reason.ERROR.name(), got.getReason()); assertEquals(false, got.getValue()); } @@ -91,13 +101,13 @@ void shouldReportProviderNotReadyAfterAFailedInit() { try (val s = new MockWebServer()) { s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404).dispatcher); val evaluator = evaluator(s); - assertThrows(FlagConfigurationEndpointNotFound.class, evaluator::init); + assertThrows(FlagConfigurationEndpointNotFound.class, () -> evaluator.initialize(new ImmutableContext())); - val got = evaluator.evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + val got = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key")); - assertEquals(ErrorCode.PROVIDER_NOT_READY.name(), got.getErrorCode()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); // the flag key is not at fault here, the relay proxy is - assertNotEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); + assertNotEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); } } @@ -106,12 +116,12 @@ void shouldReportProviderNotReadyAfterAFailedInit() { @Test void shouldReportFlagNotFoundForAnUnknownKeyOnceLoaded() { val evaluator = evaluator(this.server); - evaluator.init(); + evaluator.initialize(new ImmutableContext()); - val got = evaluator.evaluate("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); - - assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); - evaluator.destroy(); + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); } @SneakyThrows @@ -121,30 +131,31 @@ void anEmptyButValidConfigurationCountsAsLoaded() { try (val s = new MockWebServer()) { s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.EMPTY_FLAG_CONFIG).dispatcher); val evaluator = evaluator(s); - evaluator.init(); - - val got = evaluator.evaluate("bool_targeting_match", false, new ImmutableContext("user-key")); + evaluator.initialize(new ImmutableContext()); // a relay proxy legitimately serving zero flags is loaded: the key really is unknown - assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); - evaluator.destroy(); + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation( + "bool_targeting_match", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); } } @SneakyThrows - @DisplayName("should stay loaded across destroy and re-init") + @DisplayName("should stay loaded across shutdown and re-init") @Test - void shouldStayLoadedAcrossDestroyAndReInit() { + void shouldStayLoadedAcrossShutdownAndReInit() { val evaluator = evaluator(this.server); - evaluator.init(); - evaluator.destroy(); - evaluator.init(); - - val got = evaluator.evaluate("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); - // the stored ETag survives destroy(), so a re-init answered 304 must keep serving what it holds - assertEquals(ErrorCode.FLAG_NOT_FOUND.name(), got.getErrorCode()); - evaluator.destroy(); + // the stored ETag survives shutdown(), so a re-init answered 304 must keep serving what it holds + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); } @SneakyThrows @@ -152,14 +163,14 @@ void shouldStayLoadedAcrossDestroyAndReInit() { @Test void aMissingTargetingKeyShouldBePassedThroughToTheEngine() { val evaluator = evaluator(this.server); - evaluator.init(); + evaluator.initialize(new ImmutableContext()); // object_key resolves through a default rule that does not bucket, so it needs no targeting key - val got = evaluator.evaluate("object_key", null, new ImmutableContext()); + val got = evaluator.getObjectEvaluation("object_key", null, new ImmutableContext()); - assertEquals("", got.getErrorCode()); - assertEquals("varA", got.getVariationType()); - evaluator.destroy(); + assertNull(got.getErrorCode()); + assertEquals("varA", got.getVariant()); + evaluator.shutdown(); } @SneakyThrows @@ -167,11 +178,157 @@ void aMissingTargetingKeyShouldBePassedThroughToTheEngine() { @Test void theEngineShouldReportTargetingKeyMissingOnlyForAFlagThatBuckets() { val evaluator = evaluator(this.server); - evaluator.init(); + evaluator.initialize(new ImmutableContext()); + + val got = evaluator.getStringEvaluation("string_key", "default", new ImmutableContext()); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); + evaluator.shutdown(); + } + + @Nested + @DisplayName("conversion of an engine response into a resolution") + class Conversion { + private GoFeatureFlagResponse responseWithValue(Object value) { + val response = new GoFeatureFlagResponse(); + response.setValue(value); + response.setReason(Reason.TARGETING_MATCH.name()); + response.setVariationType("enabled"); + return response; + } + + private Double doubleEvaluationOf(Object engineValue) { + return toProviderEvaluation("test-flag", 0.0, responseWithValue(engineValue), Double.class) + .getValue(); + } + + @DisplayName("Should throw FlagNotFoundError when flag is not found") + @Test + void shouldThrowFlagNotFoundErrorWhenFlagIsNotFound() { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); + response.setErrorDetails("Flag test-flag was not found in your configuration"); + response.setValue(false); + + val exception = assertThrows( + FlagNotFoundError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + + assertEquals("Flag test-flag was not found in your configuration", exception.getMessage()); + } + + @DisplayName("Should return error response for other error codes") + @Test + void shouldReturnErrorResponseForOtherErrorCodes() { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.GENERAL.name()); + response.setErrorDetails("Some other error occurred"); + response.setValue(false); + + ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("Some other error occurred", result.getErrorMessage()); + assertEquals(Reason.ERROR.name(), result.getReason()); + assertEquals(false, result.getValue()); + } + + @DisplayName("Should map an error code the SDK does not know to GENERAL") + @Test + void shouldMapAnUnknownErrorCodeToGeneral() { + val response = new GoFeatureFlagResponse(); + // FLAG_CONFIG is specific to GO Feature Flag and has no SDK equivalent + response.setErrorCode("FLAG_CONFIG"); + response.setErrorDetails("the flag configuration is invalid"); + + ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); - val got = evaluator.evaluate("string_key", "default", new ImmutableContext()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("the flag configuration is invalid", result.getErrorMessage()); + assertEquals(false, result.getValue()); + } + + @DisplayName("Should handle successful evaluation") + @Test + void shouldHandleSuccessfulEvaluation() { + val response = new GoFeatureFlagResponse(); + response.setValue(true); + response.setReason(Reason.TARGETING_MATCH.name()); + response.setVariationType("enabled"); + response.setErrorCode(null); + + ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + + assertEquals(true, result.getValue()); + assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); + assertEquals("enabled", result.getVariant()); + assertNull(result.getErrorCode()); + } + + @DisplayName("Should accept an int number for the double resolver") + @Test + void shouldAcceptAnIntegralNumberForTheDoubleResolver() { + assertEquals(100.0, doubleEvaluationOf(100)); + } + + @DisplayName("Should accept an int number larger than an int for the double resolver") + @Test + void shouldAcceptAnIntegralNumberLargerThanAnIntForTheDoubleResolver() { + // above Integer.MAX_VALUE Jackson decodes a JSON integer to Long + assertEquals(3000000000.0, doubleEvaluationOf(3000000000L)); + } + + @DisplayName("Should accept a decimal number for the double resolver") + @Test + void shouldAcceptADecimalNumberForTheDoubleResolver() { + assertEquals(101.25, doubleEvaluationOf(101.25)); + } + + @DisplayName("Should not let a boolean satisfy the double resolver") + @Test + void shouldNotLetABooleanSatisfyTheDoubleResolver() { + assertThrows( + TypeMismatchError.class, + () -> toProviderEvaluation("test-flag", 0.0, responseWithValue(true), Double.class)); + } + + @DisplayName("Should not let a decimal number satisfy the integer resolver") + @Test + void shouldNotLetADecimalNumberSatisfyTheIntegerResolver() { + assertThrows( + TypeMismatchError.class, + () -> toProviderEvaluation("test-flag", 0, responseWithValue(101.25), Integer.class)); + } - assertEquals(ErrorCode.TARGETING_KEY_MISSING.name(), got.getErrorCode()); - evaluator.destroy(); + @DisplayName("Should return the caller default and keep the engine details when the value is null") + @Test + void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { + val response = responseWithValue(null); + response.setMetadata(Map.of("description", "a flag with no value")); + + ProviderEvaluation result = toProviderEvaluation("test-flag", true, response, Boolean.class); + + assertEquals(true, result.getValue()); + assertEquals(Reason.DEFAULT.name(), result.getReason()); + assertNull(result.getVariant()); + assertEquals("a flag with no value", result.getFlagMetadata().getString("description")); + assertNull(result.getErrorCode()); + } + + @DisplayName("Should not return a zero value when the value is null") + @Test + void shouldNotReturnAZeroValueWhenTheValueIsNull() { + assertEquals( + 42, + toProviderEvaluation("test-flag", 42, responseWithValue(null), Integer.class) + .getValue()); + assertEquals( + "caller-default", + toProviderEvaluation("test-flag", "caller-default", responseWithValue(null), String.class) + .getValue()); + assertEquals( + new Value("caller-default"), + toProviderEvaluation("test-flag", new Value("caller-default"), responseWithValue(null), Value.class) + .getValue()); + } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 8c6eb440bf..90a3990a4a 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -14,6 +14,13 @@ public class GoffApiMock { private static final String ofrepResponseDir = "ofrep_evaluate_responses/"; + /** + * body the relay proxy returns alongside an OFREP error status. The status alone is not enough: + * an OFREP client reads the body of every response, so a bodiless error is not representative. + */ + private static final String ofrepErrorBody = + "{\"errorCode\":\"GENERAL\",\"errorDetails\":\"error\",\"metadata\":{}}"; + private final MockMode mode; @Getter @@ -36,11 +43,11 @@ public class GoffApiMock { public MockResponse dispatch(RecordedRequest request) { switch (mode) { case ENDPOINT_ERROR: - return new MockResponse().setResponseCode(500); + return new MockResponse().setResponseCode(500).setBody(ofrepErrorBody); case API_KEY_MISSING: - return new MockResponse().setResponseCode(401); + return new MockResponse().setResponseCode(401).setBody(ofrepErrorBody); case INVALID_API_KEY: - return new MockResponse().setResponseCode(403); + return new MockResponse().setResponseCode(403).setBody(ofrepErrorBody); } lastRequestBody = request.getBody().readUtf8(); @@ -84,11 +91,11 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { .setResponseCode(400) .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); case "401": - return new MockResponse().setResponseCode(401); + return new MockResponse().setResponseCode(401).setBody(ofrepErrorBody); case "403": - return new MockResponse().setResponseCode(403); + return new MockResponse().setResponseCode(403).setBody(ofrepErrorBody); case "404": - return new MockResponse().setResponseCode(404); + return new MockResponse().setResponseCode(404).setBody(ofrepErrorBody); case "500": return new MockResponse() .setResponseCode(500) @@ -99,7 +106,7 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { .setResponseCode(200) .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); } catch (Exception e) { - return new MockResponse().setResponseCode(404); + return new MockResponse().setResponseCode(404).setBody(ofrepErrorBody); } } } From 23ca95e7cc0dfce5c95f1e8286fdf2d4a1d361e5 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 16 Sep 2026 19:04:34 +0200 Subject: [PATCH 28/93] fix(go-feature-flag): raise every engine error as its SDK error type Only FLAG_NOT_FOUND left the in-process evaluator as a thrown error; every other engine error code came back as a ProviderEvaluation the provider had assembled itself. Both reach the application, but only one of them is the mechanism the SDK contracts for (GOFF-EVAL-011), and hand-assembling the second meant the provider decided what an error looks like. ExceptionUtils.instantiateErrorByErrorCode is that mapping, maintained by the SDK: it covers FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, TARGETING_KEY_MISSING, INVALID_CONTEXT and PROVIDER_NOT_READY, and falls back to GeneralError -- which is where FLAG_CONFIG, having no SDK equivalent, belongs. Feeding it mapErrorCode covers every code in the error model, and it cannot drift as the SDK grows new error types. errorDetails carries through as the message (GOFF-ERR-003). On the surviving path the error code is always empty, so mapping it could only yield null; the call goes. RemoteEvaluator still returns error codes rather than raising them, since that is what OfrepProvider does. The two evaluation modes remain to be reconciled. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 16 +--- .../evaluator/InProcessEvaluatorTest.java | 96 +++++++++++++++---- 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 206b45b534..9f1630307c 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -18,7 +18,7 @@ import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.ExceptionUtils; import dev.openfeature.sdk.exceptions.TypeMismatchError; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; @@ -234,18 +234,9 @@ private ProviderEvaluation genericEvaluation( */ static ProviderEvaluation toProviderEvaluation( final String key, final T defaultValue, final GoFeatureFlagResponse response, final Class expectedType) { - if (ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(response.getErrorCode())) { - throw new FlagNotFoundError("Flag " + key + " was not found in your configuration"); - } - - // the engine reports a successful evaluation with an empty error code, not a null one. if (response.getErrorCode() != null && !response.getErrorCode().isEmpty()) { - return ProviderEvaluation.builder() - .errorCode(mapErrorCode(response.getErrorCode())) - .errorMessage(response.getErrorDetails()) - .reason(Reason.ERROR.name()) - .value(defaultValue) - .build(); + throw ExceptionUtils.instantiateErrorByErrorCode( + mapErrorCode(response.getErrorCode()), response.getErrorDetails()); } if (Reason.DISABLED.name().equalsIgnoreCase(response.getReason())) { @@ -273,7 +264,6 @@ static ProviderEvaluation toProviderEvaluation( } return ProviderEvaluation.builder() - .errorCode(mapErrorCode(response.getErrorCode())) .reason(response.getReason()) .value(flagValue) .variant(response.getVariationType()) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index a2068ddcfc..d73d6d86c5 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -18,6 +18,11 @@ import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.InvalidContextError; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; import java.util.Map; @@ -86,12 +91,16 @@ void aSecondInitShouldNotLeaveASecondPollerRunning() { @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test void shouldReportProviderNotReadyBeforeAnyConfigurationIsLoaded() { - val got = evaluator(this.server) - .getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key")); + val evaluator = evaluator(this.server); + + val error = assertThrows( + ProviderNotReadyError.class, + () -> evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key"))); - assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); - assertEquals(Reason.ERROR.name(), got.getReason()); - assertEquals(false, got.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, error.getErrorCode()); + assertEquals( + "impossible to evaluate flag bool_targeting_match: no flag configuration has been loaded yet", + error.getMessage()); } @SneakyThrows @@ -103,11 +112,14 @@ void shouldReportProviderNotReadyAfterAFailedInit() { val evaluator = evaluator(s); assertThrows(FlagConfigurationEndpointNotFound.class, () -> evaluator.initialize(new ImmutableContext())); - val got = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key")); + val error = assertThrows( + ProviderNotReadyError.class, + () -> evaluator.getBooleanEvaluation( + "bool_targeting_match", false, new ImmutableContext("user-key"))); - assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, error.getErrorCode()); // the flag key is not at fault here, the relay proxy is - assertNotEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); + assertNotEquals(ErrorCode.FLAG_NOT_FOUND, error.getErrorCode()); } } @@ -180,9 +192,11 @@ void theEngineShouldReportTargetingKeyMissingOnlyForAFlagThatBuckets() { val evaluator = evaluator(this.server); evaluator.initialize(new ImmutableContext()); - val got = evaluator.getStringEvaluation("string_key", "default", new ImmutableContext()); + val error = assertThrows( + TargetingKeyMissingError.class, + () -> evaluator.getStringEvaluation("string_key", "default", new ImmutableContext())); - assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); + assertEquals(ErrorCode.TARGETING_KEY_MISSING, error.getErrorCode()); evaluator.shutdown(); } @@ -216,20 +230,60 @@ void shouldThrowFlagNotFoundErrorWhenFlagIsNotFound() { assertEquals("Flag test-flag was not found in your configuration", exception.getMessage()); } - @DisplayName("Should return error response for other error codes") + @DisplayName("Should raise the matching SDK error for other error codes") @Test - void shouldReturnErrorResponseForOtherErrorCodes() { + void shouldRaiseTheMatchingSdkErrorForOtherErrorCodes() { val response = new GoFeatureFlagResponse(); response.setErrorCode(ErrorCode.GENERAL.name()); response.setErrorDetails("Some other error occurred"); response.setValue(false); - ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + val error = assertThrows( + GeneralError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + + assertEquals(ErrorCode.GENERAL, error.getErrorCode()); + assertEquals("Some other error occurred", error.getMessage()); + } + + @DisplayName("Should raise a TargetingKeyMissingError when the engine reports one") + @Test + void shouldRaiseATargetingKeyMissingErrorWhenTheEngineReportsOne() { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.TARGETING_KEY_MISSING.name()); + response.setErrorDetails("Error: Empty targeting key"); + + val error = assertThrows( + TargetingKeyMissingError.class, + () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, error.getErrorCode()); + assertEquals("Error: Empty targeting key", error.getMessage()); + } + + @DisplayName("Should raise an InvalidContextError when the engine reports one") + @Test + void shouldRaiseAnInvalidContextErrorWhenTheEngineReportsOne() { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.INVALID_CONTEXT.name()); + response.setErrorDetails("the evaluation context is invalid"); + + val error = assertThrows( + InvalidContextError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + + assertEquals(ErrorCode.INVALID_CONTEXT, error.getErrorCode()); + } + + @DisplayName("Should raise a ParseError when the engine reports one") + @Test + void shouldRaiseAParseErrorWhenTheEngineReportsOne() { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.PARSE_ERROR.name()); + response.setErrorDetails("the flag could not be parsed"); + + val error = assertThrows( + ParseError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - assertEquals(ErrorCode.GENERAL, result.getErrorCode()); - assertEquals("Some other error occurred", result.getErrorMessage()); - assertEquals(Reason.ERROR.name(), result.getReason()); - assertEquals(false, result.getValue()); + assertEquals(ErrorCode.PARSE_ERROR, error.getErrorCode()); } @DisplayName("Should map an error code the SDK does not know to GENERAL") @@ -240,11 +294,11 @@ void shouldMapAnUnknownErrorCodeToGeneral() { response.setErrorCode("FLAG_CONFIG"); response.setErrorDetails("the flag configuration is invalid"); - ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + val error = assertThrows( + GeneralError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - assertEquals(ErrorCode.GENERAL, result.getErrorCode()); - assertEquals("the flag configuration is invalid", result.getErrorMessage()); - assertEquals(false, result.getValue()); + assertEquals(ErrorCode.GENERAL, error.getErrorCode()); + assertEquals("the flag configuration is invalid", error.getMessage()); } @DisplayName("Should handle successful evaluation") From 79f27b0e1902c1e4427eda24301f94f7ae03838c Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 08:52:53 +0200 Subject: [PATCH 29/93] test(go-feature-flag): cover the remote evaluator RemoteEvaluator had no tests at all. It delegates to OfrepProvider, so the resolvers themselves are thin, but the wiring around them is not: the API key header, the endpoint path prefix and the metadata passed back to the caller are all decisions this class makes. The metadata assertions are the point. gofeatureflag_cacheable now reaches the application instead of being stripped (GOFF-EVAL-010), and a plain remote evaluation carries no gofeatureflag_evaluated_remotely: that key marks a result recovered by the in-process evaluator's remote fallback (GOFF-FALLBACK-007), so tagging it here would make the fallback indistinguishable from the remote mode once the fallback lands. An unknown flag is asserted to come back as a FLAG_NOT_FOUND error code rather than a raised error, which pins the divergence with the in-process evaluator where it can be seen instead of leaving it implicit. The error statuses are left uncovered on purpose: 401, 403 and 500 only map to their own error codes because GoffApiMock gives them a JSON body, since providers/ofrep reads the body before it looks at the status. Signed-off-by: Thomas Poignant --- .../evaluator/RemoteEvaluatorTest.java | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java new file mode 100644 index 0000000000..afaef9e89a --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -0,0 +1,172 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RemoteEvaluatorTest { + private MockWebServer server; + + @BeforeEach + void beforeEach() throws IOException { + this.server = new MockWebServer(); + this.server.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + this.server.start(); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + } + + private RemoteEvaluator evaluator(String endpoint, String apiKey) { + return new RemoteEvaluator(GoFeatureFlagProviderOptions.builder() + .endpoint(endpoint) + .apiKey(apiKey) + .timeout(1000) + .build()); + } + + private RemoteEvaluator evaluator() { + return evaluator(this.server.url("").toString(), null); + } + + @DisplayName("should resolve a boolean flag") + @Test + void shouldResolveABooleanFlag() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertEquals("enabled", got.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), got.getReason()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve a string flag") + @Test + void shouldResolveAStringFlag() { + val got = evaluator().getStringEvaluation("string_flag", "default", new ImmutableContext("user-key")); + + assertEquals("string value", got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve an integer flag") + @Test + void shouldResolveAnIntegerFlag() { + val got = evaluator().getIntegerEvaluation("int_flag", 0, new ImmutableContext("user-key")); + + assertEquals(100, got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve a double flag") + @Test + void shouldResolveADoubleFlag() { + val got = evaluator().getDoubleEvaluation("double_flag", 0.0, new ImmutableContext("user-key")); + + assertEquals(100.11, got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve an object flag") + @Test + void shouldResolveAnObjectFlag() { + val got = evaluator().getObjectEvaluation("object_flag", new Value(), new ImmutableContext("user-key")); + + assertEquals("foo", got.getValue().asStructure().getValue("name").asString()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should pass the relay proxy metadata through untouched") + @Test + void shouldPassTheRelayProxyMetadataThrough() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // gofeatureflag_cacheable belongs to the relay proxy, the provider must not strip it + assertEquals(true, got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals("A flag that is always off", got.getFlagMetadata().getString("description")); + } + + @DisplayName("should not claim a remote evaluation in the flag metadata") + @Test + void shouldNotClaimARemoteEvaluationInTheFlagMetadata() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // gofeatureflag_evaluated_remotely marks a result recovered by the remote fallback of the + // in-process evaluator, so a plain remote evaluation must not carry it + assertNull(got.getFlagMetadata().getBoolean("gofeatureflag_evaluated_remotely")); + } + + @SneakyThrows + @DisplayName("should send the API key as an X-API-Key header") + @Test + void shouldSendTheApiKeyAsAnApiKeyHeader() { + evaluator(this.server.url("").toString(), "my-api-key") + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals("my-api-key", request.getHeader(Const.HTTP_HEADER_API_KEY)); + } + + @SneakyThrows + @DisplayName("should not send an API key header when none is configured") + @Test + void shouldNotSendAnApiKeyHeaderWhenNoneIsConfigured() { + evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertNull(request.getHeader(Const.HTTP_HEADER_API_KEY)); + } + + @SneakyThrows + @DisplayName("should keep the path prefix of the endpoint") + @Test + void shouldKeepThePathPrefixOfTheEndpoint() { + evaluator(this.server.url("/gofeatureflagproxy/").toString(), null) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals("/gofeatureflagproxy/ofrep/v1/evaluate/flags/bool_flag", request.getPath()); + } + + @DisplayName("should report FLAG_NOT_FOUND for an unknown flag") + @Test + void shouldReportFlagNotFoundForAnUnknownFlag() { + val got = evaluator().getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); + + // the OFREP provider answers with an error code rather than raising, unlike the in-process one + assertEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); + assertEquals(false, got.getValue()); + } + + @DisplayName("should report every flag as trackable") + @Test + void shouldReportEveryFlagAsTrackable() { + // in remote mode the relay proxy collects the evaluations itself, so the provider cannot + // know what is tracked and must not filter anything out + assertTrue(evaluator().isFlagTrackable("bool_flag")); + assertTrue(evaluator().isFlagTrackable("DOES_NOT_EXIST")); + } +} From b0cee62eff87f9bfe616675d3678ef7034c9ab4c Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 09:16:16 +0200 Subject: [PATCH 30/93] fix(go-feature-flag): preserve the endpoint path prefix on the collector route GOFF-COLL-001 requires events to be posted to {dataCollectorBaseURL}/v1/data/collector. The URL was built from an absolute path, which RFC 3986 resolves by replacing the base path, so an endpoint mounted under a prefix had that prefix discarded. Build it through the route() helper, as the configuration and evaluation routes already do. No absolute path route construction remains in the provider. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 2 +- .../providers/gofeatureflag/util/Const.java | 1 + .../gofeatureflag/api/GoFeatureFlagApiTest.java | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 023e1b08d1..9277bf8b71 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -146,7 +146,7 @@ public Optional retrieveFlagConfiguration(final String etag, public void sendEventToDataCollector(final List eventsList, final Map exporterMetadata) { try { ExporterRequest requestBody = new ExporterRequest(eventsList, exporterMetadata); - URI url = this.endpoint.resolve("/v1/data/collector"); + URI url = route(Const.PATH_DATA_COLLECTOR); HttpRequest request = prepareHttpRequest(url, requestBody); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index ae1784f765..f692b89416 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -18,6 +18,7 @@ public class Const { public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; + public static final String PATH_DATA_COLLECTOR = "v1/data/collector"; // FLAG CONFIGURATION FIELDS // the only field of a flag configuration a provider may read, everything else is the engine's public static final String FIELD_TRACK_EVENTS = "trackEvents"; diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index bbd910857a..7382584c40 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -141,6 +141,20 @@ public void requestShouldCallTheCollectorEndpoint() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("request should keep the path prefix of the endpoint") + @Test + public void requestShouldKeepThePathPrefixOfTheEndpoint() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); + + val want = "/gofeatureflagproxy/v1/data/collector"; + assertEquals(want, server.takeRequest().getPath()); + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test From b1e9a77ab91f3df4399bb16fceaba1136497cc37 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 10:08:36 +0200 Subject: [PATCH 31/93] feat(go-feature-flag): support dataCollectorBaseURL GOFF-CFG-006 asks for a dataCollectorBaseURL option that overrides the base URL of the data collector route only, leaving flag configuration and evaluation on endpoint, and falling back to endpoint when unset. The provider had no such option, so a collector served from somewhere other than the relay proxy could not be addressed. Add the option, resolve the fallback in its getter like the other options, and reject a malformed value at construction time. The URL normalisation moves into asBaseUri so both bases are treated identically, and route() gains a form taking an explicit base. Signed-off-by: Thomas Poignant --- .../GoFeatureFlagProviderOptions.java | 24 +++++++ .../gofeatureflag/api/GoFeatureFlagApi.java | 36 +++++++--- .../api/GoFeatureFlagApiTest.java | 66 +++++++++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index 691a32278b..cbcc2c8346 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -42,6 +42,13 @@ public class GoFeatureFlagProviderOptions { * https://mydomain.com/gofeatureflagproxy/ */ private String endpoint; + /** + * (optional) dataCollectorBaseURL is the base URL used to publish the evaluation data, when the + * data collector is not served by the relay proxy itself. It replaces the whole base of the + * collector route, scheme, host, port and path prefix included, and applies to that route only: + * the flag configuration and the evaluations keep using endpoint. Default: endpoint + */ + private String dataCollectorBaseURL; /** * (optional) timeout in millisecond we are waiting when calling the go-feature-flag relay proxy * API. Default: 10000 ms @@ -126,6 +133,15 @@ public EvaluationType getEvaluationType() { return evaluationType == null ? EvaluationType.IN_PROCESS : evaluationType; } + /** + * Get the base URL used to publish the evaluation data. + * + * @return the configured data collector base URL, the endpoint if none was set + */ + public String getDataCollectorBaseURL() { + return dataCollectorBaseURL == null || dataCollectorBaseURL.isEmpty() ? endpoint : dataCollectorBaseURL; + } + /** * Get the timeout in millisecond when calling the GO Feature Flag relay proxy API. * @@ -228,6 +244,14 @@ public void validate() throws InvalidOptions { throw new InvalidEndpoint("malformed endpoint: " + endpoint); } + if (dataCollectorBaseURL != null && !dataCollectorBaseURL.isEmpty()) { + try { + new URL(dataCollectorBaseURL); + } catch (MalformedURLException e) { + throw new InvalidEndpoint("malformed dataCollectorBaseURL: " + dataCollectorBaseURL); + } + } + if (wasmEvaluatorPoolSize != null && wasmEvaluatorPoolSize < 1) { throw new InvalidOptions("wasmEvaluatorPoolSize must be at least 1"); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 9277bf8b71..367ccfa041 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -50,6 +50,9 @@ public final class GoFeatureFlagApi { /** endpoint is the endpoint of the GO Feature Flag relay proxy. */ private final URI endpoint; + /** dataCollectorBaseURL is the base of the data collector route, the endpoint unless overridden. */ + private final URI dataCollectorBaseURL; + /** timeout is the timeout in milliseconds for the HTTP requests. */ private final int timeout; @@ -69,8 +72,8 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva this.apiKey = options.getApiKey(); try { - val rawEndpoint = options.getEndpoint(); - this.endpoint = new URI(rawEndpoint.endsWith("/") ? rawEndpoint : rawEndpoint + "/"); + this.endpoint = asBaseUri(options.getEndpoint()); + this.dataCollectorBaseURL = asBaseUri(options.getDataCollectorBaseURL()); } catch (URISyntaxException e) { throw new InvalidEndpoint(e); } @@ -101,7 +104,7 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva public Optional retrieveFlagConfiguration(final String etag, final List flags) { try { val request = new FlagConfigApiRequest(flags == null ? Collections.emptyList() : flags); - final URI url = route(Const.PATH_FLAG_CONFIGURATION); + final URI url = route(this.endpoint, Const.PATH_FLAG_CONFIGURATION); final HttpRequest httpRequest = etag != null && !etag.isEmpty() ? prepareHttpRequest(url, request, Const.HTTP_HEADER_IF_NONE_MATCH, etag) @@ -146,7 +149,7 @@ public Optional retrieveFlagConfiguration(final String etag, public void sendEventToDataCollector(final List eventsList, final Map exporterMetadata) { try { ExporterRequest requestBody = new ExporterRequest(eventsList, exporterMetadata); - URI url = route(Const.PATH_DATA_COLLECTOR); + URI url = route(this.dataCollectorBaseURL, Const.PATH_DATA_COLLECTOR); HttpRequest request = prepareHttpRequest(url, requestBody); @@ -226,15 +229,28 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) } /** - * route builds the URL of an API route from the configured endpoint, preserving any path prefix - * the endpoint carries. Resolving an absolute path such as {@code /v1/flag/configuration} would - * discard that prefix (RFC 3986 section 5.3) and silently retarget the request. + * route builds the URL of an API route from an arbitrary base, so that the data collector can be + * addressed somewhere other than the relay proxy. * - * @param path - route path, relative to the endpoint and without a leading slash + * @param base - base URL of the route, already normalised by {@link #asBaseUri(String)} + * @param path - route path, relative to the base and without a leading slash * @return the URL to call */ - private URI route(final String path) { - return this.endpoint.resolve(path.startsWith("/") ? path.substring(1) : path); + private static URI route(final URI base, final String path) { + return base.resolve(path.startsWith("/") ? path.substring(1) : path); + } + + /** + * asBaseUri normalises a configured URL into a base other paths can be resolved against. The + * trailing slash makes it directory-like, so resolving a relative path appends to any prefix it + * carries instead of replacing it. + * + * @param url - the configured URL + * @return the URL as a base + * @throws URISyntaxException - if the URL is not a valid URI + */ + private static URI asBaseUri(final String url) throws URISyntaxException { + return new URI(url.endsWith("/") ? url : url + "/"); } /** diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 7382584c40..dd3161136e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag.api; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -30,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -155,6 +157,70 @@ public void requestShouldKeepThePathPrefixOfTheEndpoint() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("request should go to dataCollectorBaseURL when it is set") + @Test + public void requestShouldGoToDataCollectorBaseUrlWhenItIsSet() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseURL( + collectorServer.url("/collector-prefix/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); + + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + // the whole base is replaced: host, port and path prefix + assertEquals("/collector-prefix/v1/data/collector", request.getPath()); + assertEquals(0, server.getRequestCount(), "the endpoint should not have been called"); + } + } + + @SneakyThrows + @DisplayName("the other routes should keep using the endpoint when dataCollectorBaseURL is set") + @Test + public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSet() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseURL(collectorServer.url("").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + api.retrieveFlagConfiguration(null, Collections.emptyList()); + assertEquals("/v1/flag/configuration", server.takeRequest().getPath()); + assertEquals(0, collectorServer.getRequestCount()); + } + } + + @SneakyThrows + @DisplayName("dataCollectorBaseURL should carry the api key and the timeout") + @Test + public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val apiKey = "my-api-key"; + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseURL(collectorServer.url("").toString()) + .apiKey(apiKey) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); + + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + } + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test From 7cc17498027e005ecaeebbfeda65003ecbdbad33 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 13:56:53 +0200 Subject: [PATCH 32/93] test(go-feature-flag): cover the timeout on an overridden collector base URL GOFF-CFG-007 requires authentication, custom headers and the timeout to apply to dataCollectorBaseURL exactly as they do to the endpoint. That holds by construction, because every route builds its request through prepareHttpRequest and the override changes only the base URI, but the timeout leg was the one not covered by a test. Delay the collector response past a short configured timeout and assert the publish fails. Raising the timeout makes the test pass again, so it tracks the configured value rather than an ambient failure. Signed-off-by: Thomas Poignant --- .../api/GoFeatureFlagApiTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index dd3161136e..93af46c99b 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -36,7 +36,11 @@ import lombok.extern.slf4j.Slf4j; import lombok.val; import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -199,6 +203,31 @@ public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSe } } + @SneakyThrows + @DisplayName("dataCollectorBaseURL should carry the configured timeout") + @Test + public void dataCollectorBaseUrlShouldCarryTheConfiguredTimeout() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new Dispatcher() { + @Override + public @NonNull MockResponse dispatch(@NonNull RecordedRequest request) { + return new MockResponse().setResponseCode(200).setHeadersDelay(5, TimeUnit.SECONDS); + } + }); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseURL(collectorServer.url("").toString()) + .timeout(200) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToSendEventsException.class, + () -> api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>())); + } + } + @SneakyThrows @DisplayName("dataCollectorBaseURL should carry the api key and the timeout") @Test From 9f49f5b42d415e378525104f7b8a74ee52a500f4 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 14:16:22 +0200 Subject: [PATCH 33/93] fix(go-feature-flag): keep structured flag metadata parseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOFF-EVAL-009 forbids coercing a metadata value into a misleading string. Any value that was not a Long, Integer, Float, Double or Boolean went through toString(), so a nested object reached the caller as the Java map literal {a=1} rather than {"a":1} — not JSON, and not parseable back into anything. A null value hit toString() on null and threw. ImmutableMetadata cannot hold a nested structure: its builder exposes only typed scalar adders, so "structure intact" is unachievable in Java and §1.5 defers that half to the SDK. Carry the value as its JSON text instead, which stays parseable, and skip nulls. A value Jackson cannot serialise falls back to String.valueOf with a warning rather than throwing. MetadataUtil gets its own ObjectMapper rather than the shared Const.SERIALIZE_OBJECT_MAPPER, which GoFeatureFlagApi's constructor mutates (INDENT_OUTPUT), so the output would depend on whether a provider had been constructed. The rewritten test pinned the defect by asserting "java.lang.RuntimeException". Signed-off-by: Thomas Poignant --- .../gofeatureflag/util/MetadataUtil.java | 22 ++++++++++++++++-- .../gofeatureflag/util/MetadataUtilTest.java | 23 +++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java index 965e55b9ac..8d444473bd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java @@ -1,16 +1,21 @@ package dev.openfeature.contrib.providers.gofeatureflag.util; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.sdk.ImmutableMetadata; import java.util.Map; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * MetadataUtil is a utility class to convert the metadata received from the server to an * ImmutableMetadata format known by Open Feature. */ +@Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MetadataUtil { + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** * convertFlagMetadata is converting the flagMetadata object received from the server to an * ImmutableMetadata format known by Open Feature. @@ -24,7 +29,11 @@ public static ImmutableMetadata convertFlagMetadata(Map flagMeta return builder.build(); } flagMetadata.forEach((k, v) -> { - if (v instanceof Long) { + if (v == null) { + log.debug("skipping null metadata value for key {}", k); + } else if (v instanceof String) { + builder.addString(k, (String) v); + } else if (v instanceof Long) { builder.addLong(k, (Long) v); } else if (v instanceof Integer) { builder.addInteger(k, (Integer) v); @@ -35,9 +44,18 @@ public static ImmutableMetadata convertFlagMetadata(Map flagMeta } else if (v instanceof Boolean) { builder.addBoolean(k, (Boolean) v); } else { - builder.addString(k, v.toString()); + builder.addString(k, asJson(k, v)); } }); return builder.build(); } + + private static String asJson(final String key, final Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + log.warn("metadata value for key {} could not be serialised to JSON", key, e); + return String.valueOf(value); + } + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java index 3beaabe305..42964e403f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java @@ -2,9 +2,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import dev.openfeature.sdk.ImmutableMetadata; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -48,14 +50,25 @@ void testConvertFlagMetadata_WithVariousTypes() { } @Test - void testConvertFlagMetadata_UnsupportedType() { - // Test with a map containing an unsupported type + void testConvertFlagMetadata_StructuredValueStaysParseable() { Map flagMetadata = new HashMap<>(); - flagMetadata.put("key1", new RuntimeException()); // Unsupported type + flagMetadata.put("nested", Map.of("a", 1)); + flagMetadata.put("list", List.of(1, 2, 3)); ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + assertEquals("{\"a\":1}", metadata.getString("nested")); + assertEquals("[1,2,3]", metadata.getString("list")); + } - assertNotNull(metadata); - assertEquals("java.lang.RuntimeException", metadata.getString("key1")); + @Test + void testConvertFlagMetadata_NullValueIsSkipped() { + Map flagMetadata = new HashMap<>(); + flagMetadata.put("present", "value"); + flagMetadata.put("absent", null); + + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + + assertEquals("value", metadata.getString("present")); + assertNull(metadata.getString("absent")); } } From 99821b31fb96cae776be7e0fe091648cb5f85e55 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 15:41:11 +0200 Subject: [PATCH 34/93] test(go-feature-flag): pin the relay proxy metadata pass-through GOFF-EVAL-010 asks that metadata keys added by the relay proxy be passed through verbatim: the provider must not strip them, and must not require them. The violation it was raised against was OfrepResponse.toGoFeatureFlagResponse, which read gofeatureflag_cacheable and gofeatureflag_version out of the map with metadata.remove(), deleting them on the way to the caller. That class went away with the move to OfrepProvider, so the requirement now holds by construction and nothing pinned it. Cover both halves. gofeatureflag_version had no fixture at all, so only the cacheable key was protected against a regression. Absence is covered twice, since a relay proxy may omit the reserved keys or omit metadata entirely. Removing the two keys from the fixture, which is what the deleted code did, fails the first test. Signed-off-by: Thomas Poignant --- .../evaluator/RemoteEvaluatorTest.java | 35 +++++++++++++++++++ .../metadata_absent.json | 6 ++++ .../metadata_with_version.json | 11 ++++++ .../metadata_without_goff_keys.json | 9 +++++ 4 files changed, 61 insertions(+) create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java index afaef9e89a..b6ab1e1ea8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -109,6 +109,41 @@ void shouldPassTheRelayProxyMetadataThrough() { assertEquals("A flag that is always off", got.getFlagMetadata().getString("description")); } + @DisplayName("should pass gofeatureflag_version through untouched") + @Test + void shouldPassTheRelayProxyVersionThrough() { + val got = evaluator().getBooleanEvaluation("metadata_with_version", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals("1.2.3", got.getFlagMetadata().getString("gofeatureflag_version")); + assertEquals( + "A flag carrying both relay proxy metadata keys", + got.getFlagMetadata().getString("description")); + } + + @DisplayName("should evaluate a flag whose metadata carries no relay proxy key") + @Test + void shouldEvaluateAFlagWithoutRelayProxyMetadataKeys() { + val got = + evaluator().getBooleanEvaluation("metadata_without_goff_keys", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + assertNull(got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals( + "A relay proxy that adds no gofeatureflag_ keys", + got.getFlagMetadata().getString("description")); + } + + @DisplayName("should evaluate a flag whose response carries no metadata at all") + @Test + void shouldEvaluateAFlagWithoutAnyMetadata() { + val got = evaluator().getBooleanEvaluation("metadata_absent", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + @DisplayName("should not claim a remote evaluation in the flag metadata") @Test void shouldNotClaimARemoteEvaluationInTheFlagMetadata() { diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json new file mode 100644 index 0000000000..b4859bbab7 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json @@ -0,0 +1,6 @@ +{ + "key": "metadata_absent", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled" +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json new file mode 100644 index 0000000000..913ee6c955 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json @@ -0,0 +1,11 @@ +{ + "key": "metadata_with_version", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled", + "metadata": { + "gofeatureflag_cacheable": true, + "gofeatureflag_version": "1.2.3", + "description": "A flag carrying both relay proxy metadata keys" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json new file mode 100644 index 0000000000..4563e84e5e --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json @@ -0,0 +1,9 @@ +{ + "key": "metadata_without_goff_keys", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled", + "metadata": { + "description": "A relay proxy that adds no gofeatureflag_ keys" + } +} From 65a37eabcfa982a3d584d87dbe7cc8ccd4d782d7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 17 Sep 2026 15:41:14 +0200 Subject: [PATCH 35/93] fix(go-feature-flag): keep the flag metadata when a flag is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DISABLED branch of toProviderEvaluation built its ProviderEvaluation without a flagMetadata call, unlike the null-value and normal branches just below it, so the builder default of an empty ImmutableMetadata was returned. The engine does emit metadata on that path — disabled_bool in the test configuration comes back with description and defaultValue — so everything the flag author wrote was silently erased the moment a flag was disabled. This is the remaining leg of GOFF-EVAL-009: flag metadata must be passed through with its structure intact. It surfaced while auditing GOFF-EVAL-010, which needs no code change of its own. shouldUseBooleanDefaultValueIfTheFlagIsDisabled was pinning the loss, comparing against a FlagEvaluationDetails whose builder omitted flagMetadata. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 1 + .../gofeatureflag/GoFeatureFlagProviderTest.java | 4 ++++ .../evaluator/InProcessEvaluatorTest.java | 16 ++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 9f1630307c..c557f32061 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -246,6 +246,7 @@ static ProviderEvaluation toProviderEvaluation( .value(defaultValue) .variant(response.getVariationType()) .reason(Reason.DISABLED.name()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) .build(); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 746a6dfe6d..add29b3d9d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -417,6 +417,10 @@ void shouldUseBooleanDefaultValueIfTheFlagIsDisabled() { .variant("SdkDefault") .flagKey("disabled_bool") .reason(Reason.DISABLED.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) .build(); assertEquals(want, got); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index d73d6d86c5..5ee2bd8062 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -368,6 +368,22 @@ void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { assertNull(result.getErrorCode()); } + @DisplayName("Should keep the flag metadata when the flag is disabled") + @Test + void shouldKeepTheFlagMetadataWhenTheFlagIsDisabled() { + val response = responseWithValue(true); + response.setReason(Reason.DISABLED.name()); + response.setVariationType("SdkDefault"); + response.setMetadata(Map.of("description", "a disabled flag", "gofeatureflag_cacheable", true)); + + ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + + assertEquals(false, result.getValue()); + assertEquals(Reason.DISABLED.name(), result.getReason()); + assertEquals("a disabled flag", result.getFlagMetadata().getString("description")); + assertEquals(true, result.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + } + @DisplayName("Should not return a zero value when the value is null") @Test void shouldNotReturnAZeroValueWhenTheValueIsNull() { From 6ced26e8a83ea13dd89d7f777f1e86c226115cfb Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 18 Sep 2026 08:16:37 +0200 Subject: [PATCH 36/93] fix(go-feature-flag): keep polling after a failed configuration refresh GOFF-IP-010 requires that a failed refresh must not terminate polling. An exception thrown by the RxJava subscriber does not merely skip one refresh: LambdaObserver.onNext disposes the upstream chain, Observable.interval included, and only then calls the onError handler. That handler logged a line and made the failure look handled while polling was already dead for the lifetime of the provider. The onErrorResumeNext guarding the HTTP call sits inside the flatMap and never covered the subscriber. Three reachable ways in, all through the two cache validators, which GoFeatureFlagApi reads from optional headers and stores as null when absent: - a 200 with no ETag makes response.getEtag().equals(...) throw - a 200 with no or unparseable Last-Modified makes response.getLastUpdated().before(...) throw - a response accepted with a null Last-Modified stores lastUpdate=null, and Date.before(null) throws on the next refresh that does carry one, so the poll that poisons the state is not the one that dies Null-guard both comparisons on both sides, and wrap the whole apply in a catch so a bad response costs one refresh rather than the stream. Guarding the validators means a deployment serving neither reaches the change branch on every poll, so the emit is now conditional on the computed diff being non-empty; without that the provider announces a configuration change every polling interval forever. GOFF-EVT-004 still owns the rest of that requirement. The terminal handler claimed the API call had failed, which is the one thing it cannot be, since call failures are resumed upstream. It now says polling has stopped and logs the throwable rather than only its message. Not addressed here: initialize() only starts the daemon if the first fetch succeeds, so a transient failure at startup leaves the provider with no poller at all. That needs a decision on which init failures stay fatal. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 73 +++++++++++++------ .../evaluator/InProcessEvaluatorTest.java | 70 ++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 6 ++ 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index c557f32061..c12024d02a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -374,32 +374,59 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { return apiCallObservable.subscribe( response -> { - EvaluatorState current = this.state; - if (response.getEtag().equals(current.etag)) { - log.debug("flag configuration has not changed: {}", response); - return; + try { + applyFlagConfiguration(response); + } catch (Exception e) { + // an exception escaping the subscriber disposes it, which would stop polling + // for the lifetime of the provider rather than for this one refresh. + log.error("error while applying the flag configuration", e); } + }, + throwable -> log.error("flag configuration polling has stopped and will not resume", throwable)); + } - if (response.getLastUpdated().before(current.lastUpdate)) { - log.info("configuration received is older than the current one"); - return; - } + /** + * applyFlagConfiguration replaces the configuration state with a newly retrieved one, unless the + * response describes the configuration already held or an older one. + * + *

Both validators are optional: a relay proxy behind a cache or a reverse proxy may answer + * without an {@code ETag} or with a {@code Last-Modified} this provider cannot parse, so a null + * on either side means "cannot rule this response out" rather than a comparison. + * + * @param response - configuration returned by the last successful refresh + */ + private void applyFlagConfiguration(final FlagConfigResponse response) { + EvaluatorState current = this.state; + if (response.getEtag() != null && response.getEtag().equals(current.etag)) { + log.debug("flag configuration has not changed: {}", response); + return; + } - log.info("flag configuration has changed"); - val flagChanges = findFlagConfigurationChanges(current.flags, response.getFlags()); - this.state = new EvaluatorState( - response.getFlags(), - response.getEvaluationContextEnrichment(), - response.getEtag(), - response.getLastUpdated()); - val changeDetails = ProviderEventDetails.builder() - .flagsChanged(flagChanges) - .message("flag configuration has changed") - .build(); - this.emitProviderConfigurationChanged.accept(changeDetails); - }, - throwable -> - log.error("error while calling flag configuration API, error: {}", throwable.getMessage())); + if (response.getLastUpdated() != null + && current.lastUpdate != null + && response.getLastUpdated().before(current.lastUpdate)) { + log.info("configuration received is older than the current one"); + return; + } + + val flagChanges = findFlagConfigurationChanges(current.flags, response.getFlags()); + this.state = new EvaluatorState( + response.getFlags(), + response.getEvaluationContextEnrichment(), + response.getEtag(), + response.getLastUpdated()); + + if (flagChanges.isEmpty()) { + log.debug("flag configuration has not changed: {}", response); + return; + } + + log.info("flag configuration has changed"); + val changeDetails = ProviderEventDetails.builder() + .flagsChanged(flagChanges) + .message("flag configuration has changed") + .build(); + this.emitProviderConfigurationChanged.accept(changeDetails); } /** diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 5ee2bd8062..9814e2be56 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -26,9 +26,13 @@ import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import lombok.SneakyThrows; import lombok.val; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -87,6 +91,72 @@ void aSecondInitShouldNotLeaveASecondPollerRunning() { assertEquals(afterShutdown, goffApiMock.getConfigurationCallCount(), "polling continued after shutdown()"); } + @SneakyThrows + @DisplayName("polling should survive an error raised while applying a configuration") + @Test + void pollingShouldSurviveAnErrorRaisedWhileApplyingAConfiguration() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.CONFIG_CHANGES_EVERY_POLL).dispatcher); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + val refreshes = new AtomicInteger(); + val evaluator = new InProcessEvaluator(api, options, details -> { + if (refreshes.incrementAndGet() == 1) { + throw new IllegalStateException("an event consumer that fails on the first change"); + } + }); + + evaluator.initialize(new ImmutableContext()); + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertTrue( + refreshes.get() > 1, + "polling stopped after the first failed refresh, it reached " + refreshes.get() + " refresh(es)"); + } + } + + @SneakyThrows + @DisplayName("polling should survive a configuration served without ETag and Last-Modified") + @Test + void pollingShouldSurviveAConfigurationServedWithoutEtagAndLastModified() { + try (val s = new MockWebServer()) { + s.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + // neither cache validator: nothing to compare this response against + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}}}}"); + } + }); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + val changeEvents = new AtomicInteger(); + val evaluator = new InProcessEvaluator(api, options, details -> changeEvents.incrementAndGet()); + + evaluator.initialize(new ImmutableContext()); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val afterPolling = s.getRequestCount(); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val later = s.getRequestCount(); + val evaluated = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertTrue(later > afterPolling, "polling stopped, it stayed at " + afterPolling + " request(s)"); + assertEquals(true, evaluated.getValue()); + // the configuration never changes, so no validator to compare must not mean "changed" + assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 90a3990a4a..78807a4cde 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -167,6 +167,11 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case CONFIG_CHANGES_EVERY_POLL: + // alternating configurations, so every poll sees a validator it has not stored + configLocation = + configurationCallCount % 2 == 0 ? "valid-all-types-config-change.json" : "valid-all-types.json"; + break; case SIMPLE_CONFIG: configLocation = "valid-flag-config.json"; break; @@ -265,6 +270,7 @@ public enum MockMode { SIMPLE_CONFIG, DEFAULT, SERVE_OLD_CONFIGURATION, + CONFIG_CHANGES_EVERY_POLL, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, } From b941b30f2fa03e8b40320f38ab9fb0cffe9ad98a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 18 Sep 2026 08:33:26 +0200 Subject: [PATCH 37/93] fix(go-feature-flag): stop dropping null map entries on the way to the engine GOFF-IP-017 forbids discarding anything in the flag configuration response. SERIALIZE_WASM_MAPPER used the single-argument setDefaultPropertyInclusion(Include.NON_NULL) overload, which expands to Value.construct(NON_NULL, NON_NULL) and so sets content inclusion as well as value inclusion. MapSerializer applies content inclusion per entry, so every null-valued key of every map was silently dropped before the engine saw it. That reached both untyped maps of the engine input, at any depth: the evaluationContextEnrichment, which the relay proxy builds from a Go map where a nil value marshals to null, and evalContext, the caller's own evaluation context. In both cases the key went from "set to nothing" to "never set", which a targeting rule can tell apart. Pass the two-argument form so the inclusion applies to the engine input's own properties only. S05's WasmInputTest missed this because the opaque flag is a JsonNode rather than a Map property, so content inclusion never reached inside it, and that half was the half under test. Signed-off-by: Thomas Poignant --- .../providers/gofeatureflag/util/Const.java | 4 +- .../wasm/bean/WasmInputTest.java | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index f692b89416..93238f59e7 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -33,6 +33,8 @@ public class Const { new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); public static final ObjectMapper SERIALIZE_OBJECT_MAPPER = new ObjectMapper(); public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper() - .setDefaultPropertyInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .setDefaultPropertyInclusion(com.fasterxml.jackson.annotation.JsonInclude.Value.construct( + com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL, + com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)) .setDateFormat(new StdDateFormat().withColonInTimeZone(true)); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java index d7eba237f0..b3bd8a9329 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java @@ -1,8 +1,10 @@ package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import java.util.LinkedHashMap; import lombok.SneakyThrows; import lombok.val; import org.junit.jupiter.api.DisplayName; @@ -31,4 +33,40 @@ void theFlagShouldReachTheEngineExactlyAsItWasReceived() { // and the NON_NULL serialisation inclusion does not strip nulls inside the opaque flag. assertEquals(Const.DESERIALIZE_OBJECT_MAPPER.readTree(rawFlag), flagSentToEngine); } + + @SneakyThrows + @DisplayName("a null map value should reach the engine rather than be dropped") + @Test + void aNullMapValueShouldReachTheEngine() { + val nested = new LinkedHashMap(); + nested.put("innerNull", null); + nested.put("innerOk", 7); + val enrichment = new LinkedHashMap(); + enrichment.put("present", "value"); + enrichment.put("explicitNull", null); + enrichment.put("nested", nested); + val evalContext = new LinkedHashMap(); + evalContext.put("targetingKey", "user-key"); + evalContext.put("aContextAttributeSetToNull", null); + + val wasmInput = WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"variations\":{\"on\":true}}")) + .evalContext(evalContext) + .flagContext(FlagContext.builder() + .defaultSdkValue(false) + .evaluationContextEnrichment(enrichment) + .build()) + .build(); + + val sent = Const.DESERIALIZE_OBJECT_MAPPER.readTree( + new String(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput))); + val sentEnrichment = sent.get("flagContext").get("evaluationContextEnrichment"); + + assertTrue(sentEnrichment.get("explicitNull").isNull()); + assertEquals("value", sentEnrichment.get("present").asText()); + assertTrue(sentEnrichment.get("nested").get("innerNull").isNull()); + assertEquals(7, sentEnrichment.get("nested").get("innerOk").asInt()); + assertTrue(sent.get("evalContext").get("aContextAttributeSetToNull").isNull()); + } } From 38cfe2a29b815dd1d14c37b97744234c46658c17 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 18 Sep 2026 08:33:31 +0200 Subject: [PATCH 38/93] test(go-feature-flag): pin the tolerance of unrecognised configuration fields GOFF-IP-017 requires unknown fields anywhere in the flag configuration response to be tolerated, without failing or warning. That holds today, but it rests entirely on FAIL_ON_UNKNOWN_PROPERTIES being disabled on the shared deserialising mapper, and nothing pinned it: the existing opacity test covers a field inside a flag, which is GOFF-IP-016's concern, not a field beside it. Cover the response envelope instead, with unrecognised fields at the top level, inside a flag, and inside the enrichment, and assert the configuration still reaches the engine and evaluates. Setting FAIL_ON_UNKNOWN_PROPERTIES back to true fails the first of these with UnrecognizedPropertyException on "schemaVersion". These assert tolerance, not preservation. An unknown top-level field is still discarded at the FlagConfigApiResponse boundary, which the requirement's "preserved" forbids; carrying it would mean threading a bag through to state that nothing reads, since the engine input has no slot for a response-level sibling. Left as is deliberately. Signed-off-by: Thomas Poignant --- .../api/GoFeatureFlagApiTest.java | 19 +++++++++++++++++ .../evaluator/InProcessEvaluatorTest.java | 18 ++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 21 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 93af46c99b..c253a9ddf6 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -478,6 +478,25 @@ public void aFlagShouldBeKeptOpaque() { assertEquals("teamId", flag.get("bucketingKey").asText()); } + @SneakyThrows + @DisplayName("an unknown field beside the flags should be tolerated") + @Test + public void anUnknownFieldBesideTheFlagsShouldBeTolerated() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("unknown-response-field", Collections.emptyList()); + + // a top level field the provider has no property for must not fail the refresh + assertTrue(got.isPresent()); + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"deep\": {\"deeper\": 1}}"), + got.get().getFlags().get("TEST").get("aFieldFromANewerEngine")); + assertEquals(1, got.get().getEvaluationContextEnrichment().get("anUnknownEnrichmentKey")); + } + @SneakyThrows @DisplayName("a 200 carrying no flag map should be a failed refresh") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 9814e2be56..0650d62e6c 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -157,6 +157,24 @@ public MockResponse dispatch(RecordedRequest request) { } } + @SneakyThrows + @DisplayName("a flag carrying unknown fields should still evaluate through the engine") + @Test + void aFlagCarryingUnknownFieldsShouldStillEvaluateThroughTheEngine() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.UNKNOWN_RESPONSE_FIELD).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val got = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + // the engine receives the flag with the unrecognised fields still on it + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 78807a4cde..baa5b9635f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -129,6 +129,22 @@ public MockResponse handleCollector(RecordedRequest request) { return new MockResponse().setResponseCode(200).setBody("{\"ingestedContentCount\":0}"); } + /** + * a configuration carrying fields this provider has no property for, at the top level, inside a + * flag and inside the enrichment: all of them must be tolerated and none discarded. + */ + private MockResponse unknownResponseFieldConfig() { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"schemaVersion\": 2," + + " \"aFieldFromANewerRelayProxy\": {\"nested\": [1, 2, 3]}," + + " \"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}," + + " \"aFieldFromANewerEngine\": {\"deep\": {\"deeper\": 1}}}}," + + " \"evaluationContextEnrichment\": {\"anUnknownEnrichmentKey\": 1}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-response-field\""); + } + @SneakyThrows public MockResponse handleFlagConfiguration(RecordedRequest request) { var configLocation = "valid-all-types.json"; @@ -167,6 +183,8 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case UNKNOWN_RESPONSE_FIELD: + return unknownResponseFieldConfig(); case CONFIG_CHANGES_EVERY_POLL: // alternating configurations, so every poll sees a validator it has not stored configLocation = @@ -208,6 +226,8 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { + " \"aFieldFromANewerEngine\": {\"nested\": [1, 2, 3]}," + " \"bucketingKey\": \"teamId\"}}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-flag-field\""); + case "unknown-response-field": + return unknownResponseFieldConfig(); case "no-flags": // a 200 whose body carries no flags key at all return new MockResponse() @@ -271,6 +291,7 @@ public enum MockMode { DEFAULT, SERVE_OLD_CONFIGURATION, CONFIG_CHANGES_EVERY_POLL, + UNKNOWN_RESPONSE_FIELD, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, } From afedcf2c8062cc84611bb750ef2aa33c8b927a5b Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 21 Sep 2026 11:44:10 +0200 Subject: [PATCH 39/93] fix(go-feature-flag): report rejected credentials as fatal in remote mode GOFF-EVT-007 requires an authentication failure to put the provider in PROVIDER_FATAL, and GOFF-EVT-001 forbids a capability being present in one evaluation mode and silently absent in the other. Remote evaluation holds no configuration, so its initialization has nothing to fetch and cannot discover that the API key is wrong: against a relay proxy answering 401 or 403 the provider came up READY and stayed there, returning the caller's default with a GENERAL error on every evaluation forever. In-process mode reaches FATAL through AuthenticationFailure at init. Initialization stays a no-op, since a remote provider can serve evaluations immediately. The first evaluation is the earliest point at which rejected credentials can be observed, so report them there. The SDK's state manager moves to FATAL on a PROVIDER_ERROR event whose details carry PROVIDER_FATAL, which works after initialization has returned. Both evaluators now share one emitter rather than the in-process one owning a configuration-changed callback, so either mode can raise any event. The detection is coupled to an upstream message string, and that is the only signal available: the OFREP client folds 401 and 403 into a returned GENERAL error code, and there is no FatalError anywhere in ofrep 0.0.2. A bodiless 401 from a gateway is parsed before its status is read and so cannot be recognised at all. Both are recorded in the ofrep findings for an upstream issue. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 8 +- .../evaluator/InProcessEvaluator.java | 19 ++-- .../evaluator/RemoteEvaluator.java | 66 ++++++++++++-- .../GoFeatureFlagProviderTest.java | 54 ++++++++++++ .../evaluator/InProcessEvaluatorTest.java | 6 +- .../evaluator/RemoteEvaluatorTest.java | 88 +++++++++++++++++-- 6 files changed, 210 insertions(+), 31 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index e2c9cfc415..37d924f24e 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -18,6 +18,7 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Tracking; import dev.openfeature.sdk.TrackingEventDetails; @@ -27,6 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -188,11 +190,11 @@ public void track(final String eventName, final EvaluationContext context, final */ private IEvaluator getEvaluator() { // Select the evaluator based on the evaluation type + BiConsumer emitter = this::emit; if (options.getEvaluationType() == null || options.getEvaluationType() == EvaluationType.IN_PROCESS) { - Consumer emitProviderConfigurationChanged = this::emitProviderConfigurationChanged; - return new InProcessEvaluator(this.api, this.options, emitProviderConfigurationChanged); + return new InProcessEvaluator(this.api, this.options, emitter); } - return new RemoteEvaluator(this.options); + return new RemoteEvaluator(this.options, emitter); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index c12024d02a..1f435672f4 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -15,6 +15,7 @@ import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; @@ -30,7 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -46,8 +47,8 @@ public class InProcessEvaluator implements IEvaluator { private final WasmEvaluatorPool evaluationPool; /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; - /** Method to call when we have a configuration change. */ - private final Consumer emitProviderConfigurationChanged; + /** Method to call to emit a provider event to the SDK. */ + private final BiConsumer emitter; /** Immutable snapshot of all flag configuration state; updated atomically by the polling daemon. */ private volatile EvaluatorState state; /** disposable which manage the polling of the flag configurations. */ @@ -97,17 +98,17 @@ private EvaluatorState( /** * Constructor of the InProcessEvaluator. * - * @param api - API to contact GO Feature Flag - * @param options - options to configure the provider - * @param emitProviderConfigurationChanged - method to call when we have a configuration change + * @param api - API to contact GO Feature Flag + * @param options - options to configure the provider + * @param emitter - method to call to emit a provider event to the SDK */ public InProcessEvaluator( GoFeatureFlagApi api, GoFeatureFlagProviderOptions options, - Consumer emitProviderConfigurationChanged) { + BiConsumer emitter) { this.api = api; this.options = options; - this.emitProviderConfigurationChanged = emitProviderConfigurationChanged; + this.emitter = emitter; this.state = EvaluatorState.notLoaded(); int poolSize = options.getWasmEvaluatorPoolSize() != null ? options.getWasmEvaluatorPoolSize() @@ -426,7 +427,7 @@ private void applyFlagConfiguration(final FlagConfigResponse response) { .flagsChanged(flagChanges) .message("flag configuration has changed") .build(); - this.emitProviderConfigurationChanged.accept(changeDetails); + this.emitter.accept(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, changeDetails); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 7331aba068..20950ab7fd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -6,10 +6,15 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.ofrep.OfrepProvider; import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions; +import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Value; import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -19,15 +24,28 @@ */ @Slf4j public class RemoteEvaluator implements IEvaluator { + /** + * Start of the error message the OFREP client reports for a 401 or 403. That client folds every + * status into a GENERAL error code, so the message is the only part of the result that still + * distinguishes rejected credentials from an ordinary failure. + */ + private static final String OFREP_AUTHENTICATION_ERROR = "authentication/authorization error for flag:"; + /** OFREP provider doing the actual remote evaluation. */ private final OfrepProvider ofrep; + /** Method to call to emit a provider event to the SDK. */ + private final BiConsumer emitter; + /** Guards against re-reporting the same authentication failure on every later evaluation. */ + private final AtomicBoolean authenticationFailureReported = new AtomicBoolean(); /** * Constructor of the evaluator. * - * @param opts - options to configure the provider + * @param opts - options to configure the provider + * @param emitter - method to call to emit a provider event to the SDK */ - public RemoteEvaluator(GoFeatureFlagProviderOptions opts) { + public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer emitter) { + this.emitter = emitter; val headers = ImmutableMap.>builder(); if (opts.getApiKey() != null && !opts.getApiKey().isEmpty()) { headers.put(Const.HTTP_HEADER_API_KEY, ImmutableList.of(opts.getApiKey())); @@ -48,11 +66,13 @@ public boolean isFlagTrackable(String flagKey) { @Override public void initialize(final EvaluationContext ctx, final String domain) throws Exception { + this.authenticationFailureReported.set(false); this.ofrep.initialize(ctx, domain); } @Override public void initialize(final EvaluationContext ctx) throws Exception { + this.authenticationFailureReported.set(false); this.ofrep.initialize(ctx); } @@ -63,26 +83,58 @@ public void shutdown() { @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return this.ofrep.getBooleanEvaluation(key, defaultValue, ctx); + return reportAuthenticationFailure(this.ofrep.getBooleanEvaluation(key, defaultValue, ctx)); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return this.ofrep.getStringEvaluation(key, defaultValue, ctx); + return reportAuthenticationFailure(this.ofrep.getStringEvaluation(key, defaultValue, ctx)); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return this.ofrep.getIntegerEvaluation(key, defaultValue, ctx); + return reportAuthenticationFailure(this.ofrep.getIntegerEvaluation(key, defaultValue, ctx)); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return this.ofrep.getDoubleEvaluation(key, defaultValue, ctx); + return reportAuthenticationFailure(this.ofrep.getDoubleEvaluation(key, defaultValue, ctx)); } @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - return this.ofrep.getObjectEvaluation(key, defaultValue, ctx); + return reportAuthenticationFailure(this.ofrep.getObjectEvaluation(key, defaultValue, ctx)); + } + + /** + * reportAuthenticationFailure moves the provider to the fatal state when the relay proxy has + * rejected our credentials. + * + *

Remote evaluation holds no configuration, so initialization has nothing to fetch and cannot + * discover that the API key is wrong. The first evaluation is therefore the earliest point at + * which the provider can learn it, and rejected credentials cannot be repaired by retrying, so + * the SDK must be told rather than left reporting a per-call error forever.

+ * + * @param evaluation - result returned by the OFREP client + * @param - type of the flag value + * @return the evaluation, unchanged + */ + private ProviderEvaluation reportAuthenticationFailure(final ProviderEvaluation evaluation) { + if (evaluation.getErrorCode() != ErrorCode.GENERAL + || evaluation.getErrorMessage() == null + || !evaluation.getErrorMessage().startsWith(OFREP_AUTHENTICATION_ERROR)) { + return evaluation; + } + + if (this.authenticationFailureReported.compareAndSet(false, true)) { + log.error("the relay proxy rejected our credentials, the provider cannot recover by retrying"); + this.emitter.accept( + ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder() + .errorCode(ErrorCode.PROVIDER_FATAL) + .message("authentication/authorization error while evaluating a flag remotely") + .build()); + } + return evaluation; } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index add29b3d9d..f0555e4083 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -22,6 +22,7 @@ import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.MutableTrackingEventDetails; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FatalError; @@ -951,6 +952,59 @@ void shouldErrorIfEndpointNotAvailable() { } } + @DisplayName("Should become FATAL on the first evaluation rejected for bad credentials") + @SneakyThrows + @Test + void shouldBecomeFatalOnTheFirstEvaluationRejectedForBadCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .apiKey("a-rejected-key") + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // remote evaluation holds no configuration, so initialization has nothing to fetch + // and cannot discover the credentials are wrong + assertEquals(ProviderState.READY, client.getProviderState()); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + + // the event is emitted on the SDK's own emitter thread + for (int i = 0; i < 100 && client.getProviderState() != ProviderState.FATAL; i++) { + Thread.sleep(20); + } + assertEquals(ProviderState.FATAL, client.getProviderState()); + } + } + + @DisplayName("Should stay READY when an evaluation fails for a repairable reason") + @SneakyThrows + @Test + void shouldStayReadyWhenAnEvaluationFailsForARepairableReason() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(200); + + // a 500 is repairable without touching the credentials + assertEquals(ProviderState.READY, client.getProviderState()); + } + } + @DisplayName("Should error if no API Key provided") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 0650d62e6c..1034b9f61d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -66,7 +66,7 @@ private InProcessEvaluator evaluator(MockWebServer srv) { .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); - return new InProcessEvaluator(api, options, details -> {}); + return new InProcessEvaluator(api, options, (event, details) -> {}); } @SneakyThrows @@ -103,7 +103,7 @@ void pollingShouldSurviveAnErrorRaisedWhileApplyingAConfiguration() { .build(); val api = GoFeatureFlagApi.builder().options(options).build(); val refreshes = new AtomicInteger(); - val evaluator = new InProcessEvaluator(api, options, details -> { + val evaluator = new InProcessEvaluator(api, options, (event, details) -> { if (refreshes.incrementAndGet() == 1) { throw new IllegalStateException("an event consumer that fails on the first change"); } @@ -140,7 +140,7 @@ public MockResponse dispatch(RecordedRequest request) { .build(); val api = GoFeatureFlagApi.builder().options(options).build(); val changeEvents = new AtomicInteger(); - val evaluator = new InProcessEvaluator(api, options, details -> changeEvents.incrementAndGet()); + val evaluator = new InProcessEvaluator(api, options, (event, details) -> changeEvents.incrementAndGet()); evaluator.initialize(new ImmutableContext()); Thread.sleep(POLLING_INTERVAL_MS * 4); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java index b6ab1e1ea8..62e945c144 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -9,9 +9,14 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; import lombok.SneakyThrows; import lombok.val; import okhttp3.mockwebserver.MockWebServer; @@ -36,16 +41,19 @@ void afterEach() throws IOException { this.server = null; } - private RemoteEvaluator evaluator(String endpoint, String apiKey) { - return new RemoteEvaluator(GoFeatureFlagProviderOptions.builder() - .endpoint(endpoint) - .apiKey(apiKey) - .timeout(1000) - .build()); + private RemoteEvaluator evaluator( + String endpoint, String apiKey, BiConsumer emitter) { + return new RemoteEvaluator( + GoFeatureFlagProviderOptions.builder() + .endpoint(endpoint) + .apiKey(apiKey) + .timeout(1000) + .build(), + emitter); } private RemoteEvaluator evaluator() { - return evaluator(this.server.url("").toString(), null); + return evaluator(this.server.url("").toString(), null, (event, details) -> {}); } @DisplayName("should resolve a boolean flag") @@ -154,11 +162,61 @@ void shouldNotClaimARemoteEvaluationInTheFlagMetadata() { assertNull(got.getFlagMetadata().getBoolean("gofeatureflag_evaluated_remotely")); } + @DisplayName("should report a fatal provider error when the credentials are rejected") + @SneakyThrows + @Test + void shouldReportAFatalProviderErrorWhenTheCredentialsAreRejected() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY).dispatcher); + val reported = new RecordedEvents(); + val got = evaluator(s.url("").toString(), "a-rejected-key", reported) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals(false, got.getValue()); + assertEquals(1, reported.events.size()); + assertEquals(ProviderEvent.PROVIDER_ERROR, reported.events.get(0)); + assertEquals(ErrorCode.PROVIDER_FATAL, reported.details.get(0).getErrorCode()); + } + } + + @DisplayName("should report the rejected credentials only once") + @SneakyThrows + @Test + void shouldReportTheRejectedCredentialsOnlyOnce() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY).dispatcher); + val reported = new RecordedEvents(); + val evaluator = evaluator(s.url("").toString(), "a-rejected-key", reported); + + evaluator.getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + evaluator.getStringEvaluation("string_flag", "d", new ImmutableContext("user-key")); + evaluator.getIntegerEvaluation("int_flag", 0, new ImmutableContext("user-key")); + + assertEquals(1, reported.events.size()); + } + } + + @DisplayName("should not report a fatal provider error for an ordinary failure") + @SneakyThrows + @Test + void shouldNotReportAFatalProviderErrorForAnOrdinaryFailure() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR).dispatcher); + val reported = new RecordedEvents(); + val got = evaluator(s.url("").toString(), null, reported) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // a 500 is repairable without touching the credentials, so it must stay a per-call error + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertTrue(reported.events.isEmpty()); + } + } + @SneakyThrows @DisplayName("should send the API key as an X-API-Key header") @Test void shouldSendTheApiKeyAsAnApiKeyHeader() { - evaluator(this.server.url("").toString(), "my-api-key") + evaluator(this.server.url("").toString(), "my-api-key", (event, details) -> {}) .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); val request = this.server.takeRequest(); @@ -179,7 +237,7 @@ void shouldNotSendAnApiKeyHeaderWhenNoneIsConfigured() { @DisplayName("should keep the path prefix of the endpoint") @Test void shouldKeepThePathPrefixOfTheEndpoint() { - evaluator(this.server.url("/gofeatureflagproxy/").toString(), null) + evaluator(this.server.url("/gofeatureflagproxy/").toString(), null, (event, details) -> {}) .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); val request = this.server.takeRequest(); @@ -204,4 +262,16 @@ void shouldReportEveryFlagAsTrackable() { assertTrue(evaluator().isFlagTrackable("bool_flag")); assertTrue(evaluator().isFlagTrackable("DOES_NOT_EXIST")); } + + /** Records what the evaluator emits, so a test can assert on the event kind and on its details. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + final List details = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + this.details.add(eventDetails); + } + } } From 191bc6bf9672bd84872409174f4699d51af5b566 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 21 Sep 2026 13:55:18 +0200 Subject: [PATCH 40/93] test(go-feature-flag): pin the content comparison behind a rotating ETag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOFF-EVT-004 forbids emitting PROVIDER_CONFIGURATION_CHANGED when the configuration has not changed, and requires comparing content where the validators cannot tell "changed" from "fetched". The empty-diff guard added in d497f86c does that, and the case the requirement names itself — a server sending no validator at all — is covered by pollingShouldSurviveAConfigurationServedWithoutEtagAndLastModified. The other half was not. A server that rotates the ETag while serving identical content reaches applyFlagConfiguration through a different exit, and no mock mode could produce that shape: every branch either hard-codes a validator or derives it from the fixture name. shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged does not cover it despite the name. It gets one poll, answered with the same ETag it sent, so it returns at the ETag short-circuit and never reaches the comparison; it still passes with the content guard deleted. The new test fails there with 4 events over 4 polls. Signed-off-by: Thomas Poignant --- .../GoFeatureFlagProviderTest.java | 26 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 9 +++++++ 2 files changed, 35 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index f0555e4083..eab5422efd 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -517,6 +517,32 @@ void shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged() { assertFalse(configurationChangedCalled.get()); } + @DisplayName("Should not emit configuration change event, if only the ETag has changed") + @SneakyThrows + @Test + void shouldNotEmitConfigurationChangeEventIfOnlyTheEtagHasChanged() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SAME_CONFIG_CHANGING_ETAG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicInteger changeEvents = new AtomicInteger(); + client.onProviderConfigurationChanged(event -> changeEvents.incrementAndGet()); + + Thread.sleep(500L); + + // every poll carries a validator the provider has never stored, so the ETag cannot + // tell "changed" from "fetched" and only the content can + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the provider should have polled"); + assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); + } + } + @DisplayName("Should change evaluation details if config has changed") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index baa5b9635f..ae5241607a 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -183,6 +183,14 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case SAME_CONFIG_CHANGING_ETAG: + // identical content behind a fresh validator on every poll: the ETag alone cannot + // tell "changed" from "fetched", so only the content can + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, "\"etag-" + configurationCallCount + "\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); case UNKNOWN_RESPONSE_FIELD: return unknownResponseFieldConfig(); case CONFIG_CHANGES_EVERY_POLL: @@ -292,6 +300,7 @@ public enum MockMode { SERVE_OLD_CONFIGURATION, CONFIG_CHANGES_EVERY_POLL, UNKNOWN_RESPONSE_FIELD, + SAME_CONFIG_CHANGING_ETAG, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, } From f49b5e97952b1be38f71a5bd4efe16a6253ed926 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 21 Sep 2026 17:51:52 +0200 Subject: [PATCH 41/93] feat(go-feature-flag): announce a stale configuration and its recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOFF-EVT-005 asks for PROVIDER_STALE after 3 consecutive failed refreshes while the last known good configuration keeps being served, and GOFF-EVT-006 for an event returning the provider to ready once it recovers. Neither existed: the polling chain logged every failure and discarded it, and "stale" appeared nowhere in the module. Count failures where the API call fails and reset where it returns, which is upstream of the filter that drops a 304. That placement is the whole design: a 304 means the server confirmed our configuration is current, so it is a refresh that worked, and it never reaches the subscriber. Resetting any later would make "consecutive" mean "cumulative" and march a healthy provider to stale in three polls. The initial fetch bypasses the chain entirely, so it resets too. Both announcements are edge triggered. A repeated PROVIDER_STALE is a no-op in the SDK's state manager, but the API re-runs every registered handler on every emission, so a level triggered version would re-fire consumer handlers for the whole outage. Emitting is guarded. The onErrorResumeNext lambda has no try/catch of its own, unlike the subscriber, so a throwing emitter there is delivered downstream as a terminal error and polling stops for good — reopening the hole d497f86c closed. That is reachable: shutdown() stops the event emitter executor before it stops the poller, so a late emission is rejected. A failed refresh touches no state, so serving the last known good configuration needs no code; it is asserted rather than assumed. An exception raised while applying a good response is not a failed refresh and is not counted. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 66 +++++- .../providers/gofeatureflag/util/Const.java | 2 + .../evaluator/InProcessEvaluatorTest.java | 196 ++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 48 +++++ 4 files changed, 310 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 1f435672f4..b91bbaf899 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -31,6 +31,8 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -53,6 +55,10 @@ public class InProcessEvaluator implements IEvaluator { private volatile EvaluatorState state; /** disposable which manage the polling of the flag configurations. */ private Disposable configurationDisposable; + /** Number of refreshes that have failed in a row, reset by any successful one. */ + private final AtomicInteger consecutiveRefreshFailures = new AtomicInteger(); + /** true between the announcement of a stale configuration and the announcement of its end. */ + private final AtomicBoolean staleAnnounced = new AtomicBoolean(); private static final class EvaluatorState { final Map flags; @@ -167,6 +173,10 @@ public void initialize(EvaluationContext ctx) throws Exception { configFlags.getEvaluationContextEnrichment(), configFlags.getEtag(), configFlags.getLastUpdated())); + // this fetch is a successful refresh too, and it bypasses the polling chain entirely. The + // SDK announces readiness itself once initialize() returns, so nothing is emitted here. + this.consecutiveRefreshFailures.set(0); + this.staleAnnounced.set(false); // start the polling of the flag configuration this.configurationDisposable = startCheckFlagConfigurationChangesDaemon(); @@ -361,10 +371,15 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { Observable intervalObservable = Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS, Schedulers.io()); Observable apiCallObservable = intervalObservable - .flatMap(tick -> Observable.fromCallable(() -> - this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList())) + .flatMap(tick -> Observable.fromCallable(() -> { + val configuration = this.api.retrieveFlagConfiguration( + this.state.etag, options.getEvaluationFlagList()); + emitRefreshSuccessEvent(); + return configuration; + }) .onErrorResumeNext(e -> { log.error("error while calling flag configuration API", e); + emitRefreshFailureEvent(); return Observable.>empty(); })) // a 304 emits an empty Optional: drop it here so the refresh consumer below is @@ -386,6 +401,53 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { throwable -> log.error("flag configuration polling has stopped and will not resume", throwable)); } + /** + * emitRefreshFailureEvent counts a failed refresh and marks the configuration stale once enough of + * them have happened in a row. + */ + private void emitRefreshFailureEvent() { + if (this.consecutiveRefreshFailures.incrementAndGet() != Const.STALE_AFTER_CONSECUTIVE_FAILURES) { + return; + } + + log.warn( + "{} consecutive failed refreshes, still serving the last known good configuration", + Const.STALE_AFTER_CONSECUTIVE_FAILURES); + this.staleAnnounced.set(true); + try { + this.emitter.accept( + ProviderEvent.PROVIDER_STALE, + ProviderEventDetails.builder() + .message("the flag configuration could not be refreshed " + + Const.STALE_AFTER_CONSECUTIVE_FAILURES + " times in a row") + .build()); + } catch (Exception e) { + log.error("error while emitting the {} event", ProviderEvent.PROVIDER_STALE, e); + } + } + + /** + * emitRefreshSuccessEvent counts a refresh that worked and, if the configuration had been announced + * as stale, announces that it no longer is. + */ + private void emitRefreshSuccessEvent() { + this.consecutiveRefreshFailures.set(0); + if (!this.staleAnnounced.compareAndSet(true, false)) { + return; + } + + log.info("the flag configuration could be refreshed again"); + try { + this.emitter.accept( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder() + .message("the flag configuration could be refreshed again") + .build()); + } catch (Exception e) { + log.error("error while emitting the {} event", ProviderEvent.PROVIDER_READY, e); + } + } + /** * applyFlagConfiguration replaces the configuration state with a newly retrieved one, unless the * response describes the configuration already held or an older one. diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 93238f59e7..d14c235dba 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -28,6 +28,8 @@ public class Const { public static final int DEFAULT_MAX_PENDING_EVENTS = 10000; public static final int DEFAULT_WASM_EVALUATOR_POOL_SIZE = Runtime.getRuntime().availableProcessors(); + /** consecutive failed refreshes after which the configuration is announced as stale. */ + public static final int STALE_AFTER_CONSECUTIVE_FAILURES = 3; // MAPPERS public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 1034b9f61d..6d5b233f04 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -15,6 +15,8 @@ import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; @@ -25,8 +27,11 @@ import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import lombok.SneakyThrows; import lombok.val; import okhttp3.mockwebserver.Dispatcher; @@ -175,6 +180,197 @@ void aFlagCarryingUnknownFieldsShouldStillEvaluateThroughTheEngine() { } } + @SneakyThrows + private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(srv.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, emitter); + } + + @SneakyThrows + @DisplayName("should go stale after three consecutive failed refreshes") + @Test + void shouldGoStaleAfterThreeConsecutiveFailedRefreshes() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_REFRESH_AFTER_INIT).dispatcher); + val stale = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_STALE) { + stale.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // two failures are not enough, the third is + Thread.sleep(POLLING_INTERVAL_MS * 2 + POLLING_INTERVAL_MS / 2); + assertTrue(stale.isEmpty(), "went stale after fewer than three failures"); + + Thread.sleep(POLLING_INTERVAL_MS * 3); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + assertEquals(1, stale.size(), "stale should be announced once, not on every later failure"); + // the configuration it could no longer refresh is still the one it serves: the flag + // resolves from it rather than reporting the provider as unready + assertNull(evaluated.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("a successful refresh should restart the failure run") + @Test + void aSuccessfulRefreshShouldRestartTheFailureRun() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_TWICE_THEN_RECOVER).dispatcher); + val stale = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_STALE) { + stale.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // two failures, then a success: the two must not carry over into the next run + Thread.sleep(POLLING_INTERVAL_MS * 4 + POLLING_INTERVAL_MS / 2); + assertTrue(stale.isEmpty(), "the failures before the successful refresh were carried over"); + + Thread.sleep(POLLING_INTERVAL_MS * 3); + evaluator.shutdown(); + assertEquals(1, stale.size(), "a fresh run of three failures should still go stale"); + } + } + + @SneakyThrows + @DisplayName("polling should survive a failure raised while announcing a stale configuration") + @Test + void pollingShouldSurviveAFailureRaisedWhileAnnouncingAStaleConfiguration() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.FAIL_REFRESH_AFTER_INIT); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s, (event, details) -> { + throw new IllegalStateException("an emitter that fails, as it does after a shutdown"); + }); + evaluator.initialize(new ImmutableContext()); + + // the stale announcement happens on the third failure; polling has to outlive it + Thread.sleep(POLLING_INTERVAL_MS * 4); + val afterStale = mock.getConfigurationCallCount(); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val later = mock.getConfigurationCallCount(); + evaluator.shutdown(); + + assertTrue(later > afterStale, "polling stopped, it stayed at " + afterStale + " call(s)"); + } + } + + @SneakyThrows + @DisplayName("a not modified response should count as a successful refresh") + @Test + void aNotModifiedResponseShouldCountAsASuccessfulRefresh() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_DURING_FAILURES).dispatcher); + val stale = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_STALE) { + stale.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // a 304 says the configuration in hand is current, so it is a refresh that worked + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertTrue(stale.isEmpty(), "a 304 was counted as a failed refresh"); + } + } + + @SneakyThrows + @DisplayName("should return to ready once refreshes recover after a stale configuration") + @Test + void shouldReturnToReadyOnceRefreshesRecoverAfterStale() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_UNTIL_RECOVERY).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + // three failures announce the stale configuration, then every later poll succeeds + Thread.sleep(POLLING_INTERVAL_MS * 8); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + // the recovery serves the configuration already held, so nothing else is announced: + // the return to ready happens once, not on every successful poll after it + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY), + recorded.events, + "the provider did not come back from stale, or came back more than once"); + assertNull(evaluated.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("a not modified response should bring the provider back to ready") + @Test + void aNotModifiedResponseShouldBringTheProviderBackToReady() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_AFTER_STALE).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + // a 304 carries no configuration, so it never reaches the refresh consumer: the + // recovery has to be recognised where the call succeeds, not where a response is applied + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY), + recorded.events, + "a 304 did not end the stale condition"); + } + } + + @SneakyThrows + @DisplayName("polling should survive a failure raised while announcing the return to ready") + @Test + void pollingShouldSurviveAFailureRaisedWhileAnnouncingTheReturnToReady() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.FAIL_UNTIL_RECOVERY); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s, (event, details) -> { + throw new IllegalStateException("an emitter that fails, as it does after a shutdown"); + }); + evaluator.initialize(new ImmutableContext()); + + // the return to ready is announced from the refresh call itself, where a throw would + // otherwise be read as one more failed refresh + Thread.sleep(POLLING_INTERVAL_MS * 6); + val afterRecovery = mock.getConfigurationCallCount(); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val later = mock.getConfigurationCallCount(); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + assertTrue(later > afterRecovery, "polling stopped, it stayed at " + afterRecovery + " call(s)"); + assertNull(evaluated.getErrorCode()); + } + } + + /** Records the events the evaluator emits, in the order it emits them. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index ae5241607a..900a78a8ec 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -183,6 +183,49 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case FAIL_REFRESH_AFTER_INIT: + // the initial fetch succeeds, then every poll fails: enough in a row and the + // configuration in hand can no longer be vouched for + if (configurationCallCount > 1) { + return new MockResponse().setResponseCode(500); + } + break; + case FAIL_TWICE_THEN_RECOVER: + // two failures, a success, then failures again: the run must restart from zero + if (configurationCallCount == 2 || configurationCallCount == 3 || configurationCallCount > 4) { + return new MockResponse().setResponseCode(500); + } + break; + case FAIL_UNTIL_RECOVERY: + // the initial fetch succeeds, three polls fail, then refreshes work again: the + // stale condition has an end, and the end has to be announced + if (configurationCallCount > 1 && configurationCallCount <= 4) { + return new MockResponse().setResponseCode(500); + } + break; + case NOT_MODIFIED_AFTER_STALE: + // the same three failures, but the refresh that ends them is a 304 rather than a + // configuration: it never reaches the consumer, yet it is a refresh that worked + if (configurationCallCount > 1 && configurationCallCount <= 4) { + return new MockResponse().setResponseCode(500); + } + if (configurationCallCount > 4) { + return new MockResponse().setResponseCode(304); + } + break; + case NOT_MODIFIED_DURING_FAILURES: + // two failures, a 304, two more failures: four failed polls in a row unless the 304 + // in the middle counts as the successful refresh it is + if (configurationCallCount == 2 + || configurationCallCount == 3 + || configurationCallCount == 5 + || configurationCallCount == 6) { + return new MockResponse().setResponseCode(500); + } + if (configurationCallCount > 1) { + return new MockResponse().setResponseCode(304); + } + break; case SAME_CONFIG_CHANGING_ETAG: // identical content behind a fresh validator on every poll: the ETag alone cannot // tell "changed" from "fetched", so only the content can @@ -301,6 +344,11 @@ public enum MockMode { CONFIG_CHANGES_EVERY_POLL, UNKNOWN_RESPONSE_FIELD, SAME_CONFIG_CHANGING_ETAG, + FAIL_REFRESH_AFTER_INIT, + FAIL_TWICE_THEN_RECOVER, + NOT_MODIFIED_DURING_FAILURES, + FAIL_UNTIL_RECOVERY, + NOT_MODIFIED_AFTER_STALE, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, } From b69fa5a01cb4ad4be46ef849055647b916b82649 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 21 Sep 2026 18:14:05 +0200 Subject: [PATCH 42/93] test(go-feature-flag): pin the edges of the stale and recovery announcements GOFF-EVT-006 requires an event returning the provider to ready once it recovers from stale. The recovery itself was covered, including the case where the refresh that ends the outage is a 304 and so never reaches the consumer. Two edges around it were not. A return to ready must not be announced when nothing was ever announced as stale: two failures then a success recovers from a condition no consumer was ever told about. Without the latch every successful poll would announce one. Recovering must re-arm the failure run rather than spend the provider's one chance to go stale. Removing the counter reset leaves the count climbing past the threshold, so a second outage is never announced: [STALE, READY] instead of [STALE, READY, STALE]. The latch is not what re-arms it, the reset is, which is why the test asserts the whole event sequence rather than a flag. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluatorTest.java | 83 ++++++++++++++----- .../gofeatureflag/util/GoffApiMock.java | 8 ++ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 6d5b233f04..b6346d1650 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -65,13 +65,27 @@ void afterEach() throws IOException { } @SneakyThrows - private InProcessEvaluator evaluator(MockWebServer srv) { + private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { val options = GoFeatureFlagProviderOptions.builder() .endpoint(srv.url("").toString()) .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - return new InProcessEvaluator(api, options, (event, details) -> {}); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, emitter); + } + + private InProcessEvaluator evaluator(MockWebServer srv) { + return evaluator(srv, (event, details) -> {}); + } + + /** Records the events the evaluator emits, in the order it emits them. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + } } @SneakyThrows @@ -180,16 +194,6 @@ void aFlagCarryingUnknownFieldsShouldStillEvaluateThroughTheEngine() { } } - @SneakyThrows - private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(srv.url("").toString()) - .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) - .build(); - return new InProcessEvaluator( - GoFeatureFlagApi.builder().options(options).build(), options, emitter); - } - @SneakyThrows @DisplayName("should go stale after three consecutive failed refreshes") @Test @@ -243,6 +247,28 @@ void aSuccessfulRefreshShouldRestartTheFailureRun() { } } + @SneakyThrows + @DisplayName("should not announce a return to ready when it never went stale") + @Test + void shouldNotAnnounceAReturnToReadyWhenItNeverWentStale() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_TWICE_THEN_RECOVER).dispatcher); + val events = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_READY) { + events.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // two failures then a success: recovering from something never announced is not news + Thread.sleep(POLLING_INTERVAL_MS * 4 + POLLING_INTERVAL_MS / 2); + evaluator.shutdown(); + + assertTrue(events.isEmpty(), "a return to ready was announced without a staleness to end"); + } + } + @SneakyThrows @DisplayName("polling should survive a failure raised while announcing a stale configuration") @Test @@ -288,6 +314,27 @@ void aNotModifiedResponseShouldCountAsASuccessfulRefresh() { } } + @SneakyThrows + @DisplayName("should go stale again after recovering from a first stale configuration") + @Test + void shouldGoStaleAgainAfterRecoveringFromAFirstStaleConfiguration() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.STALE_AFTER_A_RECOVERY).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + Thread.sleep(POLLING_INTERVAL_MS * 10); + evaluator.shutdown(); + + // announcing the recovery has to re-arm the run, not spend the provider's one chance + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY, ProviderEvent.PROVIDER_STALE), + recorded.events, + "a second outage after a recovery was not announced"); + } + } + @SneakyThrows @DisplayName("should return to ready once refreshes recover after a stale configuration") @Test @@ -361,16 +408,6 @@ void pollingShouldSurviveAFailureRaisedWhileAnnouncingTheReturnToReady() { } } - /** Records the events the evaluator emits, in the order it emits them. */ - private static final class RecordedEvents implements BiConsumer { - final List events = new ArrayList<>(); - - @Override - public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { - this.events.add(event); - } - } - @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 900a78a8ec..9a2423c92b 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -203,6 +203,13 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { return new MockResponse().setResponseCode(500); } break; + case STALE_AFTER_A_RECOVERY: + // stale, a recovery, then stale again: recovering has to re-arm the failure run + // rather than leave the provider unable to go stale a second time + if ((configurationCallCount > 1 && configurationCallCount <= 4) || configurationCallCount > 5) { + return new MockResponse().setResponseCode(500); + } + break; case NOT_MODIFIED_AFTER_STALE: // the same three failures, but the refresh that ends them is a 304 rather than a // configuration: it never reaches the consumer, yet it is a refresh that worked @@ -349,6 +356,7 @@ public enum MockMode { NOT_MODIFIED_DURING_FAILURES, FAIL_UNTIL_RECOVERY, NOT_MODIFIED_AFTER_STALE, + STALE_AFTER_A_RECOVERY, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, } From 2352c8a7d0f201685a455a8bb9128ce7a66e0905 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 22 Sep 2026 13:48:05 +0200 Subject: [PATCH 43/93] feat(go-feature-flag): ask the relay proxy when local evaluation fails GOFF-FALLBACK-001 requires an in-process evaluation that returns raw engine code PARSE_ERROR or GENERAL to be retried remotely over OFREP, the remote answer being the one the caller receives. Section 16 was unimplemented: the engine's error was mapped to an SDK exception and thrown, so a provider failing to read its own input told the caller the flag was at fault. The trigger reads the raw code, before toProviderEvaluation maps it. Mapping folds GO Feature Flag's own codes into GENERAL, so after it FLAG_CONFIG is indistinguishable from an engine failure and would fall back too, which GOFF-FALLBACK-003 forbids. The codes this evaluator synthesises itself, PROVIDER_NOT_READY and FLAG_NOT_FOUND, are outside the trigger set: they are answers about the caller's key, which the relay proxy would answer identically. Each resolver passes the matching resolver of the fallback, so a fallback returns the type the caller's resolver is contracted to return rather than reconverting one. The fallback is a RemoteEvaluator built from the same options, which is what makes authentication and the timeout apply to it identically rather than by two call sites agreeing. The trap ordering section 16 prescribes already holds: the pool discards and rebuilds a poisoned instance in its finally, so the rebuild has happened by the time the GENERAL response is returned, and there is no local retry in between. Test drives a real engine failure rather than a simulated one: a context nested deeper than the engine reads answers PARSE_ERROR. Without the fallback it escapes as "input JSON exceeds the maximum nesting depth (128)". The guard against over-reaching reads the keys the mock relay proxy was asked to evaluate, so it observes the wire rather than a stub standing in for it. Still open in section 16: a failing remote call currently replaces the original in-process error rather than restoring it (GOFF-FALLBACK-005), a fallback emits a feature event (-006), carries no gofeatureflag_evaluated_remotely metadata (-007) and is not logged at warning level (-008). Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 56 ++++++++++++++---- .../evaluator/InProcessEvaluatorTest.java | 59 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 6 ++ .../ofrep_evaluate_responses/string_key.json | 7 +++ 4 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index b91bbaf899..795a9487f4 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -43,6 +44,13 @@ */ @Slf4j public class InProcessEvaluator implements IEvaluator { + /** + * Raw engine error codes that make the engine, rather than the flag, the suspect: the relay proxy + * runs the same engine against the same flag and may well answer correctly. FLAG_CONFIG is + * deliberately absent, being a misconfiguration the relay proxy would reproduce identically. + */ + private static final Set FALLBACK_TRIGGERS = Set.of(ErrorCode.PARSE_ERROR.name(), ErrorCode.GENERAL.name()); + /** API to contact GO Feature Flag. */ private final GoFeatureFlagApi api; /** Pool of WASM evaluation engine instances for thread-safe concurrent evaluation. */ @@ -59,6 +67,8 @@ public class InProcessEvaluator implements IEvaluator { private final AtomicInteger consecutiveRefreshFailures = new AtomicInteger(); /** true between the announcement of a stale configuration and the announcement of its end. */ private final AtomicBoolean staleAnnounced = new AtomicBoolean(); + /** Builds the evaluator a failed local evaluation falls back to. */ + private final IEvaluator fallbackEvaluator; private static final class EvaluatorState { final Map flags; @@ -115,6 +125,7 @@ public InProcessEvaluator( this.api = api; this.options = options; this.emitter = emitter; + this.fallbackEvaluator = new RemoteEvaluator(options, emitter); this.state = EvaluatorState.notLoaded(); int poolSize = options.getWasmEvaluatorPoolSize() != null ? options.getWasmEvaluatorPoolSize() @@ -185,48 +196,69 @@ public void initialize(EvaluationContext ctx) throws Exception { @Override public void shutdown() { stopPolling(); + this.fallbackEvaluator.shutdown(); this.evaluationPool.close(); } @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return genericEvaluation(key, defaultValue, ctx, Boolean.class); + return genericEvaluation(key, defaultValue, ctx, Boolean.class, IEvaluator::getBooleanEvaluation); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return genericEvaluation(key, defaultValue, ctx, String.class); + return genericEvaluation(key, defaultValue, ctx, String.class, IEvaluator::getStringEvaluation); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return genericEvaluation(key, defaultValue, ctx, Integer.class); + return genericEvaluation(key, defaultValue, ctx, Integer.class, IEvaluator::getIntegerEvaluation); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return genericEvaluation(key, defaultValue, ctx, Double.class); + return genericEvaluation(key, defaultValue, ctx, Double.class, IEvaluator::getDoubleEvaluation); } @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - return genericEvaluation(key, defaultValue, ctx, Value.class); + return genericEvaluation(key, defaultValue, ctx, Value.class, IEvaluator::getObjectEvaluation); } /** * genericEvaluation evaluates the flag and converts the engine response into the resolution * structure expected by the OpenFeature SDK. * - * @param key - name of the flag - * @param defaultValue - default value provided by the caller - * @param ctx - evaluation context - * @param expectedType - type the resolver called by the SDK is contracted to return - * @param - type of the flag value + *

When the engine reports a failure that points at this provider rather than at the flag, the + * relay proxy is asked instead and its answer is the one the caller receives.

+ * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @param expectedType - type the resolver called by the SDK is contracted to return + * @param remoteResolver - resolver of the fallback evaluator matching the type asked for + * @param - type of the flag value * @return the evaluation result for this flag */ private ProviderEvaluation genericEvaluation( - final String key, final T defaultValue, final EvaluationContext ctx, final Class expectedType) { - return toProviderEvaluation(key, defaultValue, this.evaluate(key, defaultValue, ctx), expectedType); + final String key, + final T defaultValue, + final EvaluationContext ctx, + final Class expectedType, + final RemoteResolver remoteResolver) { + val response = this.evaluate(key, defaultValue, ctx); + // the raw code the engine emitted, read before toProviderEvaluation maps it onto the SDK + // enumeration, where GO Feature Flag's own codes are folded into GENERAL and stop being + // distinguishable from an engine failure. + if (FALLBACK_TRIGGERS.contains(response.getErrorCode())) { + return remoteResolver.resolve(fallbackEvaluator, key, defaultValue, ctx); + } + return toProviderEvaluation(key, defaultValue, response, expectedType); + } + + @FunctionalInterface + private interface RemoteResolver { + ProviderEvaluation resolve(IEvaluator remote, String key, T defaultValue, EvaluationContext ctx); } /** diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index b6346d1650..deee3bb9b2 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -11,6 +11,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.ImmutableContext; @@ -18,6 +19,7 @@ import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; @@ -408,6 +410,63 @@ void pollingShouldSurviveAFailureRaisedWhileAnnouncingTheReturnToReady() { } } + /** + * A context the evaluation engine's own guards refuse to read, so that the engine answers + * PARSE_ERROR rather than a value. It is a real engine failure, not a simulated one. + */ + @SneakyThrows + private static ImmutableContext contextTooDeepForTheEngine() { + val deep = new StringBuilder(); + for (int i = 0; i < 400; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + for (int i = 0; i < 400; i++) { + deep.append("}"); + } + val asMap = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"targetingKey\":\"user-key\",\"deep\":" + deep + "}", Map.class); + return new ImmutableContext(Structure.mapToStructure(asMap).asMap()); + } + + @SneakyThrows + @DisplayName("a failed local evaluation should be answered by the relay proxy") + @Test + void aFailedLocalEvaluationShouldBeAnsweredByTheRelayProxy() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + val evaluated = evaluator.getStringEvaluation("string_key", "caller-default", contextTooDeepForTheEngine()); + evaluator.shutdown(); + + // the value, variant and reason are the relay proxy's, not the ones the local engine failed to + // produce and not the caller's default + assertEquals("answered by the relay proxy", evaluated.getValue()); + assertEquals("remoteVariant", evaluated.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluated.getReason()); + assertNull(evaluated.getErrorCode()); + } + + @SneakyThrows + @DisplayName("an unknown flag should not be sent to the relay proxy") + @Test + void anUnknownFlagShouldNotBeSentToTheRelayProxy() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + // FLAG_NOT_FOUND is this provider's own answer about the caller's key, not an engine failure: + // the relay proxy holds the same configuration and would answer the same + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals( + List.of(), + goffApiMock.getEvaluatedFlagKeys(), + "the relay proxy was asked about a flag it does not have either"); + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 9a2423c92b..6e0892f138 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import lombok.Getter; import lombok.SneakyThrows; import lombok.val; @@ -31,6 +32,10 @@ public class GoffApiMock { @Getter private int configurationCallCount = 0; + + /** Keys of every flag the relay proxy has been asked to evaluate, in order. */ + @Getter + private final List evaluatedFlagKeys = new CopyOnWriteArrayList<>(); /** * lastRequestBody contains the body of the last request. */ @@ -80,6 +85,7 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { String flagName = request.getPath() .substring( request.getPath().indexOf("/ofrep/v1/evaluate/flags/") + "/ofrep/v1/evaluate/flags/".length()); + evaluatedFlagKeys.add(flagName); switch (flagName) { case "timeout": Thread.sleep(500); diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json new file mode 100644 index 0000000000..f41ea9473a --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json @@ -0,0 +1,7 @@ +{ + "key": "string_key", + "value": "answered by the relay proxy", + "reason": "TARGETING_MATCH", + "variant": "remoteVariant", + "metadata": {} +} From 8351a82fd63aa8cc5c7f99648c4b222d0f2b9436 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 24 Sep 2026 09:08:07 +0200 Subject: [PATCH 44/93] test(go-feature-flag): pin the fallback trigger to the raw engine code GOFF-FALLBACK-002 requires the fallback trigger to be read from the raw engine error code, before mapping onto the SDK enumeration. It already is, but nothing stopped it drifting onto the mapped code two lines below, so the property was true by accident rather than by test. mapErrorCode folds every code the SDK enumeration has no member for into GENERAL, so exactly one class of raw code tells the two readings apart: one that maps to GENERAL without being it. FLAG_CONFIG is that code, and a flag with no default rule is how the engine is made to emit it. Read after mapping, such a flag is sent to the relay proxy, which holds the same configuration and would refuse it identically; the test fails there with nothing thrown where the caller should have seen a GeneralError. The second half evaluates a flag whose targeting query cannot be parsed, which makes the engine trap and answer a raw GENERAL. That code maps onto itself, so this half holds under either reading. The pair can only both pass if the trigger reads the raw code, which is the property the requirement is about. GOFF-FALLBACK-003, that FLAG_CONFIG must not trigger a fallback, has the same witness and is closed by the first of the two tests rather than by a second test asserting the same thing under another name. The trapping query is worth recording beyond this step: it is a real WASM trap, which no test had so far been able to provoke on the 0.2.4 binary. It reaches the discard-and-rebuild path of GOFF-WASM-008 and the freeInput guard of GOFF-WASM-012, the latter having shipped untested for want of exactly this. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluatorTest.java | 46 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 19 ++++++++ .../flag-with-a-broken-query.json | 7 +++ 3 files changed, 72 insertions(+) create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index deee3bb9b2..38f3561c3d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -467,6 +467,52 @@ void anUnknownFlagShouldNotBeSentToTheRelayProxy() { "the relay proxy was asked about a flag it does not have either"); } + @SneakyThrows + @DisplayName("a misconfigured flag should not be sent to the relay proxy") + @Test + void aMisconfiguredFlagShouldNotBeSentToTheRelayProxy() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // the engine answers the raw code FLAG_CONFIG, which the SDK enumeration has no member + // for and which mapping folds into GENERAL. Read after mapping, the trigger would send + // this to the relay proxy, which holds the same configuration and would refuse it too. + val error = assertThrows( + GeneralError.class, + () -> evaluator.getBooleanEvaluation( + "flag-without-default-rule", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals(ErrorCode.GENERAL, error.getErrorCode()); + assertEquals(List.of(), mock.getEvaluatedFlagKeys(), "a misconfiguration was sent to the relay proxy"); + } + } + + @SneakyThrows + @DisplayName("a trapped evaluation should be sent to the relay proxy") + @Test + void aTrappedEvaluationShouldBeSentToTheRelayProxy() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // a targeting query the engine cannot parse makes it trap, which answers the raw code + // GENERAL. It maps onto itself, so this half holds whichever code the trigger reads, and + // the two tests together can only pass if it reads the raw one. + val evaluated = + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, evaluated.getValue()); + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 6e0892f138..f2b021414f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -151,6 +151,22 @@ private MockResponse unknownResponseFieldConfig() { .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-response-field\""); } + /** + * two flags the evaluation engine refuses, for the two raw error codes that differ once mapped + * onto the SDK enumeration: a flag with no default rule answers FLAG_CONFIG, and a flag whose + * targeting query cannot be parsed makes the engine trap, which answers GENERAL. + */ + private MockResponse misconfiguredFlagsConfig() { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {" + + " \"flag-without-default-rule\": {\"variations\": {\"on\": true}}," + + " \"flag-with-a-broken-query\": {\"variations\": {\"on\": true}," + + " \"targeting\": [{\"query\": \"((((\", \"variation\": \"on\"}]," + + " \"defaultRule\": {\"variation\": \"on\"}}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); + } + @SneakyThrows public MockResponse handleFlagConfiguration(RecordedRequest request) { var configLocation = "valid-all-types.json"; @@ -167,6 +183,8 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { .setBody("{\"flags\": {}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"empty-flag-config\"") .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case MISCONFIGURED_FLAGS: + return misconfiguredFlagsConfig(); case SERVE_OLD_CONFIGURATION: if (configurationCallCount > 1) { // we serve an old configuration after the 1st call. @@ -365,5 +383,6 @@ public enum MockMode { STALE_AFTER_A_RECOVERY, SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, + MISCONFIGURED_FLAGS, } } diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json new file mode 100644 index 0000000000..9a3fc9ab8c --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json @@ -0,0 +1,7 @@ +{ + "key": "flag-with-a-broken-query", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "on", + "metadata": {} +} From 6d64f637238db207f1b4a41b90d6a26db77af2a4 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 24 Sep 2026 09:14:37 +0200 Subject: [PATCH 45/93] test(go-feature-flag): pin the fallback to every failed evaluation GOFF-FALLBACK-004 requires the fallback to be attempted on every qualifying occurrence. It is, there being no once-only flag and no circuit breaker to remove, but the requirement is one a later reader is tempted to undo: a permanently malformed flag turns every one of its evaluations into a network round trip, which reads as a bug worth rate limiting. Five evaluations of a flag whose targeting query makes the engine trap, each with a different targeting key, all answered by the relay proxy. Guarded by a once-only flag, the second caller gets GeneralError "Trapped on unreachable instruction" where the first got a value: the asymmetry between callers is the defect, not the number of round trips. The five evaluations also drive the discard-and-rebuild of GOFF-WASM-008 five times over, so a rebuild that quietly shrank the pool would surface here. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluatorTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 38f3561c3d..48259e824d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -30,6 +30,7 @@ import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -513,6 +514,32 @@ void aTrappedEvaluationShouldBeSentToTheRelayProxy() { } } + @SneakyThrows + @DisplayName("every failed evaluation should be sent to the relay proxy, not only the first") + @Test + void everyFailedEvaluationShouldBeSentToTheRelayProxy() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val evaluations = new ArrayList(); + for (int i = 0; i < 5; i++) { + evaluations.add(evaluator + .getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-" + i)) + .getValue()); + } + evaluator.shutdown(); + assertEquals( + List.of(true, true, true, true, true), evaluations, "a later caller got a worse answer than one"); + assertEquals( + Collections.nCopies(5, "flag-with-a-broken-query"), + mock.getEvaluatedFlagKeys(), + "the relay proxy stopped being asked"); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test From 57afe93f769878867646a78825897c61e8b40fdd Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 10:23:25 +0200 Subject: [PATCH 46/93] fix(go-feature-flag): keep the engine's error when the relay proxy fails too GOFF-FALLBACK-005 requires a fallback whose remote call also fails to return the original in-process error, the engine having failed being the root cause and the proxy merely having failed to make up for it. The remote answer was returned unconditionally, so the caller was told about the recovery attempt instead. Two shapes, because the OFREP client has two ways of failing. It returns an evaluation carrying an error code, which was handed to the caller in place of the engine's error: a relay proxy answering 404 made the engine's trap vanish and nothing was raised at all. And it raises, on a response it cannot read, which escaped past the engine's error entirely. Both are now logged and answered with the engine's error. The catch is the load-bearing half. An error status carrying no body is enough to reach it, because the OFREP client reads the body before looking at the status, so the recovery attempt fails with a ParseError about a response that was never going to parse. That is a defect of providers/ofrep recorded in .context/ofrep-findings.md and fixable only there; this contains the damage rather than repairing it. Nothing is logged here for the fallback itself. GOFF-FALLBACK-008 asks for that at warning level and owns it. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 43 ++++++++++++++++++- .../evaluator/InProcessEvaluatorTest.java | 41 ++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 21 ++++++--- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 795a9487f4..a5723b1e93 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -251,11 +251,52 @@ private ProviderEvaluation genericEvaluation( // enumeration, where GO Feature Flag's own codes are folded into GENERAL and stop being // distinguishable from an engine failure. if (FALLBACK_TRIGGERS.contains(response.getErrorCode())) { - return remoteResolver.resolve(fallbackEvaluator, key, defaultValue, ctx); + val remote = evaluateRemotely(key, defaultValue, ctx, remoteResolver); + if (remote.isPresent()) { + return remote.get(); + } } return toProviderEvaluation(key, defaultValue, response, expectedType); } + /** + * evaluateRemotely asks the relay proxy about a flag the engine could not evaluate. + * + *

An empty result means the relay proxy could not answer either, in which case the caller is + * owed the engine's error rather than the proxy's: the engine failing is the root cause, and the + * proxy merely failed to make up for it. The remote failure is logged here because it is about to + * disappear from the answer entirely.

+ * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @param remoteResolver - resolver of the fallback evaluator matching the type asked for + * @param - type of the flag value + * @return the relay proxy's answer, or empty if it could not give one + */ + private Optional> evaluateRemotely( + final String key, + final T defaultValue, + final EvaluationContext ctx, + final RemoteResolver remoteResolver) { + try { + val remote = remoteResolver.resolve(this.fallbackEvaluator, key, defaultValue, ctx); + if (remote.getErrorCode() == null) { + return Optional.of(remote); + } + log.error( + "the relay proxy could not evaluate flag {} either: {} {}", + key, + remote.getErrorCode(), + remote.getErrorMessage()); + } catch (Exception e) { + // the OFREP client raises on responses it cannot read at all, and an exception escaping + // here would replace the engine's error with one about the recovery attempt. + log.error("the relay proxy could not be asked about flag {}", key, e); + } + return Optional.empty(); + } + @FunctionalInterface private interface RemoteResolver { ProviderEvaluation resolve(IEvaluator remote, String key, T defaultValue, EvaluationContext ctx); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 48259e824d..4b27589efb 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -540,6 +540,47 @@ void everyFailedEvaluationShouldBeSentToTheRelayProxy() { } } + @SneakyThrows + @DisplayName("a relay proxy that refuses the flag too should leave the engine's error standing") + @Test + void aRelayProxyThatRefusesTheFlagTooShouldLeaveTheEnginesErrorStanding() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val error = assertThrows( + GeneralError.class, + () -> evaluator.getBooleanEvaluation( + "flag-the-proxy-does-not-have", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals("Trapped on unreachable instruction", error.getMessage()); + assertEquals(List.of("flag-the-proxy-does-not-have"), mock.getEvaluatedFlagKeys()); + } + } + + @SneakyThrows + @DisplayName("a relay proxy that cannot be read should leave the engine's error standing") + @Test + void aRelayProxyThatCannotBeReadShouldLeaveTheEnginesErrorStanding() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val error = assertThrows( + GeneralError.class, + () -> evaluator.getBooleanEvaluation( + "flag-the-proxy-answers-badly", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals("Trapped on unreachable instruction", error.getMessage()); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index f2b021414f..c48b0d0e91 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -102,6 +102,10 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { return new MockResponse().setResponseCode(403).setBody(ofrepErrorBody); case "404": return new MockResponse().setResponseCode(404).setBody(ofrepErrorBody); + case "flag-the-proxy-answers-badly": + // an error status with no body at all: the OFREP client reads the body first, so it + // raises instead of returning an evaluation carrying an error code + return new MockResponse().setResponseCode(500); case "500": return new MockResponse() .setResponseCode(500) @@ -151,19 +155,24 @@ private MockResponse unknownResponseFieldConfig() { .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-response-field\""); } + /** a flag whose targeting query cannot be parsed, which makes the engine trap. */ + private static final String BROKEN_QUERY_FLAG = "{\"variations\": {\"on\": true}," + + " \"targeting\": [{\"query\": \"((((\", \"variation\": \"on\"}]," + + " \"defaultRule\": {\"variation\": \"on\"}}"; + /** - * two flags the evaluation engine refuses, for the two raw error codes that differ once mapped - * onto the SDK enumeration: a flag with no default rule answers FLAG_CONFIG, and a flag whose - * targeting query cannot be parsed makes the engine trap, which answers GENERAL. + * flags the evaluation engine refuses. A flag with no default rule answers FLAG_CONFIG; the + * others make it trap, which answers GENERAL. The three trapping flags differ only in what the + * relay proxy then does with them: answer, refuse, or reply unreadably. */ private MockResponse misconfiguredFlagsConfig() { return new MockResponse() .setResponseCode(200) .setBody("{\"flags\": {" + " \"flag-without-default-rule\": {\"variations\": {\"on\": true}}," - + " \"flag-with-a-broken-query\": {\"variations\": {\"on\": true}," - + " \"targeting\": [{\"query\": \"((((\", \"variation\": \"on\"}]," - + " \"defaultRule\": {\"variation\": \"on\"}}}}") + + " \"flag-with-a-broken-query\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-does-not-have\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); } From baead9ed53cb755c5ee8155e51a153b863f4d1ed Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 17:23:35 +0200 Subject: [PATCH 47/93] feat(go-feature-flag): mark a result the relay proxy produced GOFF-FALLBACK-007 requires a fallback result to carry flag metadata gofeatureflag_evaluated_remotely: true. Nothing distinguished one, which is what makes the cost of GOFF-FALLBACK-004 invisible: a permanently malformed flag turns every evaluation into a network round trip and the caller sees an ordinary answer. The marker is added to the relay proxy's own metadata rather than in place of it. Those keys describe the evaluation, which is the proxy's; only the marker is this provider's to add. MetadataUtil.convertFlagMetadata does the rebuilding, the SDK's metadata builder taking typed scalars only, so the type dispatch is the one already written for GOFF-EVAL-009 rather than a second copy. It is applied where a remote answer is returned, not where one is asked for, so a fallback whose remote call failed is not marked: the result handed back there is the engine's error, which was not evaluated remotely by anyone. This is also the signal GOFF-FALLBACK-006 needs. The feature event is emitted by a hook, which sees the evaluation details and nothing else, so suppressing it asks for something in the details to read. That is this marker, mandated publicly here rather than a second private channel carrying the same fact. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 23 ++++++++++- .../providers/gofeatureflag/util/Const.java | 2 + .../evaluator/InProcessEvaluatorTest.java | 39 +++++++++++++++++++ .../flag-with-a-broken-query.json | 5 ++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index a5723b1e93..77d10f3435 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -282,7 +283,7 @@ private Optional> evaluateRemotely( try { val remote = remoteResolver.resolve(this.fallbackEvaluator, key, defaultValue, ctx); if (remote.getErrorCode() == null) { - return Optional.of(remote); + return Optional.of(markEvaluatedRemotely(remote)); } log.error( "the relay proxy could not evaluate flag {} either: {} {}", @@ -297,6 +298,26 @@ private Optional> evaluateRemotely( return Optional.empty(); } + /** + * markEvaluatedRemotely records in the flag metadata that the relay proxy produced this result. + * + *

The relay proxy's own metadata keys are kept: they describe the evaluation, which is the + * proxy's, and only the marker is this provider's to add.

+ * + * @param remote - answer the relay proxy gave + * @param - type of the flag value + * @return the same evaluation, carrying the marker + */ + private static ProviderEvaluation markEvaluatedRemotely(final ProviderEvaluation remote) { + val metadata = new LinkedHashMap(); + if (remote.getFlagMetadata() != null) { + metadata.putAll(remote.getFlagMetadata().asUnmodifiableMap()); + } + metadata.put(Const.METADATA_EVALUATED_REMOTELY, true); + remote.setFlagMetadata(MetadataUtil.convertFlagMetadata(metadata)); + return remote; + } + @FunctionalInterface private interface RemoteResolver { ProviderEvaluation resolve(IEvaluator remote, String key, T defaultValue, EvaluationContext ctx); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index d14c235dba..554cebde40 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -22,6 +22,8 @@ public class Const { // FLAG CONFIGURATION FIELDS // the only field of a flag configuration a provider may read, everything else is the engine's public static final String FIELD_TRACK_EVENTS = "trackEvents"; + // FLAG METADATA KEYS + public static final String METADATA_EVALUATED_REMOTELY = "gofeatureflag_evaluated_remotely"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 4b27589efb..b8c54d6a5b 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -540,6 +540,45 @@ void everyFailedEvaluationShouldBeSentToTheRelayProxy() { } } + @SneakyThrows + @DisplayName("a fallback result should say it was evaluated remotely") + @Test + void aFallbackResultShouldSayItWasEvaluatedRemotely() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // the engine trips on the flag's broken query; the relay proxy answers it successfully + val evaluated = + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, evaluated.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + // the marker is added to the relay proxy's metadata, not put in place of it + assertEquals(true, evaluated.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals( + "a flag only the relay proxy can evaluate", + evaluated.getFlagMetadata().getString("description")); + } + } + + @SneakyThrows + @DisplayName("a result the engine produced should not say it was evaluated remotely") + @Test + void aResultTheEngineProducedShouldNotSayItWasEvaluatedRemotely() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + assertNull(evaluated.getErrorCode()); + assertEquals(List.of(), goffApiMock.getEvaluatedFlagKeys()); + assertNull(evaluated.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + } + @SneakyThrows @DisplayName("a relay proxy that refuses the flag too should leave the engine's error standing") @Test diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json index 9a3fc9ab8c..7098293b3b 100644 --- a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json @@ -3,5 +3,8 @@ "value": true, "reason": "TARGETING_MATCH", "variant": "on", - "metadata": {} + "metadata": { + "gofeatureflag_cacheable": true, + "description": "a flag only the relay proxy can evaluate" + } } From b83a15bf6a06c431917c041c9aa22c0fa760c4a1 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 17:47:39 +0200 Subject: [PATCH 48/93] fix(go-feature-flag): stop recording a flag the relay proxy already recorded GOFF-FALLBACK-006 forbids a fallback result from emitting a feature event, the relay proxy having recorded the evaluation server side as it answered the OFREP call. Nothing distinguished one, so every fallback was counted twice. The hook reads gofeatureflag_evaluated_remotely from the evaluation details. That is the marker GOFF-FALLBACK-007 mandates publicly, so there is one mechanism rather than a private channel carrying the same fact to the hook. Only the after stage is gated. The error stage is reached when the fallback failed as well, and a relay proxy that could not answer recorded nothing to duplicate, so gating it would drop a real event. The control test earns its place: a flag the engine evaluates normally must still be recorded through the same provider and the same hook. Without it the suppression would read as satisfied by a hook that was never wired, which is true of every configuration this test could have been written in. Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/DataCollectorHook.java | 20 +++++++- .../GoFeatureFlagProviderTest.java | 48 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 4 +- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 26f056fc12..351bf36f63 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -5,6 +5,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; @@ -44,7 +45,9 @@ public DataCollectorHook(final DataCollectorHookOptions options) throws InvalidO @Override public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (!this.evaluator.isFlagTrackable(ctx.getFlagKey()) + // the relay proxy evaluated this flag itself and recorded it server side as it did so + if (wasEvaluatedRemotely(details) + || !this.evaluator.isFlagTrackable(ctx.getFlagKey()) || (!Boolean.TRUE.equals(this.options.getCollectUnCachedEvaluation()) && !Reason.CACHED.name().equals(details.getReason()))) { return; @@ -78,6 +81,21 @@ public void error(HookContext ctx, Exception error, Map hints) { eventsPublisher.add(event); } + /** + * wasEvaluatedRemotely reports whether this result came from the relay proxy rather than the + * local engine. + * + *

Only the after stage consults it. The error stage is reached when the fallback failed as + * well, and a relay proxy that could not answer recorded nothing to duplicate.

+ * + * @param details - result the SDK is about to hand to the caller + * @return true if the relay proxy produced this result + */ + private static boolean wasEvaluatedRemotely(final FlagEvaluationDetails details) { + return details.getFlagMetadata() != null + && Boolean.TRUE.equals(details.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + } + /** shutdown should be called when we stop the hook, it will publish the remaining event. */ public void shutdown() { // eventsPublisher is required so no need to check if it is null diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index eab5422efd..cba94d3296 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -847,6 +847,54 @@ void shouldNotSendEvaluationEventIfFlagHasTrackingDisabled() { assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); } + @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("flag-with-a-broken-query", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + assertEquals(0, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should send an evaluation event for a flag the engine evaluated") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagTheEngineEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("healthy-flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + // without this the suppression above would also hold with no hook wired at all + assertEquals(1, mock.getCollectorRequestsHistory().size()); + } + } + @DisplayName("Should not send events for remote evaluation") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index c48b0d0e91..7e681c6c31 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -172,7 +172,9 @@ private MockResponse misconfiguredFlagsConfig() { + " \"flag-without-default-rule\": {\"variations\": {\"on\": true}}," + " \"flag-with-a-broken-query\": " + BROKEN_QUERY_FLAG + "," + " \"flag-the-proxy-does-not-have\": " + BROKEN_QUERY_FLAG + "," - + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "}}") + + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "," + + " \"healthy-flag\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}}}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); } From 511564093b73f6ba578678ed0a891957a71e15bd Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:13:29 +0200 Subject: [PATCH 49/93] feat(go-feature-flag): warn when the relay proxy is asked to stand in GOFF-FALLBACK-008 requires every fallback to be logged at warning level. Nothing was logged at all, which is what makes GOFF-FALLBACK-004 dangerous rather than merely expensive: a flag the engine cannot evaluate turns every one of its evaluations into a network round trip, and the caller sees an ordinary answer either way. Warning is the level the requirement names because the provider is still returning correct values while no longer being an in-process provider. The line carries the raw engine code and its details, so the log says why the engine refused rather than only that it did. It is emitted where the fallback is decided, so it fires on every occurrence, including the ones whose remote call then fails and logs again at error level. The test pins the level rather than the presence of a line: at info the capture comes back empty. Its companion, an evaluation the engine answers producing no warning, is also what stops it passing vacuously, an appender that never attached being indistinguishable from a provider that never logs. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 5 + .../evaluator/InProcessEvaluatorTest.java | 443 +++++++----------- 2 files changed, 171 insertions(+), 277 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 77d10f3435..76588f5d4a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -252,6 +252,11 @@ private ProviderEvaluation genericEvaluation( // enumeration, where GO Feature Flag's own codes are folded into GENERAL and stop being // distinguishable from an engine failure. if (FALLBACK_TRIGGERS.contains(response.getErrorCode())) { + log.warn( + "the engine could not evaluate flag {} ({}: {}), asking the relay proxy instead", + key, + response.getErrorCode(), + response.getErrorDetails()); val remote = evaluateRemotely(key, defaultValue, ctx, remoteResolver); if (remote.isPresent()) { return remote.get(); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index b8c54d6a5b..6209aa00ec 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -2,7 +2,6 @@ import static dev.openfeature.contrib.providers.gofeatureflag.evaluator.InProcessEvaluator.toProviderEvaluation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -24,6 +23,7 @@ import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.InvalidContextError; +import dev.openfeature.sdk.exceptions.OpenFeatureError; import dev.openfeature.sdk.exceptions.ParseError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; @@ -35,17 +35,28 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; +import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.val; import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; class InProcessEvaluatorTest { private static final long POLLING_INTERVAL_MS = 100L; @@ -67,30 +78,6 @@ void afterEach() throws IOException { this.server = null; } - @SneakyThrows - private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(srv.url("").toString()) - .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) - .build(); - return new InProcessEvaluator( - GoFeatureFlagApi.builder().options(options).build(), options, emitter); - } - - private InProcessEvaluator evaluator(MockWebServer srv) { - return evaluator(srv, (event, details) -> {}); - } - - /** Records the events the evaluator emits, in the order it emits them. */ - private static final class RecordedEvents implements BiConsumer { - final List events = new ArrayList<>(); - - @Override - public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { - this.events.add(event); - } - } - @SneakyThrows @DisplayName("a second init should not leave a second poller running") @Test @@ -119,13 +106,8 @@ void aSecondInitShouldNotLeaveASecondPollerRunning() { void pollingShouldSurviveAnErrorRaisedWhileApplyingAConfiguration() { try (val s = new MockWebServer()) { s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.CONFIG_CHANGES_EVERY_POLL).dispatcher); - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); val refreshes = new AtomicInteger(); - val evaluator = new InProcessEvaluator(api, options, (event, details) -> { + val evaluator = evaluator(s, (event, details) -> { if (refreshes.incrementAndGet() == 1) { throw new IllegalStateException("an event consumer that fails on the first change"); } @@ -156,13 +138,8 @@ public MockResponse dispatch(RecordedRequest request) { + " \"defaultRule\": {\"variation\": \"on\"}}}}"); } }); - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); val changeEvents = new AtomicInteger(); - val evaluator = new InProcessEvaluator(api, options, (event, details) -> changeEvents.incrementAndGet()); + val evaluator = evaluator(s, (event, details) -> changeEvents.incrementAndGet()); evaluator.initialize(new ImmutableContext()); Thread.sleep(POLLING_INTERVAL_MS * 4); @@ -232,66 +209,21 @@ void shouldGoStaleAfterThreeConsecutiveFailedRefreshes() { void aSuccessfulRefreshShouldRestartTheFailureRun() { try (val s = new MockWebServer()) { s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_TWICE_THEN_RECOVER).dispatcher); - val stale = new ArrayList(); - val evaluator = evaluator(s, (event, details) -> { - if (event == ProviderEvent.PROVIDER_STALE) { - stale.add(event); - } - }); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); evaluator.initialize(new ImmutableContext()); - // two failures, then a success: the two must not carry over into the next run + // two failures, then a success: the two must not carry over into the next run, and + // recovering from something never announced is not news Thread.sleep(POLLING_INTERVAL_MS * 4 + POLLING_INTERVAL_MS / 2); - assertTrue(stale.isEmpty(), "the failures before the successful refresh were carried over"); + assertEquals(List.of(), recorded.events, "an event was announced before any staleness"); Thread.sleep(POLLING_INTERVAL_MS * 3); evaluator.shutdown(); - assertEquals(1, stale.size(), "a fresh run of three failures should still go stale"); - } - } - - @SneakyThrows - @DisplayName("should not announce a return to ready when it never went stale") - @Test - void shouldNotAnnounceAReturnToReadyWhenItNeverWentStale() { - try (val s = new MockWebServer()) { - s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_TWICE_THEN_RECOVER).dispatcher); - val events = new ArrayList(); - val evaluator = evaluator(s, (event, details) -> { - if (event == ProviderEvent.PROVIDER_READY) { - events.add(event); - } - }); - evaluator.initialize(new ImmutableContext()); - - // two failures then a success: recovering from something never announced is not news - Thread.sleep(POLLING_INTERVAL_MS * 4 + POLLING_INTERVAL_MS / 2); - evaluator.shutdown(); - - assertTrue(events.isEmpty(), "a return to ready was announced without a staleness to end"); - } - } - - @SneakyThrows - @DisplayName("polling should survive a failure raised while announcing a stale configuration") - @Test - void pollingShouldSurviveAFailureRaisedWhileAnnouncingAStaleConfiguration() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.FAIL_REFRESH_AFTER_INIT); - s.setDispatcher(mock.dispatcher); - val evaluator = evaluator(s, (event, details) -> { - throw new IllegalStateException("an emitter that fails, as it does after a shutdown"); - }); - evaluator.initialize(new ImmutableContext()); - - // the stale announcement happens on the third failure; polling has to outlive it - Thread.sleep(POLLING_INTERVAL_MS * 4); - val afterStale = mock.getConfigurationCallCount(); - Thread.sleep(POLLING_INTERVAL_MS * 4); - val later = mock.getConfigurationCallCount(); - evaluator.shutdown(); - - assertTrue(later > afterStale, "polling stopped, it stayed at " + afterStale + " call(s)"); + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE), + recorded.events, + "a fresh run of three failures should go stale, once"); } } @@ -411,25 +343,6 @@ void pollingShouldSurviveAFailureRaisedWhileAnnouncingTheReturnToReady() { } } - /** - * A context the evaluation engine's own guards refuse to read, so that the engine answers - * PARSE_ERROR rather than a value. It is a real engine failure, not a simulated one. - */ - @SneakyThrows - private static ImmutableContext contextTooDeepForTheEngine() { - val deep = new StringBuilder(); - for (int i = 0; i < 400; i++) { - deep.append("{\"a\":"); - } - deep.append("1"); - for (int i = 0; i < 400; i++) { - deep.append("}"); - } - val asMap = Const.DESERIALIZE_OBJECT_MAPPER.readValue( - "{\"targetingKey\":\"user-key\",\"deep\":" + deep + "}", Map.class); - return new ImmutableContext(Structure.mapToStructure(asMap).asMap()); - } - @SneakyThrows @DisplayName("a failed local evaluation should be answered by the relay proxy") @Test @@ -492,28 +405,6 @@ void aMisconfiguredFlagShouldNotBeSentToTheRelayProxy() { } } - @SneakyThrows - @DisplayName("a trapped evaluation should be sent to the relay proxy") - @Test - void aTrappedEvaluationShouldBeSentToTheRelayProxy() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); - s.setDispatcher(mock.dispatcher); - val evaluator = evaluator(s); - evaluator.initialize(new ImmutableContext()); - - // a targeting query the engine cannot parse makes it trap, which answers the raw code - // GENERAL. It maps onto itself, so this half holds whichever code the trigger reads, and - // the two tests together can only pass if it reads the raw one. - val evaluated = - evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); - evaluator.shutdown(); - - assertEquals(true, evaluated.getValue()); - assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); - } - } - @SneakyThrows @DisplayName("every failed evaluation should be sent to the relay proxy, not only the first") @Test @@ -524,6 +415,8 @@ void everyFailedEvaluationShouldBeSentToTheRelayProxy() { val evaluator = evaluator(s); evaluator.initialize(new ImmutableContext()); + // a targeting query the engine cannot parse makes it trap, which answers the raw code + // GENERAL, so the pair with the misconfigured flag can only pass if the trigger reads it raw val evaluations = new ArrayList(); for (int i = 0; i < 5; i++) { evaluations.add(evaluator @@ -540,6 +433,29 @@ void everyFailedEvaluationShouldBeSentToTheRelayProxy() { } } + @SneakyThrows + @DisplayName("every fallback should be logged at warning level") + @Test + void everyFallbackShouldBeLoggedAtWarningLevel() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val warnings = warningsWhile(() -> { + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-1")); + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-2")); + }); + evaluator.shutdown(); + + assertEquals(2, warnings.size(), "a fallback went unlogged: " + warnings); + assertTrue( + warnings.get(0).contains("flag-with-a-broken-query"), + "the warning does not name the flag: " + warnings.get(0)); + } + } + @SneakyThrows @DisplayName("a fallback result should say it was evaluated remotely") @Test @@ -565,45 +481,28 @@ void aFallbackResultShouldSayItWasEvaluatedRemotely() { } @SneakyThrows - @DisplayName("a result the engine produced should not say it was evaluated remotely") + @DisplayName("an evaluation the engine answers should not go through the fallback") @Test - void aResultTheEngineProducedShouldNotSayItWasEvaluatedRemotely() { + void anEvaluationTheEngineAnswersShouldNotGoThroughTheFallback() { val evaluator = evaluator(this.server); evaluator.initialize(new ImmutableContext()); - val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + val evaluated = new ArrayList>(); + val warnings = warningsWhile(() -> evaluated.add( + evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")))); evaluator.shutdown(); - assertNull(evaluated.getErrorCode()); + assertNull(evaluated.get(0).getErrorCode()); assertEquals(List.of(), goffApiMock.getEvaluatedFlagKeys()); - assertNull(evaluated.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); - } - - @SneakyThrows - @DisplayName("a relay proxy that refuses the flag too should leave the engine's error standing") - @Test - void aRelayProxyThatRefusesTheFlagTooShouldLeaveTheEnginesErrorStanding() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); - s.setDispatcher(mock.dispatcher); - val evaluator = evaluator(s); - evaluator.initialize(new ImmutableContext()); - - val error = assertThrows( - GeneralError.class, - () -> evaluator.getBooleanEvaluation( - "flag-the-proxy-does-not-have", false, new ImmutableContext("user-key"))); - evaluator.shutdown(); - - assertEquals("Trapped on unreachable instruction", error.getMessage()); - assertEquals(List.of("flag-the-proxy-does-not-have"), mock.getEvaluatedFlagKeys()); - } + assertNull(evaluated.get(0).getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + assertEquals(List.of(), warnings); } @SneakyThrows - @DisplayName("a relay proxy that cannot be read should leave the engine's error standing") - @Test - void aRelayProxyThatCannotBeReadShouldLeaveTheEnginesErrorStanding() { + @DisplayName("a relay proxy that fails too should leave the engine's error standing") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"flag-the-proxy-does-not-have", "flag-the-proxy-answers-badly"}) + void aRelayProxyThatFailsTooShouldLeaveTheEnginesErrorStanding(String flagKey) { try (val s = new MockWebServer()) { val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); s.setDispatcher(mock.dispatcher); @@ -612,11 +511,11 @@ void aRelayProxyThatCannotBeReadShouldLeaveTheEnginesErrorStanding() { val error = assertThrows( GeneralError.class, - () -> evaluator.getBooleanEvaluation( - "flag-the-proxy-answers-badly", false, new ImmutableContext("user-key"))); + () -> evaluator.getBooleanEvaluation(flagKey, false, new ImmutableContext("user-key"))); evaluator.shutdown(); assertEquals("Trapped on unreachable instruction", error.getMessage()); + assertEquals(List.of(flagKey), mock.getEvaluatedFlagKeys()); } } @@ -651,24 +550,9 @@ void shouldReportProviderNotReadyAfterAFailedInit() { "bool_targeting_match", false, new ImmutableContext("user-key"))); assertEquals(ErrorCode.PROVIDER_NOT_READY, error.getErrorCode()); - // the flag key is not at fault here, the relay proxy is - assertNotEquals(ErrorCode.FLAG_NOT_FOUND, error.getErrorCode()); } } - @SneakyThrows - @DisplayName("should report FLAG_NOT_FOUND for an unknown key once a configuration is loaded") - @Test - void shouldReportFlagNotFoundForAnUnknownKeyOnceLoaded() { - val evaluator = evaluator(this.server); - evaluator.initialize(new ImmutableContext()); - - assertThrows( - FlagNotFoundError.class, - () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); - evaluator.shutdown(); - } - @SneakyThrows @DisplayName("an empty but valid configuration counts as loaded") @Test @@ -735,115 +619,29 @@ void theEngineShouldReportTargetingKeyMissingOnlyForAFlagThatBuckets() { @Nested @DisplayName("conversion of an engine response into a resolution") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) class Conversion { - private GoFeatureFlagResponse responseWithValue(Object value) { + @DisplayName("Should raise the SDK error matching the engine's error code") + @ParameterizedTest(name = "{0}") + @MethodSource("engineErrorCodes") + void shouldRaiseTheSdkErrorMatchingTheEnginesErrorCode( + String engineCode, Class expectedError, ErrorCode expectedCode) { val response = new GoFeatureFlagResponse(); - response.setValue(value); - response.setReason(Reason.TARGETING_MATCH.name()); - response.setVariationType("enabled"); - return response; - } - - private Double doubleEvaluationOf(Object engineValue) { - return toProviderEvaluation("test-flag", 0.0, responseWithValue(engineValue), Double.class) - .getValue(); - } - - @DisplayName("Should throw FlagNotFoundError when flag is not found") - @Test - void shouldThrowFlagNotFoundErrorWhenFlagIsNotFound() { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); - response.setErrorDetails("Flag test-flag was not found in your configuration"); - response.setValue(false); - - val exception = assertThrows( - FlagNotFoundError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - - assertEquals("Flag test-flag was not found in your configuration", exception.getMessage()); - } - - @DisplayName("Should raise the matching SDK error for other error codes") - @Test - void shouldRaiseTheMatchingSdkErrorForOtherErrorCodes() { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.GENERAL.name()); - response.setErrorDetails("Some other error occurred"); - response.setValue(false); - - val error = assertThrows( - GeneralError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - - assertEquals(ErrorCode.GENERAL, error.getErrorCode()); - assertEquals("Some other error occurred", error.getMessage()); - } - - @DisplayName("Should raise a TargetingKeyMissingError when the engine reports one") - @Test - void shouldRaiseATargetingKeyMissingErrorWhenTheEngineReportsOne() { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.TARGETING_KEY_MISSING.name()); - response.setErrorDetails("Error: Empty targeting key"); - - val error = assertThrows( - TargetingKeyMissingError.class, - () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - - assertEquals(ErrorCode.TARGETING_KEY_MISSING, error.getErrorCode()); - assertEquals("Error: Empty targeting key", error.getMessage()); - } - - @DisplayName("Should raise an InvalidContextError when the engine reports one") - @Test - void shouldRaiseAnInvalidContextErrorWhenTheEngineReportsOne() { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.INVALID_CONTEXT.name()); - response.setErrorDetails("the evaluation context is invalid"); + response.setErrorCode(engineCode); + response.setErrorDetails("details for " + engineCode); val error = assertThrows( - InvalidContextError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + expectedError, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - assertEquals(ErrorCode.INVALID_CONTEXT, error.getErrorCode()); - } - - @DisplayName("Should raise a ParseError when the engine reports one") - @Test - void shouldRaiseAParseErrorWhenTheEngineReportsOne() { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.PARSE_ERROR.name()); - response.setErrorDetails("the flag could not be parsed"); - - val error = assertThrows( - ParseError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - - assertEquals(ErrorCode.PARSE_ERROR, error.getErrorCode()); - } - - @DisplayName("Should map an error code the SDK does not know to GENERAL") - @Test - void shouldMapAnUnknownErrorCodeToGeneral() { - val response = new GoFeatureFlagResponse(); - // FLAG_CONFIG is specific to GO Feature Flag and has no SDK equivalent - response.setErrorCode("FLAG_CONFIG"); - response.setErrorDetails("the flag configuration is invalid"); - - val error = assertThrows( - GeneralError.class, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); - - assertEquals(ErrorCode.GENERAL, error.getErrorCode()); - assertEquals("the flag configuration is invalid", error.getMessage()); + assertEquals(expectedCode, error.getErrorCode()); + assertEquals("details for " + engineCode, error.getMessage()); } @DisplayName("Should handle successful evaluation") @Test void shouldHandleSuccessfulEvaluation() { - val response = new GoFeatureFlagResponse(); - response.setValue(true); - response.setReason(Reason.TARGETING_MATCH.name()); - response.setVariationType("enabled"); - response.setErrorCode(null); - - ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + ProviderEvaluation result = + toProviderEvaluation("test-flag", false, responseWithValue(true), Boolean.class); assertEquals(true, result.getValue()); assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); @@ -933,5 +731,96 @@ void shouldNotReturnAZeroValueWhenTheValueIsNull() { toProviderEvaluation("test-flag", new Value("caller-default"), responseWithValue(null), Value.class) .getValue()); } + + private GoFeatureFlagResponse responseWithValue(Object value) { + val response = new GoFeatureFlagResponse(); + response.setValue(value); + response.setReason(Reason.TARGETING_MATCH.name()); + response.setVariationType("enabled"); + return response; + } + + private Double doubleEvaluationOf(Object engineValue) { + return toProviderEvaluation("test-flag", 0.0, responseWithValue(engineValue), Double.class) + .getValue(); + } + + private Stream engineErrorCodes() { + return Stream.of( + Arguments.of("FLAG_NOT_FOUND", FlagNotFoundError.class, ErrorCode.FLAG_NOT_FOUND), + Arguments.of("GENERAL", GeneralError.class, ErrorCode.GENERAL), + Arguments.of( + "TARGETING_KEY_MISSING", TargetingKeyMissingError.class, ErrorCode.TARGETING_KEY_MISSING), + Arguments.of("INVALID_CONTEXT", InvalidContextError.class, ErrorCode.INVALID_CONTEXT), + Arguments.of("PARSE_ERROR", ParseError.class, ErrorCode.PARSE_ERROR), + // specific to GO Feature Flag, no SDK equivalent + Arguments.of("FLAG_CONFIG", GeneralError.class, ErrorCode.GENERAL)); + } + } + + @SneakyThrows + private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(srv.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, emitter); + } + + private InProcessEvaluator evaluator(MockWebServer srv) { + return evaluator(srv, (event, details) -> {}); + } + + /** + * A context the evaluation engine's own guards refuse to read, so that the engine answers + * PARSE_ERROR rather than a value. It is a real engine failure, not a simulated one. + */ + @SneakyThrows + private static ImmutableContext contextTooDeepForTheEngine() { + val deep = new StringBuilder(); + for (int i = 0; i < 400; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + for (int i = 0; i < 400; i++) { + deep.append("}"); + } + val asMap = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"targetingKey\":\"user-key\",\"deep\":" + deep + "}", Map.class); + return new ImmutableContext(Structure.mapToStructure(asMap).asMap()); + } + + /** Runs an evaluation and returns the warnings InProcessEvaluator logged while it ran. */ + private static List warningsWhile(final Runnable evaluation) { + val logger = (org.apache.logging.log4j.core.Logger) LogManager.getLogger(InProcessEvaluator.class); + val captured = new ArrayList(); + val appender = new AbstractAppender("fallback-capture", null, null, true, Property.EMPTY_ARRAY) { + @Override + public void append(LogEvent event) { + if (Level.WARN.equals(event.getLevel())) { + captured.add(event.getMessage().getFormattedMessage()); + } + } + }; + appender.start(); + logger.addAppender(appender); + try { + evaluation.run(); + } finally { + logger.removeAppender(appender); + appender.stop(); + } + return captured; + } + + /** Records the events the evaluator emits, in the order it emits them. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + } } } From 7c6aaa516ff55d522af61e176c6c88e96c51c10a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:21:07 +0200 Subject: [PATCH 50/93] test(go-feature-flag): pin the fallback to the configured credentials and timeout GOFF-FALLBACK-009 requires authentication and the timeout to apply to a fallback request exactly as they do to a normal remote evaluation. They do, the fallback evaluator being built from the same options object, but that is one constructor argument away from being untrue and nothing said so. Built instead from a fresh options object carrying only the endpoint, the fallback goes out unauthenticated and with the default ten second timeout: the api key assertion reads null, and a relay proxy taking five seconds to answer is tolerated where the configured two hundred milliseconds would not have been. The timeout test asserts both directions. A single impatient evaluation proves only that something failed, which any misconfiguration would satisfy; running the same five second delay against a timeout that tolerates it is what makes the first half about the timeout in particular. Closes section 16. Remote fallback was one of the three sections the audit found unimplemented in full. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluatorTest.java | 62 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 11 ++++ 2 files changed, 73 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 6209aa00ec..33d98ca225 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -433,6 +433,56 @@ void everyFailedEvaluationShouldBeSentToTheRelayProxy() { } } + @SneakyThrows + @DisplayName("a fallback request should carry the configured api key") + @Test + void aFallbackRequestShouldCarryTheConfiguredApiKey() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s, "my-api-key", 10000); + evaluator.initialize(new ImmutableContext()); + + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(1, mock.getEvaluateRequestsHistory().size()); + assertEquals( + "my-api-key", + mock.getEvaluateRequestsHistory().get(0).getHeader(Const.HTTP_HEADER_API_KEY), + "the fallback was sent unauthenticated"); + } + } + + @SneakyThrows + @DisplayName("a fallback request should honour the configured timeout") + @Test + void aFallbackRequestShouldHonourTheConfiguredTimeout() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val impatient = evaluator(s, "my-api-key", 200); + impatient.initialize(new ImmutableContext()); + + // the relay proxy takes 5s to answer, so a 200ms timeout leaves the engine's error standing + val error = assertThrows( + GeneralError.class, + () -> impatient.getBooleanEvaluation( + "flag-the-proxy-answers-slowly", false, new ImmutableContext("user-key"))); + impatient.shutdown(); + assertEquals("Trapped on unreachable instruction", error.getMessage()); + + val patient = evaluator(s, "my-api-key", 10000); + patient.initialize(new ImmutableContext()); + val evaluated = + patient.getBooleanEvaluation("flag-the-proxy-answers-slowly", false, new ImmutableContext("u")); + patient.shutdown(); + + // the same 5s delay against a timeout that tolerates it: the failure above was the timeout + assertEquals(true, evaluated.getValue()); + } + } + @SneakyThrows @DisplayName("every fallback should be logged at warning level") @Test @@ -772,6 +822,18 @@ private InProcessEvaluator evaluator(MockWebServer srv) { return evaluator(srv, (event, details) -> {}); } + @SneakyThrows + private InProcessEvaluator evaluator(MockWebServer srv, String apiKey, long timeoutMs) { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(srv.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .apiKey(apiKey) + .timeout((int) timeoutMs) + .build(); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, (event, details) -> {}); + } + /** * A context the evaluation engine's own guards refuse to read, so that the engine answers * PARSE_ERROR rather than a value. It is a real engine failure, not a simulated one. diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 7e681c6c31..5bc8c55aa2 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -36,6 +36,10 @@ public class GoffApiMock { /** Keys of every flag the relay proxy has been asked to evaluate, in order. */ @Getter private final List evaluatedFlagKeys = new CopyOnWriteArrayList<>(); + + /** Every evaluation request the relay proxy received, in order. */ + @Getter + private final List evaluateRequestsHistory = new CopyOnWriteArrayList<>(); /** * lastRequestBody contains the body of the last request. */ @@ -86,7 +90,13 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { .substring( request.getPath().indexOf("/ofrep/v1/evaluate/flags/") + "/ofrep/v1/evaluate/flags/".length()); evaluatedFlagKeys.add(flagName); + evaluateRequestsHistory.add(request); switch (flagName) { + case "flag-the-proxy-answers-slowly": + return new MockResponse() + .setResponseCode(200) + .setHeadersDelay(5, java.util.concurrent.TimeUnit.SECONDS) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, "flag-with-a-broken-query.json")); case "timeout": Thread.sleep(500); return new MockResponse() @@ -173,6 +183,7 @@ private MockResponse misconfiguredFlagsConfig() { + " \"flag-with-a-broken-query\": " + BROKEN_QUERY_FLAG + "," + " \"flag-the-proxy-does-not-have\": " + BROKEN_QUERY_FLAG + "," + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-answers-slowly\": " + BROKEN_QUERY_FLAG + "," + " \"healthy-flag\": {\"variations\": {\"on\": true}," + " \"defaultRule\": {\"variation\": \"on\"}}}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); From 9cb2ee19ec0289f1c35f9c76b2bf3160f4289f9e Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:31:42 +0200 Subject: [PATCH 51/93] test(go-feature-flag): cover trap handling against a real trap GOFF-WASM-013 requires trap handling to be implemented whichever binary ships, the guards of section 10.1 reducing traps without eliminating them. Until now no test could produce one on 0.2.4: the obvious candidates are over-large inputs, which the guards answer with a structured PARSE_ERROR. A targeting query that is malformed rather than over-large still faults the guest, so the handling of GOFF-WASM-008 and GOFF-WASM-012 is at last exercised by a trap rather than by mocked instances standing in for one. GOFF-WASM-012 had no coverage at all. It was implemented and reviewable, but the test written for it then passed with the fix disabled and was deleted rather than kept. The first version of that test here was vacuous in the same way, and went the same way. It asserted that nothing was logged, on the specification's rationale that calling free on a trapped instance faults inside malloc: with the guard removed it still passed, because on Chicory 1.7.5 with this binary that call does not fault. The requirement holds as written, but its stated consequence is not observable here, so the test now counts calls to the guest's free and asserts there are none. Nothing the instance returns reveals whether free ran, hence reaching the export by reflection. Its companion, an evaluation that did not trap having its buffer freed exactly once, keeps the guard about the trap rather than about freeing in general. Signed-off-by: Thomas Poignant --- .../wasm/WasmEvaluatorPoolTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java index 7bcf2c58d1..10e12fc489 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -4,19 +4,23 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.dylibso.chicory.runtime.ExportFunction; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.Reason; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import lombok.SneakyThrows; import lombok.val; @@ -145,6 +149,98 @@ void aGuardedInputShouldAnswerParseErrorWithoutPoisoningTheInstance() { assertFalse(instance.isPoisoned(), "a guarded input is not a guest fault, the instance is still healthy"); } + /** + * An input the bundled binary traps on. The guards of 10.1 turn over-large inputs into a + * PARSE_ERROR, but a query that is malformed rather than over-large still faults the guest, which + * is why GOFF-WASM-013 asks for trap handling whichever binary ships. + */ + @SneakyThrows + private static WasmInput trappingInput() { + return WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"variations\":{\"on\":true}," + + "\"targeting\":[{\"query\":\"((((\",\"variation\":\"on\"}]," + + "\"defaultRule\":{\"variation\":\"on\"}}")) + .evalContext(Const.DESERIALIZE_OBJECT_MAPPER.readValue("{\"targetingKey\":\"k\"}", java.util.Map.class)) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + } + + @SneakyThrows + @DisplayName("a real trap should poison the instance") + @Test + void aRealTrapShouldPoisonTheInstance() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val got = instance.evaluate(trappingInput()); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals("Trapped on unreachable instruction", got.getErrorDetails()); + assertTrue(instance.isPoisoned(), "a guest fault left the instance usable"); + } + + /** + * Counts the calls an instance makes to the guest's free, by swapping the export for one that + * delegates. The requirement is about the call being made at all, and nothing the instance + * returns reveals whether it was. + */ + @SneakyThrows + private static AtomicInteger countFreeCalls(final EvaluationWasm instance) { + val calls = new AtomicInteger(); + val field = EvaluationWasm.class.getDeclaredField("free"); + field.setAccessible(true); + val real = (ExportFunction) field.get(instance); + field.set(instance, (ExportFunction) args -> { + calls.incrementAndGet(); + return real.apply(args); + }); + return calls; + } + + @SneakyThrows + @DisplayName("a real trap should not have the input buffer freed on it") + @Test + void aRealTrapShouldNotHaveTheInputBufferFreedOnIt() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + + instance.evaluate(trappingInput()); + + assertEquals(0, freeCalls.get(), "free was called on a trapped instance"); + } + + @SneakyThrows + @DisplayName("an evaluation that did not trap should have its input buffer freed") + @Test + void anEvaluationThatDidNotTrapShouldHaveItsInputBufferFreed() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + + instance.evaluate(input().value); + + // the guard is on the trap, not on freeing in general + assertEquals(1, freeCalls.get()); + } + + @SneakyThrows + @DisplayName("a real trap should leave the pool able to serve the next evaluation") + @Test + void aRealTrapShouldLeaveThePoolAbleToServeTheNextEvaluation() { + try (val pool = new WasmEvaluatorPool(1)) { + val trapped = pool.evaluate(trappingInput()); + val next = pool.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), trapped.getErrorCode()); + // the poisoned instance was discarded and rebuilt rather than handed out again + assertEquals(true, next.getValue()); + // the engine reports no error as an empty string, not as null + assertEquals("", next.getErrorCode()); + } + } + @SneakyThrows @DisplayName("closing the pool should release every instance it holds") @Test From 5c97194f5c90a211ef583ef00835b5350e244db3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:43:33 +0200 Subject: [PATCH 52/93] fix(go-feature-flag): build the hooks on initialization instead of adding to them GOFF-HOOK-002 forbids repeated initialization from duplicating hooks. The list was final and never cleared, so a provider initialized twice ran the enrichment hook once per initialization and recorded every evaluation as that many feature events. Of the three re-initialization hazards on this path, the other two being GOFF-LIFE-002 and GOFF-LIFE-003, this is the one that corrupts telemetry rather than leaking a resource: the counts are wrong and nothing looks broken. The order assertion comes along because the list is being pinned anyway, and GOFF-HOOK-001 wants [EnrichEvaluationContext, DataCollector] observable by the time initialization completes. Nothing covered that either. Not fixed here: re-initializing into remote mode, or with data collection disabled, leaves the dataCollectorHook field pointing at the previous hook while the list no longer holds it, and shutdown still flushes through it. That is the unconditional flush GOFF-COLL-014 and GOFF-LIFE-004 ask for, so it belongs with them rather than half-done here. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 1 + .../GoFeatureFlagProviderTest.java | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 37d924f24e..d17f2b562a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -130,6 +130,7 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw // one-shot, so without this a provider that is shut down and initialized again never flushes. this.eventsPublisher.start(); this.evaluator.initialize(evaluationContext, domain); + this.hooks.clear(); this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); // In case of remote evaluation, we don't need to send the data to the collector // because the relay-proxy will collect events directly server side. diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index cba94d3296..eace5abc73 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -12,6 +12,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.ErrorCode; @@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -79,6 +81,54 @@ void afterEach() throws IOException { @Nested @DisplayName("Common tests working with all evaluation types") class Common { + @DisplayName("Should not register the hooks twice when initialized twice") + @SneakyThrows + @Test + void shouldNotRegisterTheHooksTwiceWhenInitializedTwice() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + provider.initialize(new ImmutableContext()); + val afterFirst = provider.getProviderHooks().size(); + provider.initialize(new ImmutableContext()); + val afterSecond = provider.getProviderHooks(); + provider.shutdown(); + + assertEquals(afterFirst, afterSecond.size(), "a second initialization registered the hooks again"); + assertEquals( + List.of( + EnrichEvaluationContextHook.class, + dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook.class), + afterSecond.stream().map(Object::getClass).collect(Collectors.toList())); + } + + @DisplayName("Should record one evaluation once after being initialized twice") + @SneakyThrows + @Test + void shouldRecordOneEvaluationOnceAfterBeingInitializedTwice() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + provider.initialize(new ImmutableContext()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals( + 1, + mock.getCollectorRequestsHistory().size(), + "a duplicated data collector hook records the same evaluation once per copy"); + } + } + @SneakyThrows @Test void getMetadata_validate_name() { From a4b8cf916a74e26e8cf02c02f895f9b65b60c90d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:50:18 +0200 Subject: [PATCH 53/93] fix(go-feature-flag): gate the error stage on the flag's trackability GOFF-HOOK-006 requires both hook stages to honour the flag's trackability. The after stage did and the error stage gated on nothing, so a flag configured with trackEvents:false was recorded whenever its evaluation failed. The flags chosen to go untracked were exactly the ones whose failures were counted, which reads as a flag that only ever fails rather than as a bug. disableDataCollection needed nothing on either stage: the provider does not register the hook when it is set, so both honour it by not existing. The tests drive the error stage with a real type mismatch and assert the error code first, so one that never reached that stage fails rather than passing for the wrong reason. The trackable flag alongside it rules out a gate that silences the error stage for everything. GOFF-COLL-022 asks for the same property through both stages and is closed by these two together with the after stage test that already existed. Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/DataCollectorHook.java | 4 ++ .../GoFeatureFlagProviderTest.java | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 351bf36f63..f4d6e9a0dd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -68,6 +68,10 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { @Override public void error(HookContext ctx, Exception error, Map hints) { + if (!this.evaluator.isFlagTrackable(ctx.getFlagKey())) { + return; + } + IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index eace5abc73..2047c618a3 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -945,6 +945,56 @@ void shouldSendAnEvaluationEventForAFlagTheEngineEvaluated() { } } + @DisplayName("Should not send an evaluation event for an untrackable flag that errors") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAnUntrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key carries trackEvents:false, and asking for it as a boolean reaches the error + // stage rather than the after stage + val details = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals( + ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 0, + goffAPIMock.getCollectorRequestsHistory().size(), + "an untrackable flag was recorded by the error stage"); + } + + @DisplayName("Should send an evaluation event for a trackable flag that errors") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForATrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getBooleanDetails("integer_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals( + ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "the gate silenced the error stage for every flag, not only untrackable ones"); + } + @DisplayName("Should not send events for remote evaluation") @SneakyThrows @Test From 26dd0b29ab707d7b70d17102f05a7951996c2f9f Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 18:55:41 +0200 Subject: [PATCH 54/93] fix(go-feature-flag): treat a flag absent from the configuration as trackable GOFF-COLL-021 requires a flag absent from the configuration to be trackable, so that a flag added between polls still produces data. It was treated as untrackable, which silences exactly the window an operator watches after a rollout: the relay proxy is already serving the new flag while this provider has not refreshed yet, and none of those evaluations were recorded. It pairs with the error stage gate of GOFF-HOOK-006. An unknown flag evaluates to FLAG_NOT_FOUND and so reaches the error stage, which until that gate recorded everything regardless. Together the stage now records the flags it should: untrackable ones suppressed, unpolled ones kept. No existing test depended on the old default, which is worth stating for a change that flips one. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 2 +- .../GoFeatureFlagProviderTest.java | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 76588f5d4a..99f091c80d 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -437,7 +437,7 @@ public boolean isFlagTrackable(final String flagKey) { // else belongs to the evaluation engine and is passed through untouched. JsonNode flag = this.state.flags.get(flagKey); if (flag == null) { - return false; + return true; } JsonNode trackEvents = flag.get(Const.FIELD_TRACK_EVENTS); return trackEvents == null || trackEvents.isNull() || trackEvents.asBoolean(true); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 2047c618a3..8816655cb2 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -995,6 +995,30 @@ void shouldSendAnEvaluationEventForATrackableFlagThatErrors() { "the gate silenced the error stage for every flag, not only untrackable ones"); } + @DisplayName("Should send an evaluation event for a flag absent from the configuration") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagAbsentFromTheConfiguration() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getBooleanDetails( + "a-flag-added-since-the-last-poll", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, details.getErrorCode()); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "a flag this provider has not polled yet went unrecorded"); + } + @DisplayName("Should not send events for remote evaluation") @SneakyThrows @Test From 804f0684f092ed2b3646d92a6bba551f6b4ecf46 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 19:01:28 +0200 Subject: [PATCH 55/93] fix(go-feature-flag): attribute remote mode events to this SDK GOFF-COLL-011 requires the meta envelope to carry provider and openfeature whether or not the user configured any metadata. The data collector's envelope did, the constructor adding both, but the enrichment hook was handed the raw user map instead of that enriched one. That map is what reaches the relay proxy inside the evaluation context, and in remote mode the proxy is what exports the events, so remote mode events carried no attribution at all. With no configured metadata the hook returned early and contributed nothing, which left the unconditional registration GOFF-HOOK-003 asks for with nothing to do. One map now feeds both paths rather than two call sites agreeing on what belongs in it. Both tests of the enrichment hook asserted the defect as intended behaviour, one of them named for it, so they are rewritten rather than deleted. The hook's own empty-metadata early return stays: it is unreachable from the provider now, but it is the right answer for a hook constructed directly with an empty map and no requirement asks for it to go. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 7 ++++--- .../contrib/providers/gofeatureflag/util/Const.java | 4 ++++ .../gofeatureflag/GoFeatureFlagProviderTest.java | 13 +++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index d17f2b562a..4daec26054 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -12,6 +12,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHookOptions; import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -73,8 +74,8 @@ public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws new EventsPublisher<>(publisher, options.getFlushIntervalMs(), options.getMaxPendingEvents()); val exp = new HashMap<>(options.getExporterMetadata()); - exp.put("provider", "java"); - exp.put("openfeature", true); + exp.put(Const.METADATA_PROVIDER, "java"); + exp.put(Const.METADATA_OPENFEATURE, true); this.exporterMetadata = exp; } @@ -131,7 +132,7 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw this.eventsPublisher.start(); this.evaluator.initialize(evaluationContext, domain); this.hooks.clear(); - this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); + this.hooks.add(new EnrichEvaluationContextHook(this.exporterMetadata)); // In case of remote evaluation, we don't need to send the data to the collector // because the relay-proxy will collect events directly server side. if (!this.options.isDisableDataCollection() && this.options.getEvaluationType() != EvaluationType.REMOTE) { diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 554cebde40..ffb22ccdcc 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -24,6 +24,10 @@ public class Const { public static final String FIELD_TRACK_EVENTS = "trackEvents"; // FLAG METADATA KEYS public static final String METADATA_EVALUATED_REMOTELY = "gofeatureflag_evaluated_remotely"; + // EXPORTER METADATA KEYS + // the collector groups by provider, so the value must not change between releases + public static final String METADATA_PROVIDER = "provider"; + public static final String METADATA_OPENFEATURE = "openfeature"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 8816655cb2..4cfb5cd520 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -1082,7 +1082,11 @@ void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { "test-boolean", true, "test-string", - "testing-provider"))); + "testing-provider", + "provider", + "java", + "openfeature", + true))); context.put("professional", true); context.put("labels", List.of("pro", "beta")); @@ -1091,10 +1095,10 @@ void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { assertEquals(want, got); } - @DisplayName("Should not add gofeatureflag key in exporterMetadata if the exporterMetadata is empty") + @DisplayName("Should add the reserved exporter metadata even if the user configured none") @SneakyThrows @Test - void shouldNotAddGoffeatureflagKeyInExporterMetadataIfTheExporterMetadataIsEmpty() { + void shouldAddTheReservedExporterMetadataEvenIfTheUserConfiguredNone() { GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) .evaluationType(EvaluationType.REMOTE) @@ -1115,10 +1119,11 @@ void shouldNotAddGoffeatureflagKeyInExporterMetadataIfTheExporterMetadataIsEmpty context.put("age", 30); context.put("professional", true); context.put("labels", List.of("pro", "beta")); + context.put("gofeatureflag", Map.of("exporterMetadata", Map.of("provider", "java", "openfeature", true))); Map want = new HashMap<>(); want.put("context", context); - assertEquals(want, got); + assertEquals(want, got, "a provider with no configured metadata is unattributable to an SDK"); } } From 29e000df2e414c7678ae109e1f1ad85c5ce4ff1a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 19:38:02 +0200 Subject: [PATCH 56/93] fix(go-feature-flag): decide anonymity by an identity test on the attribute GOFF-COLL-005 requires contextKind to come from an identity test on the anonymous attribute, a truthiness test not being sufficient. The check called asBoolean() on the value, which returns null for anything that is not a boolean and was then unboxed. So it was worse than a truthiness test: an evaluation whose context carried anonymous as a string, a number or a structure threw a NullPointerException inside the data collector hook. That is caller supplied data, not something the provider controls, and the same helper is read by both hook stages and by track(), so one such context broke recording and tracking alike. The seven shapes in the test all threw before. Two of them, the string "true" and the number 1, are what the requirement is about rather than the crash: a truthiness test buckets those under a name nobody asserted. Signed-off-by: Thomas Poignant --- .../util/EvaluationContextUtil.java | 2 +- .../util/EvaluationContextUtilTest.java | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java index fc085bad84..0fc952b2b3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java @@ -25,6 +25,6 @@ public static boolean isAnonymousUser(final EvaluationContext ctx) { return true; } Value value = ctx.getValue(anonymousFieldName); - return value != null && value.asBoolean(); + return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java index a19284ccc0..176d6f01bd 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java @@ -3,8 +3,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import lombok.val; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class EvaluationContextUtilTest { @@ -32,4 +43,25 @@ void testIsAnonymousUser_WhenAnonymousFieldIsMissing_ShouldReturnFalse() { MutableContext ctx = new MutableContext(); assertFalse(EvaluationContextUtil.isAnonymousUser(ctx), "Expected false when anonymous field is missing"); } + + @DisplayName("any non-boolean value under the attribute should not make the user anonymous") + @ParameterizedTest(name = "anonymous = {0}") + @MethodSource("nonBooleanValues") + void nonBooleanValuesShouldNotMakeTheUserAnonymous(String label, Value value) { + val ctx = new ImmutableContext(Map.of("anonymous", value)); + assertFalse( + EvaluationContextUtil.isAnonymousUser(ctx), + "a " + label + " under the attribute was read as an assertion of anonymity"); + } + + private static Stream nonBooleanValues() { + return Stream.of( + Arguments.of("string \"true\"", new Value("true")), + Arguments.of("string", new Value("yes")), + Arguments.of("number", new Value(1)), + Arguments.of("zero", new Value(0)), + Arguments.of("list", new Value(List.of(new Value(true)))), + Arguments.of("structure", new Value(new MutableStructure().add("anonymous", true))), + Arguments.of("null value", new Value())); + } } From 06877d81a34a66f99cb389faa4a659563229e971 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 19:45:18 +0200 Subject: [PATCH 57/93] fix(go-feature-flag): attribute an evaluation with no targeting key GOFF-COLL-006 requires userKey to be the targeting key, or a placeholder when there is none. Both data collector stages sent the targeting key raw, so an evaluation made without one was recorded with a null userKey. The placeholder already existed for tracking events, but only for a null context rather than a context carrying no key, so neither path had the requirement. The check reads an empty key as absent too. ImmutableContext with no targeting key returns the empty string rather than null, so a null check alone passes the test and fails in production. The helper joins isAnonymousUser: both answer a question about the same context for the same event, and the alternative is three call sites agreeing on a string literal. track() is not switched over here. It has the same gap, but GOFF-TRACK-005 asks for contextKind, userKey and creationDate to follow these rules together, so it belongs there with the helper now in place for it. Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/DataCollectorHook.java | 4 +-- .../providers/gofeatureflag/util/Const.java | 2 ++ .../util/EvaluationContextUtil.java | 15 +++++++++++ .../GoFeatureFlagProviderTest.java | 25 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index f4d6e9a0dd..a5a0864c4b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -60,7 +60,7 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { .defaultValue(false) .variation(details.getVariant()) .value(details.getValue()) - .userKey(ctx.getCtx().getTargetingKey()) + .userKey(EvaluationContextUtil.userKey(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) .build(); eventsPublisher.add(event); @@ -80,7 +80,7 @@ public void error(HookContext ctx, Exception error, Map hints) { .defaultValue(true) .variation("SdkDefault") .value(ctx.getDefaultValue()) - .userKey(ctx.getCtx().getTargetingKey()) + .userKey(EvaluationContextUtil.userKey(ctx.getCtx())) .build(); eventsPublisher.add(event); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index ffb22ccdcc..75c1953cc9 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -28,6 +28,8 @@ public class Const { // the collector groups by provider, so the value must not change between releases public static final String METADATA_PROVIDER = "provider"; public static final String METADATA_OPENFEATURE = "openfeature"; + // EVENT FIELDS + public static final String UNDEFINED_TARGETING_KEY = "undefined-targetingKey"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java index 0fc952b2b3..cb2b18fe56 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java @@ -27,4 +27,19 @@ public static boolean isAnonymousUser(final EvaluationContext ctx) { Value value = ctx.getValue(anonymousFieldName); return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); } + + /** + * userKey is the key an event is attributed to. + * + * @param ctx - EvaluationContext from open-feature + * @return the targeting key, or a placeholder when there is none + */ + public static String userKey(final EvaluationContext ctx) { + if (ctx == null + || ctx.getTargetingKey() == null + || ctx.getTargetingKey().isEmpty()) { + return Const.UNDEFINED_TARGETING_KEY; + } + return ctx.getTargetingKey(); + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 4cfb5cd520..af1f232c71 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -1019,6 +1019,31 @@ void shouldSendAnEvaluationEventForAFlagAbsentFromTheConfiguration() { "a flag this provider has not polled yet went unrecorded"); } + @DisplayName("Should record an evaluation with no targeting key under a placeholder key") + @SneakyThrows + @Test + void shouldRecordAnEvaluationWithNoTargetingKeyUnderAPlaceholderKey() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getIntegerDetails("integer_key", 1000, new ImmutableContext()); + Thread.sleep(180L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val events = (List>) body.get("events"); + assertEquals(1, events.size()); + assertEquals( + "undefined-targetingKey", + events.get(0).get("userKey"), + "an evaluation with no targeting key was attributed to nobody"); + } + @DisplayName("Should not send events for remote evaluation") @SneakyThrows @Test From 2398b9a26a54cf33f324e0effb930b9a738e302b Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 19:49:29 +0200 Subject: [PATCH 58/93] fix(go-feature-flag): flush buffered events on shutdown in every mode GOFF-COLL-014 requires events to be flushed on shutdown. The flush went through the data collector hook, which exists only for in-process evaluation with data collection enabled, while track() buffers into the publisher in every mode. So remote mode and disabled data collection discarded every tracking event the application had recorded, which are exactly the configurations where tracking is the only thing the publisher carries. Shutting the publisher down directly leaves the hook field with no reader, so it goes and the hook is constructed inline. That also closes the staleness left by GOFF-HOOK-002: re-initializing into remote mode used to leave the field pointing at a hook no longer in the list. Three of the four mode combinations fail against the old shutdown, the fourth being the one that already worked. The disabled data collection row buffers tracking events today, which is what makes it testable. GOFF-TRACK-003 stops it doing that, and the expectation for that row becomes no request at all rather than one. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 12 ++---- .../GoFeatureFlagProviderTest.java | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 4daec26054..afeff38837 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -51,8 +51,6 @@ public final class GoFeatureFlagProvider extends EventProvider implements Tracki private final EventsPublisher eventsPublisher; /** exporter metadata contains the metadata that we want to send to the exporter. */ private final Map exporterMetadata; - /** DataCollectorHook is the hook to send usage of the flags. */ - private DataCollectorHook dataCollectorHook; /** * Constructor of the provider. @@ -136,13 +134,11 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw // In case of remote evaluation, we don't need to send the data to the collector // because the relay-proxy will collect events directly server side. if (!this.options.isDisableDataCollection() && this.options.getEvaluationType() != EvaluationType.REMOTE) { - this.dataCollectorHook = new DataCollectorHook(DataCollectorHookOptions.builder() + this.hooks.add(new DataCollectorHook(DataCollectorHookOptions.builder() .eventsPublisher(this.eventsPublisher) .collectUnCachedEvaluation(true) .evaluator(this.evaluator) - .build()); - - this.hooks.add(this.dataCollectorHook); + .build())); } log.info("finishing initializing provider"); } @@ -151,9 +147,7 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw public void shutdown() { super.shutdown(); this.evaluator.shutdown(); - if (this.dataCollectorHook != null) { - this.dataCollectorHook.shutdown(); - } + this.eventsPublisher.shutdown(); } @Override diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index af1f232c71..732eca70c1 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -38,6 +38,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -49,9 +50,21 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; @Slf4j class GoFeatureFlagProviderTest { + /** The three shapes that register no data collector hook, plus the one that does. */ + static Stream shutdownModes() { + return Stream.of( + Arguments.of(EvaluationType.IN_PROCESS, false), + Arguments.of(EvaluationType.IN_PROCESS, true), + Arguments.of(EvaluationType.REMOTE, false), + Arguments.of(EvaluationType.REMOTE, true)); + } + private MockWebServer server; private GoffApiMock goffAPIMock; private HttpUrl baseUrl; @@ -1476,6 +1489,32 @@ void shouldSendTrackingEventToTheDataCollector() { assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); } + @DisplayName("Should flush buffered events on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderTest#shutdownModes") + @SneakyThrows + void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + // long enough that only shutdown can flush within the test + .flushIntervalMs(60000L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size(), "the event was flushed before shutdown"); + + provider.shutdown(); + + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "shutdown dropped the events the publisher was holding"); + } + @DisplayName("Should omit events if max pending events is reached") @SneakyThrows @Test From 6a34f4bb4c2c6d375afe0b61fa92e9c2d6e45361 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 21:53:10 +0200 Subject: [PATCH 59/93] test(go-feature-flag): pin what shutdown stops, at the provider GOFF-LIFE-004 requires shutdown to stop the polling task, flush buffered events and stop the event publisher, in both modes and regardless of data collection. GOFF-COLL-014 delivered the flush. The other two legs held but were asserted somewhere other than where the requirement is stated: polling against the evaluator directly, and stopping the publisher against one built by hand in its own unit test. Neither says anything about what provider.shutdown() does. The polling test also asserts the provider polled more than once beforehand, so one that never polled cannot pass by doing nothing. The publisher test tracks again after shutdown and waits four flush intervals, which covers both halves of stopping it: no longer accepting events, and no longer running the scheduler that would send them. Against a shutdown that does neither, polling continues past it and the final drain never happens in any of the four mode combinations. Signed-off-by: Thomas Poignant --- .../GoFeatureFlagProviderTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 732eca70c1..65b199c5c8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -94,6 +94,60 @@ void afterEach() throws IOException { @Nested @DisplayName("Common tests working with all evaluation types") class Common { + @DisplayName("Should stop polling the flag configuration on shutdown") + @SneakyThrows + @Test + void shouldStopPollingTheFlagConfigurationOnShutdown() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .flagChangePollingIntervalMs(100L) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + Thread.sleep(300L); + + provider.shutdown(); + // whatever was in flight when shutdown was called may still land, so settle first + Thread.sleep(200L); + val afterShutdown = goffAPIMock.getConfigurationCallCount(); + Thread.sleep(500L); + + assertTrue(afterShutdown > 1, "the provider never polled, so stopping is not what is under test"); + assertEquals( + afterShutdown, + goffAPIMock.getConfigurationCallCount(), + "the configuration was still being polled after shutdown"); + } + + @DisplayName("Should stop the event publisher on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderTest#shutdownModes") + @SneakyThrows + void shouldStopTheEventPublisherOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("before-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + + provider.shutdown(); + val afterShutdown = goffAPIMock.getCollectorRequestsHistory().size(); + + client.track("after-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + Thread.sleep(400L); + + assertEquals(1, afterShutdown, "the final drain did not happen"); + assertEquals( + afterShutdown, + goffAPIMock.getCollectorRequestsHistory().size(), + "the publisher kept accepting and flushing after shutdown"); + } + @DisplayName("Should not register the hooks twice when initialized twice") @SneakyThrows @Test From b41861ed68fb61bf732af419047e2e80653c0e45 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 22:02:51 +0200 Subject: [PATCH 60/93] test(go-feature-flag): split the provider tests by concern GoFeatureFlagProviderTest had grown to six nested classes and sixteen hundred lines, so a failure said little about where to look and every step of the conformance work appended to the same file. One file per concern now: polling, data collection, enrichment, in-process evaluation, remote evaluation, tracking and options, over a shared base holding the mock web server and its fixtures. No test changed. The same 219 run, and the split is a move rather than a rewrite. Signed-off-by: Thomas Poignant --- .../AbstractGoFeatureFlagProviderTest.java | 51 + .../GoFeatureFlagProviderOptionsTest.java | 98 + .../GoFeatureFlagProviderTest.java | 1643 +---------------- .../ProviderConfigurationPollingTest.java | 209 +++ .../ProviderDataCollectorTest.java | 219 +++ .../ProviderEnrichEvaluationContextTest.java | 97 + .../ProviderInProcessEvaluationTest.java | 470 +++++ .../ProviderRemoteEvaluationTest.java | 321 ++++ .../gofeatureflag/ProviderTrackingTest.java | 86 + 9 files changed, 1643 insertions(+), 1551 deletions(-) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java new file mode 100644 index 0000000000..b9c2b1c991 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java @@ -0,0 +1,51 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.io.IOException; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.params.provider.Arguments; + +abstract class AbstractGoFeatureFlagProviderTest { + /** The three shapes that register no data collector hook, plus the one that does. */ + static Stream shutdownModes() { + return Stream.of( + Arguments.of(EvaluationType.IN_PROCESS, false), + Arguments.of(EvaluationType.IN_PROCESS, true), + Arguments.of(EvaluationType.REMOTE, false), + Arguments.of(EvaluationType.REMOTE, true)); + } + + protected MockWebServer server; + protected GoffApiMock goffAPIMock; + protected HttpUrl baseUrl; + protected String testName; + + @BeforeEach + void beforeEach(TestInfo testInfo) throws IOException { + this.server = new MockWebServer(); + goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + this.server.setDispatcher(goffAPIMock.dispatcher); + this.server.start(); + baseUrl = server.url(""); + this.testName = testInfo.getDisplayName(); + } + + @SneakyThrows + @AfterEach + void afterEach() throws IOException { + OpenFeatureAPI.getInstance().shutdown(); + + Thread.sleep(50L); + this.server.close(); + this.server = null; + baseUrl = null; + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java new file mode 100644 index 0000000000..327d34af40 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java @@ -0,0 +1,98 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import java.util.Map; +import lombok.SneakyThrows; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider options validation") +class GoFeatureFlagProviderOptionsTest extends AbstractGoFeatureFlagProviderTest { + @Test + void constructor_options_null() { + assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); + } + + @Test + void constructor_options_empty() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().build())); + } + + @SneakyThrows + @Test + void constructor_options_empty_endpoint() { + assertThrows( + InvalidEndpoint.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().endpoint("").build())); + } + + @SneakyThrows + @Test + void constructor_options_only_timeout() { + assertThrows( + InvalidEndpoint.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().timeout(10000).build())); + } + + @SneakyThrows + @Test + void constructor_options_valid_endpoint() { + assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint("http://localhost:1031") + .build())); + } + + @DisplayName("Should error if the metadata is not a valid type") + @SneakyThrows + @Test + void shouldErrorIfTheMetadataIsNotAValidType() { + assertThrows( + InvalidExporterMetadata.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .exporterMetadata(Map.of( + // object is not a valid metadata + "invalid-metadata", goffAPIMock)) + .evaluationType(EvaluationType.REMOTE) + .build())); + } + + @DisplayName("Should error if invalid flush interval is set") + @SneakyThrows + @Test + void shouldErrorIfInvalidFlushIntervalIsSet() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(-1L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build())); + } + + @DisplayName("Should error if invalid max pending events is set") + @SneakyThrows + @Test + void shouldErrorIfInvalidMaxPendingEventsIsSet() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(-1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build())); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 65b199c5c8..1c3d4629f7 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -1,1596 +1,137 @@ package dev.openfeature.contrib.providers.gofeatureflag; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook; import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; -import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.MutableTrackingEventDetails; import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.ProviderState; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.FatalError; -import dev.openfeature.sdk.exceptions.GeneralError; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; import lombok.val; -import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -@Slf4j -class GoFeatureFlagProviderTest { - /** The three shapes that register no data collector hook, plus the one that does. */ - static Stream shutdownModes() { - return Stream.of( - Arguments.of(EvaluationType.IN_PROCESS, false), - Arguments.of(EvaluationType.IN_PROCESS, true), - Arguments.of(EvaluationType.REMOTE, false), - Arguments.of(EvaluationType.REMOTE, true)); +@DisplayName("Provider lifecycle") +class GoFeatureFlagProviderTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should stop polling the flag configuration on shutdown") + @SneakyThrows + @Test + void shouldStopPollingTheFlagConfigurationOnShutdown() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .flagChangePollingIntervalMs(100L) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + Thread.sleep(300L); + + provider.shutdown(); + // whatever was in flight when shutdown was called may still land, so settle first + Thread.sleep(200L); + val afterShutdown = goffAPIMock.getConfigurationCallCount(); + Thread.sleep(500L); + + assertTrue(afterShutdown > 1, "the provider never polled, so stopping is not what is under test"); + assertEquals( + afterShutdown, + goffAPIMock.getConfigurationCallCount(), + "the configuration was still being polled after shutdown"); } - private MockWebServer server; - private GoffApiMock goffAPIMock; - private HttpUrl baseUrl; - private String testName; - - @BeforeEach - void beforeEach(TestInfo testInfo) throws IOException { - this.server = new MockWebServer(); - goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); - this.server.setDispatcher(goffAPIMock.dispatcher); - this.server.start(); - baseUrl = server.url(""); - this.testName = testInfo.getDisplayName(); + @DisplayName("Should stop the event publisher on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("shutdownModes") + @SneakyThrows + void shouldStopTheEventPublisherOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("before-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + + provider.shutdown(); + val afterShutdown = goffAPIMock.getCollectorRequestsHistory().size(); + + client.track("after-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + Thread.sleep(400L); + + assertEquals(1, afterShutdown, "the final drain did not happen"); + assertEquals( + afterShutdown, + goffAPIMock.getCollectorRequestsHistory().size(), + "the publisher kept accepting and flushing after shutdown"); } + @DisplayName("Should not register the hooks twice when initialized twice") @SneakyThrows - @AfterEach - void afterEach() throws IOException { - OpenFeatureAPI.getInstance().shutdown(); - - Thread.sleep(50L); - this.server.close(); - this.server = null; - baseUrl = null; + @Test + void shouldNotRegisterTheHooksTwiceWhenInitializedTwice() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + provider.initialize(new ImmutableContext()); + val afterFirst = provider.getProviderHooks().size(); + provider.initialize(new ImmutableContext()); + val afterSecond = provider.getProviderHooks(); + provider.shutdown(); + + assertEquals(afterFirst, afterSecond.size(), "a second initialization registered the hooks again"); + assertEquals( + List.of(EnrichEvaluationContextHook.class, DataCollectorHook.class), + afterSecond.stream().map(Object::getClass).collect(Collectors.toList())); } - @Nested - @DisplayName("Common tests working with all evaluation types") - class Common { - @DisplayName("Should stop polling the flag configuration on shutdown") - @SneakyThrows - @Test - void shouldStopPollingTheFlagConfigurationOnShutdown() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .flagChangePollingIntervalMs(100L) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - Thread.sleep(300L); - - provider.shutdown(); - // whatever was in flight when shutdown was called may still land, so settle first - Thread.sleep(200L); - val afterShutdown = goffAPIMock.getConfigurationCallCount(); - Thread.sleep(500L); - - assertTrue(afterShutdown > 1, "the provider never polled, so stopping is not what is under test"); - assertEquals( - afterShutdown, - goffAPIMock.getConfigurationCallCount(), - "the configuration was still being polled after shutdown"); - } - - @DisplayName("Should stop the event publisher on shutdown in every mode") - @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") - @MethodSource("dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderTest#shutdownModes") - @SneakyThrows - void shouldStopTheEventPublisherOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + @DisplayName("Should record one evaluation once after being initialized twice") + @SneakyThrows + @Test + void shouldRecordOneEvaluationOnceAfterBeingInitializedTwice() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(mock.dispatcher); GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() .flushIntervalMs(100L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(type) - .disableDataCollection(disableDataCollection) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track("before-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); - - provider.shutdown(); - val afterShutdown = goffAPIMock.getCollectorRequestsHistory().size(); - - client.track("after-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); - Thread.sleep(400L); - - assertEquals(1, afterShutdown, "the final drain did not happen"); - assertEquals( - afterShutdown, - goffAPIMock.getCollectorRequestsHistory().size(), - "the publisher kept accepting and flushing after shutdown"); - } - - @DisplayName("Should not register the hooks twice when initialized twice") - @SneakyThrows - @Test - void shouldNotRegisterTheHooksTwiceWhenInitializedTwice() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - provider.initialize(new ImmutableContext()); - val afterFirst = provider.getProviderHooks().size(); - provider.initialize(new ImmutableContext()); - val afterSecond = provider.getProviderHooks(); - provider.shutdown(); - - assertEquals(afterFirst, afterSecond.size(), "a second initialization registered the hooks again"); - assertEquals( - List.of( - EnrichEvaluationContextHook.class, - dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook.class), - afterSecond.stream().map(Object::getClass).collect(Collectors.toList())); - } - - @DisplayName("Should record one evaluation once after being initialized twice") - @SneakyThrows - @Test - void shouldRecordOneEvaluationOnceAfterBeingInitializedTwice() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); - s.setDispatcher(mock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - provider.initialize(new ImmutableContext()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals( - 1, - mock.getCollectorRequestsHistory().size(), - "a duplicated data collector hook records the same evaluation once per copy"); - } - } - - @SneakyThrows - @Test - void getMetadata_validate_name() { - assertEquals( - "GO Feature Flag Provider", - new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint("https://gofeatureflag.org") - .timeout(1000) - .build()) - .getMetadata() - .getName()); - } - - @Test - void constructor_options_null() { - assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); - } - - @Test - void constructor_options_empty() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions.builder().build())); - } - - @SneakyThrows - @Test - void constructor_options_empty_endpoint() { - assertThrows( - InvalidEndpoint.class, - () -> new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions.builder().endpoint("").build())); - } - - @SneakyThrows - @Test - void constructor_options_only_timeout() { - assertThrows( - InvalidEndpoint.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .timeout(10000) - .build())); - } - - @SneakyThrows - @Test - void constructor_options_valid_endpoint() { - assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint("http://localhost:1031") - .build())); - } - - @DisplayName("Should error if the metadata is not a valid type") - @SneakyThrows - @Test - void shouldErrorIfTheMetadataIsNotAValidType() { - assertThrows( - InvalidExporterMetadata.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .exporterMetadata(Map.of( - // object is not a valid metadata - "invalid-metadata", goffAPIMock)) - .evaluationType(EvaluationType.REMOTE) - .build())); - } - - @DisplayName("Should error if invalid flush interval is set") - @SneakyThrows - @Test - void shouldErrorIfInvalidFlushIntervalIsSet() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(-1L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build())); - } - - @DisplayName("Should error if invalid max pending events is set") - @SneakyThrows - @Test - void shouldErrorIfInvalidMaxPendingEventsIsSet() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(-1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build())); - } - } - - @Nested - class InProcessEvaluation { - @DisplayName("Should use in process evaluation by default") - @SneakyThrows - @Test - void shouldUseInProcessByDefault() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); - val want = "/v1/flag/configuration"; - assertEquals(want, server.takeRequest().getPath()); - } - - @DisplayName("Should use in process evaluation if option is set") - @SneakyThrows - @Test - void shouldUseInProcessIfOptionIsSet() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); - val want = "/v1/flag/configuration"; - assertEquals(want, server.takeRequest().getPath()); - } - - @DisplayName("Should throw an error if the endpoint is not available") - @SneakyThrows - @Test - void shouldThrowAnErrorIfEndpointNotAvailable() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .timeout(1000) - .build()); - assertThrows( - GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); - } - } - - @DisplayName("Should throw an error if api key is missing") - @SneakyThrows - @Test - void shouldThrowAnErrorIfApiKeyIsMissing() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .timeout(1000) - .build()); - assertThrows( - GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); - } - } - - @DisplayName("Should return FLAG_NOT_FOUND if the flag does not exists") - @SneakyThrows - @Test - void shouldReturnFlagNotFoundIfFlagDoesNotExists() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("DOES_NOT_EXISTS", false, TestUtils.defaultEvaluationContext); - - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("DOES_NOT_EXISTS") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag DOES_NOT_EXISTS was not found in your configuration") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should throw an error if we expect a boolean and got another type") - @SneakyThrows - @Test - void shouldThrowAnErrorIfWeExpectABooleanAndGotAnotherType() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .flagKey("string_key") - .value(false) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage( - "Flag value string_key had unexpected type class java.lang.String, expected class java.lang.Boolean.") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid boolean flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidBooleanFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid string flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidStringFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("string_key", "", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("CC0002") - .variant("color1") - .flagKey("string_key") - .reason(Reason.STATIC.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addString("defaultValue", "CC0000") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid double flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidDoubleFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getDoubleDetails("double_key", 100.10, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(101.25) - .variant("medium") - .flagKey("double_key") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addDouble("defaultValue", 100.25) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid integer flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidIntegerFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(101) - .variant("medium") - .flagKey("integer_key") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addInteger("defaultValue", 1000) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid object flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidObjectFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getObjectDetails( - "object_key", - Value.objectToValue(new MutableStructure().add("default", "true")), - TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(Value.objectToValue(new MutableStructure().add("test", "false"))) - .variant("varB") - .flagKey("object_key") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should use boolean default value if the flag is disabled") - @SneakyThrows - @Test - void shouldUseBooleanDefaultValueIfTheFlagIsDisabled() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .variant("SdkDefault") - .flagKey("disabled_bool") - .reason(Reason.DISABLED.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should emit configuration change event, if config has changed") - @SneakyThrows - @Test - void shouldEmitConfigurationChangeEventIfConfigHasChanged() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - List flagsChanged = new ArrayList<>(); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - flagsChanged.addAll(event.getFlagsChanged()); - }); - client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - - // waiting to get a flag change - int maxWait = 10; - while (!configurationChangedCalled.get() && maxWait > 0) { - maxWait--; - Thread.sleep(10L); - } - assertTrue(configurationChangedCalled.get()); - assertEquals(List.of("bool_targeting_match", "new-flag-changed", "disabled_bool"), flagsChanged); - } - - @DisplayName("Should keep polling the configuration after a 304 not-modified response") - @SneakyThrows - @Test - void shouldKeepPollingAfterANotModifiedResponse() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_THEN_CHANGE); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> configurationChangedCalled.set(true)); - - val before = - client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - - // the 1st poll answers a 304, so the change can only arrive on a later poll - int maxWait = 200; - while (!configurationChangedCalled.get() && maxWait > 0) { - maxWait--; - Thread.sleep(10L); - } - - assertTrue( - configurationChangedCalled.get(), - "the polling daemon must survive a 304 and pick up the next configuration change"); - assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the 304 poll should have happened"); - val after = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - assertNotEquals(before, after); - } - } - - @DisplayName("Should not emit configuration change event, if config has not changed") - @SneakyThrows - @Test - void shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - Thread.sleep(150L); - assertFalse(configurationChangedCalled.get()); - } - - @DisplayName("Should not emit configuration change event, if only the ETag has changed") - @SneakyThrows - @Test - void shouldNotEmitConfigurationChangeEventIfOnlyTheEtagHasChanged() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SAME_CONFIG_CHANGING_ETAG); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicInteger changeEvents = new AtomicInteger(); - client.onProviderConfigurationChanged(event -> changeEvents.incrementAndGet()); - - Thread.sleep(500L); - - // every poll carries a validator the provider has never stored, so the ETag cannot - // tell "changed" from "fetched" and only the content can - assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the provider should have polled"); - assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); - } - } - - @DisplayName("Should change evaluation details if config has changed") - @SneakyThrows - @Test - void shouldChangeEvaluationValueIfConfigHasChanged() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - - val got1 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - // waiting to get a flag change - int maxWait = 10; - while (!configurationChangedCalled.get() && maxWait > 0) { - maxWait--; - Thread.sleep(10L); - } - val got2 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - assertNotEquals(got1, got2); - } - - @DisplayName("Should error if flag configuration endpoint return a 404") - @SneakyThrows - @Test - void shouldErrorIfFlagConfigurationEndpointReturn404() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); - } - - @DisplayName("Should not reject an evaluation context without a targeting key") - @SneakyThrows - @Test - void shouldNotRejectAnEvaluationContextWithoutATargetingKey() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - val got = client.getObjectDetails("object_key", new Value("default"), new ImmutableContext()); - - assertNull(got.getErrorCode()); - assertEquals("varA", got.getVariant()); - } - } - - @DisplayName("Should return TARGETING_KEY_MISSING if the flag needs a targeting key to bucket") - @SneakyThrows - @Test - void shouldReturnTargetingKeyMissingIfTheFlagNeedsATargetingKeyToBucket() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - // string_key buckets on a percentage default rule, so the engine cannot evaluate it blind - val got = client.getStringDetails("string_key", "default", new ImmutableContext()); - - assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); - assertEquals(Reason.ERROR.name(), got.getReason()); - assertEquals("default", got.getValue()); - } - } - - @DisplayName("Should be in FATAL state if the relay proxy rejects the credentials") - @SneakyThrows - @Test - void shouldBeInFatalStateIfTheRelayProxyRejectsTheCredentials() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - - assertThrows( - FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); - - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); - } - } - - @DisplayName("Should report PROVIDER_NOT_READY on evaluation after a failed initialization") - @SneakyThrows - @Test - void shouldReportProviderNotReadyAfterAFailedInitialization() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - assertThrows( - GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); - - // a failed initialization leaves the provider in ERROR, which the SDK does not - // short-circuit, so the resolver is reached and must answer for itself - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - - assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); - assertEquals(Reason.ERROR.name(), got.getReason()); - assertEquals(false, got.getValue()); - } - } - - @DisplayName("Should ignore configuration if etag is different by last-modified is older") - @SneakyThrows - @Test - void shouldIgnoreConfigurationIfEtagIsDifferentByLastModifiedIsOlder() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SERVE_OLD_CONFIGURATION); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) + .maxPendingEvents(1) .endpoint(s.url("").toString()) .evaluationType(EvaluationType.IN_PROCESS) .build()); + provider.initialize(new ImmutableContext()); OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - - client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - Thread.sleep(300L); - assertFalse(configurationChangedCalled.get()); - } - - @DisplayName("Should apply a scheduled rollout step") - @SneakyThrows - @Test - void shouldApplyAScheduledRolloutStep() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("my-flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("my-flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should not apply a scheduled rollout step if the date is in the future") - @SneakyThrows - @Test - void shouldNotApplyAScheduledRolloutStepIfTheDateIsInTheFuture() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails( - "my-flag-scheduled-in-future", true, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .variant("disabled") - .flagKey("my-flag-scheduled-in-future") - .reason(Reason.STATIC.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should evaluate flags correctly under concurrent access") - @SneakyThrows - @Test - void shouldEvaluateFlagsCorrectlyUnderConcurrentAccess() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .flagChangePollingIntervalMs(999999L) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - int threadCount = 20; - int evaluationsPerThread = 100; - AtomicInteger errorCount = new AtomicInteger(0); - CountDownLatch startGate = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(threadCount); - for (int t = 0; t < threadCount; t++) { - new Thread(() -> { - try { - startGate.await(); - for (int i = 0; i < evaluationsPerThread; i++) { - FlagEvaluationDetails result = client.getBooleanDetails( - "bool_targeting_match", false, TestUtils.defaultEvaluationContext); - if (result.getErrorCode() != null) { - errorCount.incrementAndGet(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }) - .start(); - } - - startGate.countDown(); - assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not finish in time"); - assertEquals(0, errorCount.get(), "Concurrent evaluations produced errors"); - } - } - - @Nested - class DataCollectorHook { - @DisplayName("Should omit events if max pending events is reached") - @SneakyThrows - @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); - } - - @DisplayName("Should not send evaluation event if flag has tracking disabled") - @SneakyThrows - @Test - void shouldNotSendEvaluationEventIfFlagHasTrackingDisabled() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); - client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); Thread.sleep(180L); - assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); - } - - @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated") - @SneakyThrows - @Test - void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluated() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); - s.setDispatcher(mock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - client.getBooleanDetails("flag-with-a-broken-query", false, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - - assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); - assertEquals(0, mock.getCollectorRequestsHistory().size()); - } - } - - @DisplayName("Should send an evaluation event for a flag the engine evaluated") - @SneakyThrows - @Test - void shouldSendAnEvaluationEventForAFlagTheEngineEvaluated() { - try (val s = new MockWebServer()) { - val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); - s.setDispatcher(mock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - client.getBooleanDetails("healthy-flag", false, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - - // without this the suppression above would also hold with no hook wired at all - assertEquals(1, mock.getCollectorRequestsHistory().size()); - } - } - - @DisplayName("Should not send an evaluation event for an untrackable flag that errors") - @SneakyThrows - @Test - void shouldNotSendAnEvaluationEventForAnUntrackableFlagThatErrors() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - // string_key carries trackEvents:false, and asking for it as a boolean reaches the error - // stage rather than the after stage - val details = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - - assertEquals( - ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); - assertEquals( - 0, - goffAPIMock.getCollectorRequestsHistory().size(), - "an untrackable flag was recorded by the error stage"); - } - - @DisplayName("Should send an evaluation event for a trackable flag that errors") - @SneakyThrows - @Test - void shouldSendAnEvaluationEventForATrackableFlagThatErrors() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - val details = client.getBooleanDetails("integer_key", false, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - - assertEquals( - ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); assertEquals( 1, - goffAPIMock.getCollectorRequestsHistory().size(), - "the gate silenced the error stage for every flag, not only untrackable ones"); - } - - @DisplayName("Should send an evaluation event for a flag absent from the configuration") - @SneakyThrows - @Test - void shouldSendAnEvaluationEventForAFlagAbsentFromTheConfiguration() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - val details = client.getBooleanDetails( - "a-flag-added-since-the-last-poll", false, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - - assertEquals(ErrorCode.FLAG_NOT_FOUND, details.getErrorCode()); - assertEquals( - 1, - goffAPIMock.getCollectorRequestsHistory().size(), - "a flag this provider has not polled yet went unrecorded"); - } - - @DisplayName("Should record an evaluation with no targeting key under a placeholder key") - @SneakyThrows - @Test - void shouldRecordAnEvaluationWithNoTargetingKeyUnderAPlaceholderKey() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - client.getIntegerDetails("integer_key", 1000, new ImmutableContext()); - Thread.sleep(180L); - - val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); - val events = (List>) body.get("events"); - assertEquals(1, events.size()); - assertEquals( - "undefined-targetingKey", - events.get(0).get("userKey"), - "an evaluation with no targeting key was attributed to nobody"); - } - - @DisplayName("Should not send events for remote evaluation") - @SneakyThrows - @Test - void shouldResolveAValidStringFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); - } - } - - @Nested - class EnrichEvaluationContext { - @DisplayName("Should add to the context the exporter metadata to the evaluation context") - @SneakyThrows - @Test - void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .exporterMetadata(Map.of( - "test-string", - "testing-provider", - "test-int", - 1, - "test-double", - 3.14, - "test-boolean", - true)) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); - - val context = new HashMap(); - context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); - context.put("rate", 3.14); - context.put("company_info", Map.of("size", 120, "name", "my_company")); - context.put("anonymous", false); - context.put("email", "john.doe@gofeatureflag.org"); - context.put("lastname", "doe"); - context.put("firstname", "john"); - context.put("age", 30); - context.put( - "gofeatureflag", - Map.of( - "exporterMetadata", - Map.of( - "test-double", - 3.14, - "test-int", - 1, - "test-boolean", - true, - "test-string", - "testing-provider", - "provider", - "java", - "openfeature", - true))); - context.put("professional", true); - context.put("labels", List.of("pro", "beta")); - - Map want = new HashMap<>(); - want.put("context", context); - assertEquals(want, got); - } - - @DisplayName("Should add the reserved exporter metadata even if the user configured none") - @SneakyThrows - @Test - void shouldAddTheReservedExporterMetadataEvenIfTheUserConfiguredNone() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); - - val context = new HashMap(); - context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); - context.put("rate", 3.14); - context.put("company_info", Map.of("size", 120, "name", "my_company")); - context.put("anonymous", false); - context.put("email", "john.doe@gofeatureflag.org"); - context.put("lastname", "doe"); - context.put("firstname", "john"); - context.put("age", 30); - context.put("professional", true); - context.put("labels", List.of("pro", "beta")); - context.put("gofeatureflag", Map.of("exporterMetadata", Map.of("provider", "java", "openfeature", true))); - - Map want = new HashMap<>(); - want.put("context", context); - assertEquals(want, got, "a provider with no configured metadata is unattributable to an SDK"); + mock.getCollectorRequestsHistory().size(), + "a duplicated data collector hook records the same evaluation once per copy"); } } - @Nested - class RemoteEvaluation { - @DisplayName("Should error if the endpoint is not available") - @SneakyThrows - @Test - void shouldErrorIfEndpointNotAvailable() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("Unknown error while retrieving flag: bool_flag, status code: 500") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should become FATAL on the first evaluation rejected for bad credentials") - @SneakyThrows - @Test - void shouldBecomeFatalOnTheFirstEvaluationRejectedForBadCredentials() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .apiKey("a-rejected-key") - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - // remote evaluation holds no configuration, so initialization has nothing to fetch - // and cannot discover the credentials are wrong - assertEquals(ProviderState.READY, client.getProviderState()); - - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - - // the event is emitted on the SDK's own emitter thread - for (int i = 0; i < 100 && client.getProviderState() != ProviderState.FATAL; i++) { - Thread.sleep(20); - } - assertEquals(ProviderState.FATAL, client.getProviderState()); - } - } - - @DisplayName("Should stay READY when an evaluation fails for a repairable reason") - @SneakyThrows - @Test - void shouldStayReadyWhenAnEvaluationFailsForARepairableReason() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - Thread.sleep(200); - - // a 500 is repairable without touching the credentials - assertEquals(ProviderState.READY, client.getProviderState()); - } - } - - @DisplayName("Should error if no API Key provided") - @SneakyThrows - @Test - void shouldErrorIfApiKeyIsMissing() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error for flag: bool_flag") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should error if API Key is invalid") - @SneakyThrows - @Test - void shouldErrorIfApiKeyIsInvalid() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .apiKey("invalid") - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error for flag: bool_flag") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should error if the flag is not found") - @SneakyThrows - @Test - void shouldErrorIfFlagNotFound() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("does-not-exists", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("does-not-exists") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("flag: does-not-exists not found") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should error if evaluating the wrong type") - @SneakyThrows - @Test - void shouldErrorIfEvaluatingTheWrongType() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("bool_flag", "default", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("default") - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorMessage("Type mismatch: expected String but got Boolean") - .errorCode(ErrorCode.TYPE_MISMATCH) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid boolean flag") - @SneakyThrows - @Test - void shouldResolveAValidBooleanFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("bool_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid string flag") - @SneakyThrows - @Test - void shouldResolveAValidStringFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("string value") - .variant("variantA") - .flagKey("string_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid int flag") - @SneakyThrows - @Test - void shouldResolveAValidIntFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getIntegerDetails("int_flag", 0, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(100) - .variant("variantA") - .flagKey("int_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid double flag") - @SneakyThrows - @Test - void shouldResolveAValidDoubleFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getDoubleDetails("double_flag", 0.0, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(100.11) - .variant("variantA") - .flagKey("double_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid object flag") - @SneakyThrows - @Test - void shouldResolveAValidObjectFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getObjectDetails("object_flag", new Value("default"), TestUtils.defaultEvaluationContext); - - val want = FlagEvaluationDetails.builder() - .value(new Value(new MutableStructure().add("name", "foo").add("age", 100))) - .variant("variantA") - .flagKey("object_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .addBoolean("gofeatureflag_cacheable", true) - .build()) - .build(); - assertEquals(want, got); - } - } - - @Nested - class Tracking { - @DisplayName("Should send the evaluation information to the data collector") - @SneakyThrows - @Test - void shouldSendTrackingEventToTheDataCollector() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); - Thread.sleep(200L); - assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); - } - - @DisplayName("Should flush buffered events on shutdown in every mode") - @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") - @MethodSource("dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderTest#shutdownModes") - @SneakyThrows - void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - // long enough that only shutdown can flush within the test - .flushIntervalMs(60000L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(type) - .disableDataCollection(disableDataCollection) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); - assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size(), "the event was flushed before shutdown"); - - provider.shutdown(); - - assertEquals( - 1, - goffAPIMock.getCollectorRequestsHistory().size(), - "shutdown dropped the events the publisher was holding"); - } - - @DisplayName("Should omit events if max pending events is reached") - @SneakyThrows - @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 567).add("user_id", "123ABC")); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); - } + @SneakyThrows + @Test + void getMetadata_validate_name() { + assertEquals( + "GO Feature Flag Provider", + new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint("https://gofeatureflag.org") + .timeout(1000) + .build()) + .getMetadata() + .getName()); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java new file mode 100644 index 0000000000..cc1b4307b0 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java @@ -0,0 +1,209 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider configuration polling") +class ProviderConfigurationPollingTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should emit configuration change event, if config has changed") + @SneakyThrows + @Test + void shouldEmitConfigurationChangeEventIfConfigHasChanged() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + List flagsChanged = new ArrayList<>(); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + flagsChanged.addAll(event.getFlagsChanged()); + }); + client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + + // waiting to get a flag change + int maxWait = 10; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + assertTrue(configurationChangedCalled.get()); + assertEquals(List.of("bool_targeting_match", "new-flag-changed", "disabled_bool"), flagsChanged); + } + + @DisplayName("Should keep polling the configuration after a 304 not-modified response") + @SneakyThrows + @Test + void shouldKeepPollingAfterANotModifiedResponse() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_THEN_CHANGE); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> configurationChangedCalled.set(true)); + + val before = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + // the 1st poll answers a 304, so the change can only arrive on a later poll + int maxWait = 200; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + + assertTrue( + configurationChangedCalled.get(), + "the polling daemon must survive a 304 and pick up the next configuration change"); + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the 304 poll should have happened"); + val after = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertNotEquals(before, after); + } + } + + @DisplayName("Should not emit configuration change event, if config has not changed") + @SneakyThrows + @Test + void shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + Thread.sleep(150L); + assertFalse(configurationChangedCalled.get()); + } + + @DisplayName("Should not emit configuration change event, if only the ETag has changed") + @SneakyThrows + @Test + void shouldNotEmitConfigurationChangeEventIfOnlyTheEtagHasChanged() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SAME_CONFIG_CHANGING_ETAG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicInteger changeEvents = new AtomicInteger(); + client.onProviderConfigurationChanged(event -> changeEvents.incrementAndGet()); + + Thread.sleep(500L); + + // every poll carries a validator the provider has never stored, so the ETag cannot + // tell "changed" from "fetched" and only the content can + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the provider should have polled"); + assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); + } + } + + @DisplayName("Should change evaluation details if config has changed") + @SneakyThrows + @Test + void shouldChangeEvaluationValueIfConfigHasChanged() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + + val got1 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + // waiting to get a flag change + int maxWait = 10; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + val got2 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertNotEquals(got1, got2); + } + + @DisplayName("Should error if flag configuration endpoint return a 404") + @SneakyThrows + @Test + void shouldErrorIfFlagConfigurationEndpointReturn404() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + } + + @DisplayName("Should ignore configuration if etag is different by last-modified is older") + @SneakyThrows + @Test + void shouldIgnoreConfigurationIfEtagIsDifferentByLastModifiedIsOlder() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SERVE_OLD_CONFIGURATION); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + + client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + Thread.sleep(300L); + assertFalse(configurationChangedCalled.get()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java new file mode 100644 index 0000000000..ed1d9bf67f --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java @@ -0,0 +1,219 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider data collector") +class ProviderDataCollectorTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should omit events if max pending events is reached") + @SneakyThrows + @Test + void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); + } + + @DisplayName("Should not send evaluation event if flag has tracking disabled") + @SneakyThrows + @Test + void shouldNotSendEvaluationEventIfFlagHasTrackingDisabled() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); + client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); + } + + @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("flag-with-a-broken-query", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + assertEquals(0, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should send an evaluation event for a flag the engine evaluated") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagTheEngineEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("healthy-flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + // without this the suppression above would also hold with no hook wired at all + assertEquals(1, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should not send an evaluation event for an untrackable flag that errors") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAnUntrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key carries trackEvents:false, and asking for it as a boolean reaches the error + // stage rather than the after stage + val details = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 0, + goffAPIMock.getCollectorRequestsHistory().size(), + "an untrackable flag was recorded by the error stage"); + } + + @DisplayName("Should send an evaluation event for a trackable flag that errors") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForATrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getBooleanDetails("integer_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "the gate silenced the error stage for every flag, not only untrackable ones"); + } + + @DisplayName("Should send an evaluation event for a flag absent from the configuration") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagAbsentFromTheConfiguration() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = + client.getBooleanDetails("a-flag-added-since-the-last-poll", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, details.getErrorCode()); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "a flag this provider has not polled yet went unrecorded"); + } + + @DisplayName("Should record an evaluation with no targeting key under a placeholder key") + @SneakyThrows + @Test + void shouldRecordAnEvaluationWithNoTargetingKeyUnderAPlaceholderKey() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getIntegerDetails("integer_key", 1000, new ImmutableContext()); + Thread.sleep(180L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val events = (List>) body.get("events"); + assertEquals(1, events.size()); + assertEquals( + "undefined-targetingKey", + events.get(0).get("userKey"), + "an evaluation with no targeting key was attributed to nobody"); + } + + @DisplayName("Should not send events for remote evaluation") + @SneakyThrows + @Test + void shouldResolveAValidStringFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java new file mode 100644 index 0000000000..770f17ced7 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java @@ -0,0 +1,97 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider evaluation context enrichment") +class ProviderEnrichEvaluationContextTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should add to the context the exporter metadata to the evaluation context") + @SneakyThrows + @Test + void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .exporterMetadata(Map.of( + "test-string", "testing-provider", "test-int", 1, "test-double", 3.14, "test-boolean", true)) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + + val context = new HashMap(); + context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); + context.put("rate", 3.14); + context.put("company_info", Map.of("size", 120, "name", "my_company")); + context.put("anonymous", false); + context.put("email", "john.doe@gofeatureflag.org"); + context.put("lastname", "doe"); + context.put("firstname", "john"); + context.put("age", 30); + context.put( + "gofeatureflag", + Map.of( + "exporterMetadata", + Map.of( + "test-double", + 3.14, + "test-int", + 1, + "test-boolean", + true, + "test-string", + "testing-provider", + "provider", + "java", + "openfeature", + true))); + context.put("professional", true); + context.put("labels", List.of("pro", "beta")); + + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got); + } + + @DisplayName("Should add the reserved exporter metadata even if the user configured none") + @SneakyThrows + @Test + void shouldAddTheReservedExporterMetadataEvenIfTheUserConfiguredNone() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + + val context = new HashMap(); + context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); + context.put("rate", 3.14); + context.put("company_info", Map.of("size", 120, "name", "my_company")); + context.put("anonymous", false); + context.put("email", "john.doe@gofeatureflag.org"); + context.put("lastname", "doe"); + context.put("firstname", "john"); + context.put("age", 30); + context.put("professional", true); + context.put("labels", List.of("pro", "beta")); + context.put("gofeatureflag", Map.of("exporterMetadata", Map.of("provider", "java", "openfeature", true))); + + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got, "a provider with no configured metadata is unattributable to an SDK"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java new file mode 100644 index 0000000000..3bdb1a7684 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java @@ -0,0 +1,470 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider in-process evaluation") +class ProviderInProcessEvaluationTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should use in process evaluation by default") + @SneakyThrows + @Test + void shouldUseInProcessByDefault() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); + val want = "/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @DisplayName("Should use in process evaluation if option is set") + @SneakyThrows + @Test + void shouldUseInProcessIfOptionIsSet() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); + val want = "/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @DisplayName("Should throw an error if the endpoint is not available") + @SneakyThrows + @Test + void shouldThrowAnErrorIfEndpointNotAvailable() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .timeout(1000) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); + } + } + + @DisplayName("Should throw an error if api key is missing") + @SneakyThrows + @Test + void shouldThrowAnErrorIfApiKeyIsMissing() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .timeout(1000) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); + } + } + + @DisplayName("Should return FLAG_NOT_FOUND if the flag does not exists") + @SneakyThrows + @Test + void shouldReturnFlagNotFoundIfFlagDoesNotExists() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("DOES_NOT_EXISTS", false, TestUtils.defaultEvaluationContext); + + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("DOES_NOT_EXISTS") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag DOES_NOT_EXISTS was not found in your configuration") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should throw an error if we expect a boolean and got another type") + @SneakyThrows + @Test + void shouldThrowAnErrorIfWeExpectABooleanAndGotAnotherType() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .flagKey("string_key") + .value(false) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage( + "Flag value string_key had unexpected type class java.lang.String, expected class java.lang.Boolean.") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid boolean flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidBooleanFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("bool_targeting_match") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid string flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidStringFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("string_key", "", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("CC0002") + .variant("color1") + .flagKey("string_key") + .reason(Reason.STATIC.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addString("defaultValue", "CC0000") + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid double flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidDoubleFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getDoubleDetails("double_key", 100.10, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(101.25) + .variant("medium") + .flagKey("double_key") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addDouble("defaultValue", 100.25) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid integer flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidIntegerFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(101) + .variant("medium") + .flagKey("integer_key") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addInteger("defaultValue", 1000) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid object flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidObjectFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getObjectDetails( + "object_key", + Value.objectToValue(new MutableStructure().add("default", "true")), + TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(Value.objectToValue(new MutableStructure().add("test", "false"))) + .variant("varB") + .flagKey("object_key") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should use boolean default value if the flag is disabled") + @SneakyThrows + @Test + void shouldUseBooleanDefaultValueIfTheFlagIsDisabled() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .variant("SdkDefault") + .flagKey("disabled_bool") + .reason(Reason.DISABLED.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should not reject an evaluation context without a targeting key") + @SneakyThrows + @Test + void shouldNotRejectAnEvaluationContextWithoutATargetingKey() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val got = client.getObjectDetails("object_key", new Value("default"), new ImmutableContext()); + + assertNull(got.getErrorCode()); + assertEquals("varA", got.getVariant()); + } + } + + @DisplayName("Should return TARGETING_KEY_MISSING if the flag needs a targeting key to bucket") + @SneakyThrows + @Test + void shouldReturnTargetingKeyMissingIfTheFlagNeedsATargetingKeyToBucket() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key buckets on a percentage default rule, so the engine cannot evaluate it blind + val got = client.getStringDetails("string_key", "default", new ImmutableContext()); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals("default", got.getValue()); + } + } + + @DisplayName("Should be in FATAL state if the relay proxy rejects the credentials") + @SneakyThrows + @Test + void shouldBeInFatalStateIfTheRelayProxyRejectsTheCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + + assertThrows(FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); + } + } + + @DisplayName("Should report PROVIDER_NOT_READY on evaluation after a failed initialization") + @SneakyThrows + @Test + void shouldReportProviderNotReadyAfterAFailedInitialization() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + // a failed initialization leaves the provider in ERROR, which the SDK does not + // short-circuit, so the resolver is reached and must answer for itself + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals(false, got.getValue()); + } + } + + @DisplayName("Should apply a scheduled rollout step") + @SneakyThrows + @Test + void shouldApplyAScheduledRolloutStep() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("my-flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("my-flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should not apply a scheduled rollout step if the date is in the future") + @SneakyThrows + @Test + void shouldNotApplyAScheduledRolloutStepIfTheDateIsInTheFuture() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("my-flag-scheduled-in-future", true, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .variant("disabled") + .flagKey("my-flag-scheduled-in-future") + .reason(Reason.STATIC.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should evaluate flags correctly under concurrent access") + @SneakyThrows + @Test + void shouldEvaluateFlagsCorrectlyUnderConcurrentAccess() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .flagChangePollingIntervalMs(999999L) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + int threadCount = 20; + int evaluationsPerThread = 100; + AtomicInteger errorCount = new AtomicInteger(0); + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int t = 0; t < threadCount; t++) { + new Thread(() -> { + try { + startGate.await(); + for (int i = 0; i < evaluationsPerThread; i++) { + FlagEvaluationDetails result = client.getBooleanDetails( + "bool_targeting_match", false, TestUtils.defaultEvaluationContext); + if (result.getErrorCode() != null) { + errorCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }) + .start(); + } + + startGate.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not finish in time"); + assertEquals(0, errorCount.get(), "Concurrent evaluations produced errors"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java new file mode 100644 index 0000000000..332b04166c --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java @@ -0,0 +1,321 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider remote evaluation") +class ProviderRemoteEvaluationTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should error if the endpoint is not available") + @SneakyThrows + @Test + void shouldErrorIfEndpointNotAvailable() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("Unknown error while retrieving flag: bool_flag, status code: 500") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should become FATAL on the first evaluation rejected for bad credentials") + @SneakyThrows + @Test + void shouldBecomeFatalOnTheFirstEvaluationRejectedForBadCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .apiKey("a-rejected-key") + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // remote evaluation holds no configuration, so initialization has nothing to fetch + // and cannot discover the credentials are wrong + assertEquals(ProviderState.READY, client.getProviderState()); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + + // the event is emitted on the SDK's own emitter thread + for (int i = 0; i < 100 && client.getProviderState() != ProviderState.FATAL; i++) { + Thread.sleep(20); + } + assertEquals(ProviderState.FATAL, client.getProviderState()); + } + } + + @DisplayName("Should stay READY when an evaluation fails for a repairable reason") + @SneakyThrows + @Test + void shouldStayReadyWhenAnEvaluationFailsForARepairableReason() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(200); + + // a 500 is repairable without touching the credentials + assertEquals(ProviderState.READY, client.getProviderState()); + } + } + + @DisplayName("Should error if no API Key provided") + @SneakyThrows + @Test + void shouldErrorIfApiKeyIsMissing() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("authentication/authorization error for flag: bool_flag") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should error if API Key is invalid") + @SneakyThrows + @Test + void shouldErrorIfApiKeyIsInvalid() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .apiKey("invalid") + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("authentication/authorization error for flag: bool_flag") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should error if the flag is not found") + @SneakyThrows + @Test + void shouldErrorIfFlagNotFound() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("does-not-exists", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("does-not-exists") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("flag: does-not-exists not found") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should error if evaluating the wrong type") + @SneakyThrows + @Test + void shouldErrorIfEvaluatingTheWrongType() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("bool_flag", "default", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("default") + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorMessage("Type mismatch: expected String but got Boolean") + .errorCode(ErrorCode.TYPE_MISMATCH) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid boolean flag") + @SneakyThrows + @Test + void shouldResolveAValidBooleanFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("bool_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid string flag") + @SneakyThrows + @Test + void shouldResolveAValidStringFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("string value") + .variant("variantA") + .flagKey("string_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid int flag") + @SneakyThrows + @Test + void shouldResolveAValidIntFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getIntegerDetails("int_flag", 0, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(100) + .variant("variantA") + .flagKey("int_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid double flag") + @SneakyThrows + @Test + void shouldResolveAValidDoubleFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getDoubleDetails("double_flag", 0.0, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(100.11) + .variant("variantA") + .flagKey("double_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid object flag") + @SneakyThrows + @Test + void shouldResolveAValidObjectFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getObjectDetails("object_flag", new Value("default"), TestUtils.defaultEvaluationContext); + + val want = FlagEvaluationDetails.builder() + .value(new Value(new MutableStructure().add("name", "foo").add("age", 100))) + .variant("variantA") + .flagKey("object_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java new file mode 100644 index 0000000000..968db79628 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java @@ -0,0 +1,86 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.MutableTrackingEventDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("Provider tracking") +class ProviderTrackingTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should send the evaluation information to the data collector") + @SneakyThrows + @Test + void shouldSendTrackingEventToTheDataCollector() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); + Thread.sleep(200L); + assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); + } + + @DisplayName("Should flush buffered events on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("shutdownModes") + @SneakyThrows + void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + // long enough that only shutdown can flush within the test + .flushIntervalMs(60000L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size(), "the event was flushed before shutdown"); + + provider.shutdown(); + + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "shutdown dropped the events the publisher was holding"); + } + + @DisplayName("Should omit events if max pending events is reached") + @SneakyThrows + @Test + void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 567).add("user_id", "123ABC")); + Thread.sleep(180L); + assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); + } +} From 619268f80edf04b173ae0b51c4593a9aac008d70 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 22:06:11 +0200 Subject: [PATCH 61/93] fix(go-feature-flag): stop tracking when data collection is disabled GOFF-TRACK-003 requires tracking events to honour disableDataCollection. The flag gated the data collector hook at initialization and nothing on the tracking path, so a deployment that had switched data collection off was still posting to the collector on every track() call. The gate goes on the four argument overload the other three delegate to, so there is one of it rather than four. Two expectations flip with it. The shutdown tests tracked an event and asserted one collector request in all four mode combinations, which was right while track() ignored the flag and is wrong now that the disabled rows have nothing to flush. Those rows still carry something: the publisher must not flush what it should never have accepted. The row GOFF-COLL-014 was about, remote mode with collection enabled, is untouched. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 4 +++ .../GoFeatureFlagProviderTest.java | 2 +- .../gofeatureflag/ProviderTrackingTest.java | 28 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index afeff38837..ebfef7df76 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -167,6 +167,10 @@ public void track(final String eventName, final TrackingEventDetails trackingEve @Override public void track(final String eventName, final EvaluationContext context, final TrackingEventDetails details) { + if (this.options.isDisableDataCollection()) { + return; + } + val trackingEvent = TrackingEvent.builder() .evaluationContext((context != null) ? context.asObjectMap() : Collections.emptyMap()) .userKey(context != null ? context.getTargetingKey() : "undefined-targetingKey") diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 1c3d4629f7..5c2745df96 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -69,7 +69,7 @@ void shouldStopTheEventPublisherOnShutdownInEveryMode(EvaluationType type, boole client.track("after-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); Thread.sleep(400L); - assertEquals(1, afterShutdown, "the final drain did not happen"); + assertEquals(disableDataCollection ? 0 : 1, afterShutdown, "the final drain did not happen"); assertEquals( afterShutdown, goffAPIMock.getCollectorRequestsHistory().size(), diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java index 968db79628..42afda8e1f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; @DisplayName("Provider tracking") @@ -55,11 +56,36 @@ void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean provider.shutdown(); assertEquals( - 1, + disableDataCollection ? 0 : 1, goffAPIMock.getCollectorRequestsHistory().size(), "shutdown dropped the events the publisher was holding"); } + @DisplayName("Should record no tracking event when data collection is disabled") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + @SneakyThrows + void shouldRecordNoTrackingEventWhenDataCollectionIsDisabled(EvaluationType type) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(true) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + Thread.sleep(400L); + provider.shutdown(); + + assertEquals( + 0, + goffAPIMock.getCollectorRequestsHistory().size(), + "a tracking event was sent although data collection is disabled"); + } + @DisplayName("Should omit events if max pending events is reached") @SneakyThrows @Test From de4359ff35e180a17ee4db51004d44a3a0566fcf Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 22:12:12 +0200 Subject: [PATCH 62/93] fix(go-feature-flag): attribute a tracking event by the feature event rules GOFF-TRACK-005 requires a tracking event to carry the evaluation context and to follow the same contextKind, userKey and creationDate rules as a feature event. track() built its userKey from the raw targeting key, with the placeholder only for a null context, so a context carrying no key was recorded against an empty string. GOFF-COLL-006 fixed that for the data collector and left track() for this step. contextKind joins userKey as a helper, so the hook stages and track() read both from one place rather than three call sites agreeing on the bucket names. The creationDate javadoc on TrackingEvent said milliseconds. The code has always sent seconds, which is what the requirement asks for, so the doc was wrong. The test tracks with an empty context and checks each field the requirement names on what reaches the collector. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 4 +-- .../gofeatureflag/bean/TrackingEvent.java | 2 +- .../gofeatureflag/hook/DataCollectorHook.java | 4 +-- .../util/EvaluationContextUtil.java | 13 +++++++ .../gofeatureflag/ProviderTrackingTest.java | 36 +++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index ebfef7df76..65d39fcba8 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -173,8 +173,8 @@ public void track(final String eventName, final EvaluationContext context, final val trackingEvent = TrackingEvent.builder() .evaluationContext((context != null) ? context.asObjectMap() : Collections.emptyMap()) - .userKey(context != null ? context.getTargetingKey() : "undefined-targetingKey") - .contextKind(EvaluationContextUtil.isAnonymousUser(context) ? "anonymousUser" : "user") + .userKey(EvaluationContextUtil.userKey(context)) + .contextKind(EvaluationContextUtil.contextKind(context)) .kind("tracking") .key(eventName) .trackingEventDetails(details != null ? details.asObjectMap() : Collections.emptyMap()) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java index b03fb02bb8..49299681d2 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java @@ -33,7 +33,7 @@ public class TrackingEvent implements IEvent { private String userKey; /** - * CreationDate When the feature flag was requested at Unix epoch time in milliseconds. + * CreationDate When the event happened, at Unix epoch time in seconds. */ private Long creationDate; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index a5a0864c4b..ffd8735fc6 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -56,7 +56,7 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .defaultValue(false) .variation(details.getVariant()) .value(details.getValue()) @@ -75,7 +75,7 @@ public void error(HookContext ctx, Exception error, Map hints) { IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) .defaultValue(true) .variation("SdkDefault") diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java index cb2b18fe56..16218d9ed3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java @@ -14,6 +14,9 @@ public class EvaluationContextUtil { */ private static final String anonymousFieldName = "anonymous"; + private static final String ANONYMOUS_USER_CONTEXT_KIND = "anonymousUser"; + private static final String USER_CONTEXT_KIND = "user"; + /** * isAnonymousUser is checking if the user in the evaluationContext is anonymous. * @@ -28,6 +31,16 @@ public static boolean isAnonymousUser(final EvaluationContext ctx) { return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); } + /** + * contextKind is the bucket an event is counted under. + * + * @param ctx - EvaluationContext from open-feature + * @return the bucket this evaluation belongs to + */ + public static String contextKind(final EvaluationContext ctx) { + return isAnonymousUser(ctx) ? ANONYMOUS_USER_CONTEXT_KIND : USER_CONTEXT_KIND; + } + /** * userKey is the key an event is attributed to. * diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java index 42afda8e1f..18637e06a3 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java @@ -1,10 +1,16 @@ package dev.openfeature.contrib.providers.gofeatureflag; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableTrackingEventDetails; import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.SneakyThrows; import lombok.val; import org.junit.jupiter.api.DisplayName; @@ -61,6 +67,36 @@ void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean "shutdown dropped the events the publisher was holding"); } + @DisplayName("Should build a tracking event by the same rules as a feature event") + @SneakyThrows + @Test + void shouldBuildATrackingEventByTheSameRulesAsAFeatureEvent() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val before = System.currentTimeMillis() / 1000L; + + client.track("my-key", new ImmutableContext(), new MutableTrackingEventDetails().add("revenue", 123)); + Thread.sleep(200L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val event = ((List>) body.get("events")).get(0); + assertEquals("tracking", event.get("kind")); + assertEquals(Map.of("revenue", 123), event.get("trackingEventDetails")); + assertEquals(Map.of(), event.get("evaluationContext")); + assertEquals("undefined-targetingKey", event.get("userKey"), "a tracking event was attributed to nobody"); + assertEquals("user", event.get("contextKind")); + val creationDate = ((Number) event.get("creationDate")).longValue(); + assertTrue( + creationDate >= before && creationDate <= System.currentTimeMillis() / 1000L, + "creationDate is not Unix epoch seconds: " + creationDate); + } + @DisplayName("Should record no tracking event when data collection is disabled") @ParameterizedTest(name = "{0} evaluation") @EnumSource(EvaluationType.class) From d5451049b2e5bd2f9119a35f5a5f1e04293cd32e Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 22:22:11 +0200 Subject: [PATCH 63/93] fix(go-feature-flag)!: remove the connection pool options nothing reads GOFF-CFG-009 requires every option the provider declares and documents to be honoured. maxIdleConnections and keepAliveDuration were the arguments of the OkHttp ConnectionPool and nothing has read them since the provider moved to java.net.http, while the README still documented a 1000 connection pool and a two hour keep-alive. They cannot be honoured instead. java.net.http.HttpClient has no per client pool size or keep-alive setting, only the JVM wide jdk.httpclient.connectionPoolSize and jdk.httpclient.keepalive.timeout properties, read once when the first client of the JVM is built. REMOTE evaluation and the relay proxy fallback also go through the ofrep module's own client, which accepts neither. Deleting them rather than deprecating them also closes GOFF-CFG-010, which asks for vestigial options to be removed rather than left inert: a deprecated option is still declared, still documented and still unread. Callers setting either option no longer compile and should drop the call, which never had any effect. The JDK client can be tuned with -Djdk.httpclient.connectionPoolSize and -Djdk.httpclient.keepalive.timeout (seconds) instead. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 2 - .../GoFeatureFlagProviderOptions.java | 38 ------------------- 2 files changed, 40 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index cc1d6cb0fa..6c1bcff9d9 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -73,8 +73,6 @@ You can configure the provider with several options to customize its behavior. T | **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | | **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.
  • If you want to have a local evaluation, you should use IN_PROCESS.
  • If you want to have an evaluation on the relay-proxy directly, you should use REMOTE.
Default: IN_PROCESS
| | **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | -| **`maxIdleConnections`** | `false` | maxIdleConnections is the maximum number of connections in the connection pool. _(default: `1000`)_ | -| **`keepAliveDuration`** | `false` | keepAliveDuration is the time in millisecond we keep the connection open. _(default: `7200000` (2 hours))_ | | **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | | **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | | **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index cbcc2c8346..19813bf2c0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -25,10 +25,6 @@ public class GoFeatureFlagProviderOptions { /** Default timeout in millisecond when calling the GO Feature Flag relay proxy API. */ private static final int DEFAULT_TIMEOUT_MS = 10000; - /** Default maximum number of connexions in the connexion pool. */ - private static final int DEFAULT_MAX_IDLE_CONNECTIONS = 1000; - /** Default time in millisecond we keep the connexion open (2 hours). */ - private static final long DEFAULT_KEEP_ALIVE_DURATION_MS = 7200000L; /** * evaluationType is the type of evaluation you want to use. @@ -54,22 +50,6 @@ public class GoFeatureFlagProviderOptions { * API. Default: 10000 ms */ private int timeout; - /** - * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. - * Default: 1000 - * - *

This option is currently not used by the provider, the underlying HTTP client does not - * expose this setting.

- */ - private int maxIdleConnections; - /** - * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. Default: - * 7200000 (2 hours) - * - *

This option is currently not used by the provider, the underlying HTTP client does not - * expose this setting.

- */ - private Long keepAliveDuration; /** * (optional) If the relay proxy is configured to authenticate the requests, you should provide an * API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. @@ -151,24 +131,6 @@ public int getTimeout() { return timeout == 0 ? DEFAULT_TIMEOUT_MS : timeout; } - /** - * Get the maximum number of connexions in the connexion pool. - * - * @return the configured maximum, 1000 if none was set - */ - public int getMaxIdleConnections() { - return maxIdleConnections == 0 ? DEFAULT_MAX_IDLE_CONNECTIONS : maxIdleConnections; - } - - /** - * Get the time in millisecond we keep the connexion open. - * - * @return the configured duration, 7200000 ms if none was set - */ - public Long getKeepAliveDuration() { - return keepAliveDuration == null ? DEFAULT_KEEP_ALIVE_DURATION_MS : keepAliveDuration; - } - /** * Get the interval time we publish statistics collection data to the proxy. * From 8ff8d5e03ba11f2c81c8a0a83e0bee7f53be4d6c Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 22:50:16 +0200 Subject: [PATCH 64/93] test(go-feature-flag): run the engine against the canonical ABI vectors Appendix B.3 names three engine ABI vectors, in go-feature-flag's cmd/wasm/testdata, that a provider must test against unmodified: a valid input, one with no targeting key, and a malformed one. valid.json had been vendored and was loaded by nothing, and the other two inputs and all three outputs were absent. All six now sit under wasm_inputs and wasm_outputs, byte for byte as canon has them. The malformed input is truncated JSON, which no WasmInput can carry, so the ABI call moves into a package private evaluateRaw that takes the raw bytes and returns the raw output. evaluate keeps serializing, calling and deserializing around it. evaluateRaw marks the instance poisoned before its finally frees the input, so a trapped instance is still never freed (GOFF-WASM-012). The one behavioural difference is unreachable: a non Chicory exception thrown by free now becomes a GENERAL response instead of propagating. The test compares each raw output with canon as a JSON tree, and rejects bytes after the JSON so a wrong result length cannot pass. It does not tell engine versions apart, since 0.1.3 through 0.2.4 all answer these vectors canonically; the pom pin remains the evidence for GOFF-ENG-001. Signed-off-by: Thomas Poignant --- .../gofeatureflag/wasm/EvaluationWasm.java | 38 ++++++++++--------- .../wasm/EvaluationWasmTest.java | 38 +++++++++++++++++++ .../test/resources/wasm_inputs/invalid.json | 38 +++++++++++++++++++ .../wasm_inputs/missing-targeting-key.json | 38 +++++++++++++++++++ .../test/resources/wasm_outputs/invalid.json | 11 ++++++ .../wasm_outputs/missing-targeting-key.json | 15 ++++++++ .../test/resources/wasm_outputs/valid.json | 15 ++++++++ 7 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java create mode 100644 providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json create mode 100644 providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json create mode 100644 providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json create mode 100644 providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json create mode 100644 providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 5876d9903f..831fa3b510 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -95,35 +95,37 @@ public void preWarmWasm() { * @return the result of the evaluation */ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { - int len = 0; + try { + val output = evaluateRaw(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); + return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); + } catch (Exception e) { + return errorResponse(e); + } + } + + /** + * evaluateRaw hands the engine a raw input and returns its raw output, across the engine ABI. + * + * @param message - the JSON input of the engine + * @return the JSON output of the engine + */ + String evaluateRaw(final byte[] message) { int ptr = 0; try { - // convert the WasmInput object to JSON string - val message = Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput); - // Store the json string in the memory Memory memory = this.instance.memory(); - len = message.length; - ptr = (int) malloc.apply(len)[0]; + ptr = (int) malloc.apply(message.length)[0]; memory.write(ptr, message); - // Call the wasm evaluate function - val resultPointer = this.evaluate.apply(ptr, len); + val resultPointer = this.evaluate.apply(ptr, message.length); - // Read the output int valuePosition = (int) ((resultPointer[0] >>> 32) & 0xFFFFFFFFL); int valueSize = (int) (resultPointer[0] & 0xFFFFFFFFL); - val output = memory.readString(valuePosition, valueSize); - - // Convert the output to a WasmOutput object - return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); - + return memory.readString(valuePosition, valueSize); } catch (ChicoryException | WasmException e) { this.poisoned = true; - return errorResponse(e); - } catch (Exception e) { - return errorResponse(e); + throw e; } finally { - freeInput(ptr, len); + freeInput(ptr, message.length); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java new file mode 100644 index 0000000000..9f7de9fc69 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java @@ -0,0 +1,38 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import java.nio.charset.StandardCharsets; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EvaluationWasmTest { + + @SneakyThrows + @DisplayName("the engine should answer each canonical ABI vector with its canonical output") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"valid", "missing-targeting-key", "invalid"}) + void theEngineShouldAnswerEachCanonicalAbiVectorWithItsCanonicalOutput(String vector) { + try (val instance = new EvaluationWasm()) { + instance.preWarmWasm(); + + val output = instance.evaluateRaw( + TestUtils.readMockResponse("wasm_inputs/", vector + ".json").getBytes(StandardCharsets.UTF_8)); + + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree( + TestUtils.readMockResponse("wasm_outputs/", vector + ".json")), + Const.DESERIALIZE_OBJECT_MAPPER + .reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(output), + "the bundled engine must answer the Appendix B.3 vector exactly as canon does"); + } + } +} diff --git a/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json b/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json new file mode 100644 index 0000000000..8319f3115b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json @@ -0,0 +1,38 @@ +{ + "flagKey": "TEST", + "flag": { + "variations": { + "enable": true, + "disable": false + }, + "targeting": [ + { + "name": "targetingID rule", + "query": "targetingKey eq \"random-key\"", + "percentage": { + "enable": 90, + "disable": 10 + } + } + ], + "defaultRule": { + "variation": "disable" + }, + "metadata": { + "description": "test flag", + "type": "boolean" + } + }, + "evalContext": { + "targetingKey": "random-key", + "name": "foo", + "age": 42, + "fullname": "foo bar", + "email": "foo.bar@gofeatureflag.org" + }, + "flagContext": { + "evaluationContextEnrichment": { + "env": "production" + }, + "defaultSdkValue": false + \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json b/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json new file mode 100644 index 0000000000..5784fc6669 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json @@ -0,0 +1,38 @@ +{ + "flagKey": "TEST", + "flag": { + "variations": { + "enable": true, + "disable": false + }, + "targeting": [ + { + "name": "targetingID rule", + "query": "targetingKey eq \"random-key\"", + "percentage": { + "enable": 90, + "disable": 10 + } + } + ], + "defaultRule": { + "variation": "disable" + }, + "metadata": { + "description": "test flag", + "type": "boolean" + } + }, + "evalContext": { + "name": "foo", + "age": 42, + "fullname": "foo bar", + "email": "foo.bar@gofeatureflag.org" + }, + "flagContext": { + "evaluationContextEnrichment": { + "env": "production" + }, + "defaultSdkValue": false + } +} \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json new file mode 100644 index 0000000000..5f130c7b13 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json @@ -0,0 +1,11 @@ +{ + "trackEvents": false, + "variationType": "", + "failed": false, + "version": "", + "reason": "", + "errorCode": "PARSE_ERROR", + "errorDetails": "unexpected end of JSON input", + "value": null, + "cacheable": false +} diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json new file mode 100644 index 0000000000..4c89f4a40b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "SdkDefault", + "failed": true, + "version": "", + "reason": "ERROR", + "errorCode": "TARGETING_KEY_MISSING", + "errorDetails": "Error: Empty targeting key", + "value": false, + "cacheable": false, + "metadata": { + "description": "test flag", + "type": "boolean" + } +} \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json new file mode 100644 index 0000000000..a2a3d13638 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "enable", + "failed": false, + "version": "", + "reason": "TARGETING_MATCH_SPLIT", + "errorCode": "", + "value": true, + "cacheable": true, + "metadata": { + "description": "test flag", + "evaluatedRuleName": "targetingID rule", + "type": "boolean" + } +} From 462099d9712f0faa9e4e132ec265d4453d40ad12 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 23:18:41 +0200 Subject: [PATCH 65/93] test(go-feature-flag): run the provider against a real relay proxy Appendix B.4 names go-feature-flag's openfeature/provider_tests as the fixture the cross-language integration suites run against. Nothing in this repository ran it: the only Java suite for it lives upstream and pins the published provider, so no change on this branch had ever met a real relay proxy. The flags and both relay proxy configurations are vendored unmodified, and the flags file is copied to the path the configurations name relative to the container's root, so they work as upstream wrote them. The suites start the relay proxy with Testcontainers and run every case in both evaluation modes: every enabled flag of the fixture with the canonical context, down to its full metadata, a context the targeting misses, semver prerelease ordering, unknown flags, type mismatches and disabled flags; the API key accepted and rejected; a flag changed under a running provider; and the relay proxy stopped under one. Comparing the whole metadata map rather than the two keys B.4 names is what tells an in-process result from one the relay proxy produced through the fallback, which carries gofeatureflag_evaluated_remotely. The disabled flag rows are assumed away in remote mode. The relay proxy answers a disabled flag with a null value over OFREP, and the OFREP resolver reports that as FLAG_NOT_FOUND, dropping the DISABLED reason: a GOFF-EVAL-006 violation that belongs in providers/ofrep. Like flagd's, these tests sit under e2e/ and run only with the e2e profile, which CI activates on the Java 21 job. docker-java and Testcontainers log at info, since the module's root logger at trace dumped their wire logs. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/pom.xml | 26 +++ .../AbstractRelayProxyIntegrationTest.java | 39 ++++ .../e2e/AuthenticationIntegrationTest.java | 67 ++++++ .../e2e/FlagChangeIntegrationTest.java | 57 +++++ .../e2e/FlagEvaluationIntegrationTest.java | 198 ++++++++++++++++++ .../e2e/RelayProxyOutageIntegrationTest.java | 59 ++++++ .../e2e/RelayProxyTestHelper.java | 109 ++++++++++ .../src/test/resources/log4j2-test.xml | 2 + .../test/resources/provider_tests/flags.yaml | 190 +++++++++++++++++ .../goff-proxy-authenticated.yaml | 14 ++ .../resources/provider_tests/goff-proxy.yaml | 13 ++ 11 files changed, 774 insertions(+) create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java create mode 100644 providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml create mode 100644 providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml create mode 100644 providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index f84974b86d..0095ac7bc0 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -21,6 +21,8 @@ ${groupId}.gofeatureflag 0.2.4 + + **/e2e/*.java @@ -103,6 +105,20 @@ test + + org.testcontainers + testcontainers + 2.0.4 + test + + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.4 + test + + com.github.spotbugs spotbugs-annotations @@ -143,4 +159,14 @@
+ + + + + e2e + + + + + diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java new file mode 100644 index 0000000000..947b7069f3 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.provider; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.OpenFeatureAPI; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.testcontainers.containers.GenericContainer; + +/** Gives each test a provider domain of its own, and shuts its provider down once it is done. */ +abstract class AbstractRelayProxyIntegrationTest { + protected String domain; + + @BeforeEach + void beforeEach(TestInfo testInfo) { + this.domain = testInfo.getDisplayName(); + } + + @AfterEach + void afterEach() { + OpenFeatureAPI.getInstance().shutdown(); + } + + @SneakyThrows + protected Client client(final GoFeatureFlagProviderOptionsBuilder options) { + OpenFeatureAPI.getInstance().setProviderAndWait(domain, provider(options)); + return OpenFeatureAPI.getInstance().getClient(domain); + } + + protected Client client(final EvaluationType type, final GenericContainer relayProxy, final String apiKey) { + return client(options(type, relayProxy).apiKey(apiKey)); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java new file mode 100644 index 0000000000..24bbf575d2 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java @@ -0,0 +1,67 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.provider; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.exceptions.FatalError; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** Runs the provider against a relay proxy that only serves callers sending the authorized_token API key. */ +@Testcontainers +class AuthenticationIntegrationTest extends AbstractRelayProxyIntegrationTest { + @Container + private static final GenericContainer authenticatedRelayProxy = RelayProxyTestHelper.authenticatedRelayProxy(); + + @DisplayName("a relay proxy that requires an API key should serve a provider that sends it") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aRelayProxyThatRequiresAnApiKeyShouldServeAProviderThatSendsIt(EvaluationType type) { + val client = client(type, authenticatedRelayProxy, "authorized_token"); + + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(true, got.getValue()); + assertEquals("True", got.getVariant(), "the relay proxy accepted the key and evaluated the flag"); + } + + @DisplayName("an in-process provider with a rejected API key should fail its initialization") + @Test + void anInProcessProviderWithARejectedApiKeyShouldFailItsInitialization() { + val provider = provider( + options(EvaluationType.IN_PROCESS, authenticatedRelayProxy).apiKey("wrong_token")); + + assertThrows(FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(domain, provider)); + + val got = OpenFeatureAPI.getInstance() + .getClient(domain) + .getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); + } + + @DisplayName("a remote provider with a rejected API key should fail its first evaluation") + @Test + void aRemoteProviderWithARejectedApiKeyShouldFailItsFirstEvaluation() { + val client = client(EvaluationType.REMOTE, authenticatedRelayProxy, "wrong_token"); + + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java new file mode 100644 index 0000000000..9658371c24 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java @@ -0,0 +1,57 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.MUTABLE_FLAG; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableFlagValue; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableRelayProxy; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.serveMutableFlagVariation; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Changes a flag on a relay proxy of the test's own, and checks when each evaluation mode sees it. */ +class FlagChangeIntegrationTest extends AbstractRelayProxyIntegrationTest { + + @DisplayName("an in-process provider should pick up a flag change by polling the relay proxy") + @Test + void anInProcessProviderShouldPickUpAFlagChangeByPollingTheRelayProxy() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.IN_PROCESS, relayProxy).flagChangePollingIntervalMs(500L)); + val flagsChanged = new AtomicReference>(); + client.onProviderConfigurationChanged(details -> flagsChanged.set(details.getFlagsChanged())); + assertEquals("A", mutableFlagValue(client)); + + serveMutableFlagVariation(relayProxy, "B"); + + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertEquals(List.of(MUTABLE_FLAG), flagsChanged.get())); + assertEquals("B", mutableFlagValue(client)); + } + } + + @DisplayName("a remote provider should see a flag change on its next evaluation without polling") + @Test + void aRemoteProviderShouldSeeAFlagChangeOnItsNextEvaluationWithoutPolling() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(EvaluationType.REMOTE, relayProxy, null); + val configurationChanges = new AtomicInteger(); + client.onProviderConfigurationChanged(details -> configurationChanges.incrementAndGet()); + assertEquals("A", mutableFlagValue(client)); + + serveMutableFlagVariation(relayProxy, "B"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> assertEquals("B", mutableFlagValue(client))); + assertEquals(0, configurationChanges.get(), "a remote provider holds no configuration to announce"); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java new file mode 100644 index 0000000000..bf4710528b --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java @@ -0,0 +1,198 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.TARGETING_KEY; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.evaluate; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the provider against a real relay proxy serving the cross-language fixture of Appendix B.4, + * go-feature-flag's openfeature/provider_tests, with its flags and relay proxy configurations vendored + * unmodified. + */ +@Testcontainers +class FlagEvaluationIntegrationTest extends AbstractRelayProxyIntegrationTest { + @Container + private static final GenericContainer relayProxy = RelayProxyTestHelper.relayProxy(); + + private static final String TARGETING_MATCH = Reason.TARGETING_MATCH.name(); + private static final String DEFAULT = Reason.DEFAULT.name(); + private static final Map PR_916 = Map.of( + "description", "this is a test", + "pr_link", "https://github.com/thomaspoignant/go-feature-flag/pull/916"); + + static Stream enabledFlags() { + val trueObject = new Value(new MutableStructure() + .add("test", "test1") + .add("test2", false) + .add("test3", 123.3) + .add("test4", 1)); + val semverMetadata = Map.of( + "description", "this is a semver matching test", + "pr_link", "https://github.com/thomaspoignant/go-feature-flag/pull/4764"); + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "bool_targeting_match", false, true, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "string_key", "", "CC0000", "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "string_key_with_version", "", "CC0000", "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "integer_key", 0, 100, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "double_key", 0.0, 100.25, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "object_key", new Value(), trueObject, "True", TARGETING_MATCH, PR_916), + Arguments.of( + type, + "flag-use-evaluation-context-enrichment", + "", + "A", + "A", + TARGETING_MATCH, + Map.of()), + Arguments.of( + type, + "boolean_semver_targeting_match", + true, + false, + "False", + DEFAULT, + semverMetadata))); + } + + static Stream disabledFlags() { + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "disabled_bool", false), + Arguments.of(type, "disabled_string", "sdk-default"), + Arguments.of(type, "disabled_int", 0), + Arguments.of(type, "disabled_float", 0.0), + Arguments.of(type, "disabled_interface", new Value()))); + } + + static Stream semverVersions() { + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "10.0.0-10", true, "True", TARGETING_MATCH), + Arguments.of(type, "10.0.0-2", false, "False", DEFAULT))); + } + + @DisplayName("the canonical context should resolve every enabled flag of the fixture") + @ParameterizedTest(name = "{0} evaluation of {1}") + @MethodSource("enabledFlags") + void theCanonicalContextShouldResolveEveryEnabledFlagOfTheFixture( + EvaluationType type, + String flagKey, + Object defaultValue, + Object expectedValue, + String expectedVariant, + String expectedReason, + Map flagMetadata) { + val client = client(type, relayProxy, null); + val expectedMetadata = new HashMap<>(flagMetadata); + if (type == EvaluationType.REMOTE) { + expectedMetadata.put("gofeatureflag_cacheable", true); + } + + val got = evaluate(client, flagKey, defaultValue); + + assertEquals(expectedValue, got.getValue()); + assertEquals(expectedVariant, got.getVariant()); + assertEquals(expectedReason, got.getReason()); + assertEquals( + expectedMetadata, + got.getFlagMetadata().asUnmodifiableMap(), + "the flag metadata unmodified, with nothing added but what the relay proxy adds when it evaluates"); + } + + @DisplayName("a context the targeting query does not match should resolve through the default rule") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aContextTheTargetingQueryDoesNotMatchShouldResolveThroughTheDefaultRule(EvaluationType type) { + val client = client(type, relayProxy, null); + val ctx = new MutableContext(TARGETING_KEY).add("email", "jane.doe@gofeatureflag.org"); + + val got = client.getBooleanDetails("bool_targeting_match", false, ctx); + + assertEquals(true, got.getValue(), "the default rule sends 100% of the traffic to True"); + assertEquals("True", got.getVariant()); + assertEquals(DEFAULT, got.getReason()); + } + + @DisplayName("a semver targeting query should compare prerelease versions numerically") + @ParameterizedTest(name = "{0} evaluation of version {1}") + @MethodSource("semverVersions") + void aSemverTargetingQueryShouldComparePrereleaseVersionsNumerically( + EvaluationType type, String version, boolean expectedValue, String expectedVariant, String reason) { + val client = client(type, relayProxy, null); + val ctx = new MutableContext(TARGETING_KEY).add("version", version); + + val got = client.getBooleanDetails("boolean_semver_targeting_match", false, ctx); + + assertEquals(expectedValue, got.getValue()); + assertEquals(expectedVariant, got.getVariant()); + assertEquals(reason, got.getReason()); + } + + @DisplayName("an unknown flag should resolve to the default value with FLAG_NOT_FOUND") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void anUnknownFlagShouldResolveToTheDefaultValueWithFlagNotFound(EvaluationType type) { + val client = client(type, relayProxy, null); + + val got = client.getBooleanDetails("does_not_exist", false, TestUtils.defaultEvaluationContext); + + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + + @DisplayName("a flag evaluated as the wrong type should resolve to the default value with TYPE_MISMATCH") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aFlagEvaluatedAsTheWrongTypeShouldResolveToTheDefaultValueWithTypeMismatch(EvaluationType type) { + val client = client(type, relayProxy, null); + + val got = client.getStringDetails("bool_targeting_match", "sdk-default", TestUtils.defaultEvaluationContext); + + assertEquals("sdk-default", got.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + + @DisplayName("a disabled flag should resolve to the default value with DISABLED") + @ParameterizedTest(name = "{0} evaluation of {1}") + @MethodSource("disabledFlags") + void aDisabledFlagShouldResolveToTheDefaultValueWithDisabled( + EvaluationType type, String flagKey, Object defaultValue) { + assumeFalse( + type == EvaluationType.REMOTE, + "the relay proxy answers a disabled flag with a null value over OFREP, which the OFREP" + + " resolver reports as FLAG_NOT_FOUND"); + val client = client(type, relayProxy, null); + + val got = evaluate(client, flagKey, defaultValue); + + assertEquals(defaultValue, got.getValue()); + assertEquals(Reason.DISABLED.name(), got.getReason()); + assertNull(got.getErrorCode()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java new file mode 100644 index 0000000000..e31f6f70b1 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java @@ -0,0 +1,59 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.MUTABLE_FLAG; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableFlagValue; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableRelayProxy; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Stops a relay proxy of the test's own once the provider is ready, and checks what each evaluation mode serves. */ +class RelayProxyOutageIntegrationTest extends AbstractRelayProxyIntegrationTest { + + @DisplayName("an in-process provider should keep serving its last configuration once the relay proxy is gone") + @Test + void anInProcessProviderShouldKeepServingItsLastConfigurationOnceTheRelayProxyIsGone() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.IN_PROCESS, relayProxy).flagChangePollingIntervalMs(200L)); + val stale = new AtomicBoolean(); + client.onProviderStale(details -> stale.set(true)); + + relayProxy.stop(); + + val got = client.getStringDetails(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + assertEquals("A", got.getValue()); + assertNull(got.getErrorCode()); + await().atMost(Duration.ofSeconds(10)).untilTrue(stale); + assertEquals("A", mutableFlagValue(client), "a stale configuration is still served"); + } + } + + @DisplayName("a remote provider should fail its evaluations once the relay proxy is gone") + @Test + void aRemoteProviderShouldFailItsEvaluationsOnceTheRelayProxyIsGone() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.REMOTE, relayProxy).timeout(1000)); + assertEquals("A", mutableFlagValue(client)); + + relayProxy.stop(); + + val got = client.getStringDetails(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + assertEquals("sdk-default", got.getValue()); + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java new file mode 100644 index 0000000000..7bc1339b44 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java @@ -0,0 +1,109 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProvider; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Value; +import lombok.SneakyThrows; +import lombok.val; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** Builds the relay proxies the end-to-end tests run against, and the providers that talk to them. */ +final class RelayProxyTestHelper { + static final String MUTABLE_FLAG = "mutable_flag"; + static final String TARGETING_KEY = "d45e303a-38c2-11ed-a261-0242ac120002"; + + private static final DockerImageName RELAY_PROXY_IMAGE = + DockerImageName.parse("gofeatureflag/go-feature-flag:latest"); + private static final String FLAGS_PATH = "/openfeature/provider_tests/flags.yaml"; + + private RelayProxyTestHelper() {} + + /** A relay proxy serving the provider_tests fixture, with its evaluation context enrichment. */ + static GenericContainer relayProxy() { + return relayProxy(MountableFile.forClasspathResource("provider_tests/flags.yaml"), "goff-proxy.yaml", 1031); + } + + /** A relay proxy serving the provider_tests fixture only to callers sending the authorized_token API key. */ + static GenericContainer authenticatedRelayProxy() { + return relayProxy( + MountableFile.forClasspathResource("provider_tests/flags.yaml"), "goff-proxy-authenticated.yaml", 1032); + } + + /** A relay proxy serving only {@link #MUTABLE_FLAG} on variation A, for tests that change or stop it. */ + static GenericContainer mutableRelayProxy() { + return relayProxy(Transferable.of(mutableFlag("A")), "goff-proxy.yaml", 1031); + } + + /** Rewrites the flag file of a {@link #mutableRelayProxy()} so that it serves the given variation. */ + static void serveMutableFlagVariation(final GenericContainer relayProxy, final String variation) { + relayProxy.copyFileToContainer(Transferable.of(mutableFlag(variation)), FLAGS_PATH); + } + + static GoFeatureFlagProviderOptionsBuilder options( + final EvaluationType type, final GenericContainer relayProxy) { + return GoFeatureFlagProviderOptions.builder() + .endpoint(endpoint(relayProxy)) + .evaluationType(type); + } + + @SneakyThrows + static GoFeatureFlagProvider provider(final GoFeatureFlagProviderOptionsBuilder options) { + return new GoFeatureFlagProvider(options.build()); + } + + static String mutableFlagValue(final Client client) { + return client.getStringValue(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + } + + /** Evaluates the flag with the canonical context, through the resolver matching the default value's type. */ + static FlagEvaluationDetails evaluate(final Client client, final String flagKey, final Object defaultValue) { + val ctx = TestUtils.defaultEvaluationContext; + if (defaultValue instanceof Boolean) { + return client.getBooleanDetails(flagKey, (Boolean) defaultValue, ctx); + } + if (defaultValue instanceof String) { + return client.getStringDetails(flagKey, (String) defaultValue, ctx); + } + if (defaultValue instanceof Integer) { + return client.getIntegerDetails(flagKey, (Integer) defaultValue, ctx); + } + if (defaultValue instanceof Double) { + return client.getDoubleDetails(flagKey, (Double) defaultValue, ctx); + } + return client.getObjectDetails(flagKey, (Value) defaultValue, ctx); + } + + private static GenericContainer relayProxy( + final Transferable flags, final String configuration, final int port) { + return new GenericContainer<>(RELAY_PROXY_IMAGE) + .withCopyToContainer(flags, FLAGS_PATH) + .withCopyFileToContainer( + MountableFile.forClasspathResource("provider_tests/" + configuration), "/" + configuration) + .withCommand("/go-feature-flag", "--config", "/" + configuration) + .withExposedPorts(port) + .waitingFor(Wait.forHttp("/health").forStatusCode(200)); + } + + private static String mutableFlag(final String variation) { + return MUTABLE_FLAG + ":\n" + + " variations:\n" + + " A: A\n" + + " B: B\n" + + " defaultRule:\n" + + " variation: " + variation + "\n"; + } + + private static String endpoint(final GenericContainer relayProxy) { + val port = relayProxy.getExposedPorts().get(0); + return "http://" + relayProxy.getHost() + ":" + relayProxy.getMappedPort(port) + "/"; + } +} diff --git a/providers/go-feature-flag/src/test/resources/log4j2-test.xml b/providers/go-feature-flag/src/test/resources/log4j2-test.xml index ca4fab9d98..a5e384dce9 100644 --- a/providers/go-feature-flag/src/test/resources/log4j2-test.xml +++ b/providers/go-feature-flag/src/test/resources/log4j2-test.xml @@ -6,6 +6,8 @@ + + diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml new file mode 100644 index 0000000000..fd38583609 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml @@ -0,0 +1,190 @@ +bool_targeting_match: + variations: + Default: false + "False": false + "True": true + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_bool: + variations: + Default: false + "False": false + "True": true + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_float: + variations: + Default: 103.25 + "False": 101.25 + "True": 100.25 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_int: + variations: + Default: 103 + "False": 101 + "True": 100 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_interface: + variations: + Default: + test: default + "False": + test: "false" + "True": + test: test1 + test2: false + test3: 123.3 + test4: 1 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_string: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +double_key: + variations: + Default: 103.25 + "False": 101.25 + "True": 100.25 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +integer_key: + variations: + Default: 103 + "False": 101 + "True": 100 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +object_key: + variations: + Default: + test: default + "False": + test: "false" + "True": + test: test1 + test2: false + test3: 123.3 + test4: 1 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +string_key: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +string_key_with_version: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +flag-use-evaluation-context-enrichment: + variations: + A: A + B: B + targeting: + - query: environment eq "integration-test" + variation: A + defaultRule: + variation: B +boolean_semver_targeting_match: + variations: + "False": false + "True": true + targeting: + - query: version ge 10.0.0-10 + variation: "True" + defaultRule: + percentage: + "False": 100 + "True": 0 + metadata: + description: this is a semver matching test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/4764 diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml new file mode 100644 index 0000000000..49208a75b3 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml @@ -0,0 +1,14 @@ +server: + mode: http + port: 1032 +pollingInterval: 1000 +startWithRetrieverError: false +retriever: + kind: file + path: ./openfeature/provider_tests/flags.yaml +exporter: + kind: log +authorizedKeys: + evaluation: + - authorized_token +enableSwagger: true diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml new file mode 100644 index 0000000000..e5133e29ce --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml @@ -0,0 +1,13 @@ +server: + mode: http + port: 1031 +pollingInterval: 1000 +startWithRetrieverError: false +retriever: + kind: file + path: ./openfeature/provider_tests/flags.yaml +exporter: + kind: log +enableSwagger: true +evaluationContextEnrichment: + environment: integration-test From 1fdf4993b95b1bcafc3ba501bf67b831045cace3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Fri, 25 Sep 2026 23:26:06 +0200 Subject: [PATCH 66/93] fix(go-feature-flag): attribute a feature event to in-process evaluation GOFF-COLL-010 requires a feature event to carry source INPROCESS when the flag was evaluated locally. The event had no source at all, and the relay proxy's collector fills an empty source with PROVIDER_CACHE, so every evaluation this provider made in-process was recorded as served from a remote mode cache. Both hook stages set it, and INPROCESS is the only value either can see: the data collector hook is only registered in in-process mode, and a result the relay proxy produced through the fallback emits no feature event. The collector request test compares the exact body against a fixture, so the event it builds and the fixture both carry the field now. Signed-off-by: Thomas Poignant --- .../gofeatureflag/bean/FeatureEvent.java | 1 + .../gofeatureflag/hook/DataCollectorHook.java | 2 ++ .../ProviderDataCollectorTest.java | 28 +++++++++++++++++++ .../api/GoFeatureFlagApiTest.java | 1 + .../resources/api_events/valid-response.json | 1 + 5 files changed, 33 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java index ea8c48224d..a3fa01b2ed 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java @@ -18,6 +18,7 @@ public class FeatureEvent implements IEvent { private String key; private String kind; + private String source; private String userKey; private Object value; private String variation; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index ffd8735fc6..1fd202248e 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -56,6 +56,7 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") + .source("INPROCESS") .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .defaultValue(false) .variation(details.getVariant()) @@ -75,6 +76,7 @@ public void error(HookContext ctx, Exception error, Map hints) { IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") + .source("INPROCESS") .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) .defaultValue(true) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java index ed1d9bf67f..f084fca0d8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java @@ -16,6 +16,8 @@ import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; @DisplayName("Provider data collector") class ProviderDataCollectorTest extends AbstractGoFeatureFlagProviderTest { @@ -200,6 +202,32 @@ void shouldRecordAnEvaluationWithNoTargetingKeyUnderAPlaceholderKey() { "an evaluation with no targeting key was attributed to nobody"); } + @DisplayName("Should record a locally evaluated flag as an in-process evaluation") + @SneakyThrows + @ParameterizedTest(name = "{0}") + @CsvSource({"after stage, bool_targeting_match", "error stage, integer_key"}) + void shouldRecordALocallyEvaluatedFlagAsAnInProcessEvaluation(String stage, String flagKey) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails(flagKey, false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val events = (List>) body.get("events"); + assertEquals(1, events.size()); + assertEquals( + "INPROCESS", + events.get(0).get("source"), + "the collector cannot tell an in-process evaluation from one the relay proxy served"); + } + @DisplayName("Should not send events for remote evaluation") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index c253a9ddf6..edfd63c889 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -296,6 +296,7 @@ public void requestShouldHaveTheEvaluationContextInTheBody() { .creationDate(1617970547L) .contextKind("anonymousUser") .kind("feature") + .source("INPROCESS") .userKey("ABCD") .variation("enabled") .value(true) diff --git a/providers/go-feature-flag/src/test/resources/api_events/valid-response.json b/providers/go-feature-flag/src/test/resources/api_events/valid-response.json index 673edeaebd..6855e04782 100644 --- a/providers/go-feature-flag/src/test/resources/api_events/valid-response.json +++ b/providers/go-feature-flag/src/test/resources/api_events/valid-response.json @@ -9,6 +9,7 @@ "creationDate": 1617970547, "key": "xxx", "kind": "feature", + "source": "INPROCESS", "userKey": "ABCD", "value": true, "variation": "enabled", From 6d67fa2e2e8c7a8e9fd72140c89811f614adb653 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 11:00:08 +0200 Subject: [PATCH 67/93] fix(go-feature-flag): jitter the configuration polling interval GOFF-IP-011 asks for jitter on the polling interval so that a restarted fleet does not poll in lockstep. Polling ran on a fixed rate interval, so providers started together hit the relay proxy at the same instant for as long as they stayed up. Each poll now waits the configured interval shortened or lengthened by up to 10%, drawn afresh every cycle, which is the ratio the Python provider uses. The timer is re-armed once the previous refresh has returned rather than on a fixed rate, so a slow relay proxy no longer stacks up polls behind it either. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 2 +- .../evaluator/InProcessEvaluator.java | 21 ++++++++++++++++--- .../providers/gofeatureflag/util/Const.java | 2 ++ .../evaluator/InProcessEvaluatorTest.java | 16 ++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 6c1bcff9d9..26e000dd86 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -79,7 +79,7 @@ You can configure the provider with several options to customize its behavior. T | **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | | **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | | **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| -| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. default: `120000` | +| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | | **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 99f091c80d..1c0c6b6722 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -467,9 +468,10 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { ? options.getFlagChangePollingIntervalMs() : Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS; - Observable intervalObservable = - Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS, Schedulers.io()); - Observable apiCallObservable = intervalObservable + Observable pollObservable = Observable.defer(() -> + Observable.timer(nextPollDelayMs(pollingIntervalMs), TimeUnit.MILLISECONDS, Schedulers.io())) + .repeat(); + Observable apiCallObservable = pollObservable .flatMap(tick -> Observable.fromCallable(() -> { val configuration = this.api.retrieveFlagConfiguration( this.state.etag, options.getEvaluationFlagList()); @@ -500,6 +502,19 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { throwable -> log.error("flag configuration polling has stopped and will not resume", throwable)); } + /** + * nextPollDelayMs is the polling interval with jitter applied, so that a fleet restarted together + * does not poll the relay proxy in lockstep for as long as it stays up. + * + * @param pollingIntervalMs - the configured polling interval + * @return the interval, randomly shortened or lengthened by up to {@link Const#POLLING_JITTER_RATIO} + */ + static long nextPollDelayMs(final long pollingIntervalMs) { + return (long) (pollingIntervalMs + * ThreadLocalRandom.current() + .nextDouble(1 - Const.POLLING_JITTER_RATIO, 1 + Const.POLLING_JITTER_RATIO)); + } + /** * emitRefreshFailureEvent counts a failed refresh and marks the configuration stale once enough of * them have happened in a row. diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 75c1953cc9..8c3bccb238 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -38,6 +38,8 @@ public class Const { Runtime.getRuntime().availableProcessors(); /** consecutive failed refreshes after which the configuration is announced as stale. */ public static final int STALE_AFTER_CONSECUTIVE_FAILURES = 3; + /** fraction by which each poll interval is randomly shortened or lengthened. */ + public static final double POLLING_JITTER_RATIO = 0.1; // MAPPERS public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 33d98ca225..db60910f1c 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -35,6 +35,8 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.LongStream; import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.val; @@ -156,6 +158,20 @@ public MockResponse dispatch(RecordedRequest request) { } } + @DisplayName("each poll should be spread by up to ten percent either way") + @Test + void eachPollShouldBeSpreadByUpToTenPercentEitherWay() { + val delays = LongStream.range(0, 1000) + .map(i -> InProcessEvaluator.nextPollDelayMs(1000L)) + .boxed() + .collect(Collectors.toSet()); + + assertTrue(delays.stream().allMatch(d -> d >= 900L && d <= 1100L), "a poll strayed beyond the jitter"); + assertTrue( + delays.stream().anyMatch(d -> d < 1000L) && delays.stream().anyMatch(d -> d > 1000L), + "a fleet restarted together would still poll in lockstep"); + } + @SneakyThrows @DisplayName("a flag carrying unknown fields should still evaluate through the engine") @Test From 62d53d6fb757474c3db345db3b7ab4551892197a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 11:05:34 +0200 Subject: [PATCH 68/93] docs(go-feature-flag): say the engine ships inside the provider jar GOFF-WASM-011 asks providers to let the engine binary path be overridden, so that bundlers and non-standard packaging layouts stay usable. This provider has no binary to override: the chicory compiler turns the wasm module into Java bytecode at build time, and at runtime the only thing read is Module.meta, a resource beside the Module class that relocation moves with it. A shaded jar relocating the provider's package was checked to build and run the engine. Honouring the option would mean a second engine path, parsing a user supplied wasm file at startup and running it on Chicory's interpreter, measured at about ten times slower per evaluation, and outside the pinned 0.2.4 engine GOFF-ENG-001 relies on. As a SHOULD it is left untaken, and the README says why there is nothing to locate. The engine source link pointed at wasm/, which upstream moved to cmd/wasm. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 26e000dd86..a1eed54eaf 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -114,10 +114,12 @@ client.getObjectDetails("my-flag",Value.objectToValue(new MutableStructure().add When the provider is configured to use in process evaluation, it will fetch the flag configuration from the GO Feature Flag relay-proxy API and evaluate the flags directly in the provider. The evaluation is done inside the provider using a webassembly module that is compiled from the GO Feature Flag source code. -The `wasm` module is used to evaluate the flags and the source code is available in the [thomaspoignant/go-feature-flag](https://github.com/thomaspoignant/go-feature-flag/tree/main/wasm) repository. +The `wasm` module is used to evaluate the flags and the source code is available in the [thomaspoignant/go-feature-flag](https://github.com/thomaspoignant/go-feature-flag/tree/main/cmd/wasm) repository. The provider will call the GO Feature Flag relay-proxy API to fetch the flag configuration and then evaluate the flags using the `wasm` module. +The `wasm` module is compiled into Java bytecode when the provider is built, so it ships as classes inside the provider jar. There is no `.wasm` file to locate at runtime, and repackaging the provider (shaded or fat jars) keeps the engine with it. The engine version is pinned by the provider release. + ### Remote evaluation When the provider is configured to use remote evaluation, it will call the GO Feature Flag relay-proxy for each flag evaluation. From ebf9959b1a4989754d18cf9bc15e85161e6c65a4 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 11:53:48 +0200 Subject: [PATCH 69/93] feat(go-feature-flag): add custom headers for gateways in front of the relay proxy GOFF-AUTH-004 asks providers to allow arbitrary additional headers, for deployments behind a gateway that needs its own authentication. customHeaders is added to every request the provider makes: flag configuration, data collection including a dataCollectorBaseURL, remote evaluation and the in-process fallback to the relay proxy. The custom headers go on first and the provider's own are set over them, so a configured apiKey always wins over a custom X-API-Key, as in the Python provider. Content-Type and If-None-Match are refused outright rather than overridden: a custom If-None-Match matching the server's ETag left an in-process provider answering PROVIDER_NOT_READY on every poll. Validation at construction also refuses a header the Java HTTP client would reject on every request (Host, Content-Length, a malformed name), a null name or value, and the same name twice under different cases, which the two HTTP paths would otherwise send differently. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 27 +++++---- .../GoFeatureFlagProviderOptions.java | 59 +++++++++++++++++++ .../gofeatureflag/api/GoFeatureFlagApi.java | 16 +++-- .../evaluator/RemoteEvaluator.java | 7 ++- .../GoFeatureFlagProviderOptionsTest.java | 42 +++++++++++++ .../ProviderRemoteEvaluationTest.java | 21 +++++++ .../api/GoFeatureFlagApiTest.java | 51 ++++++++++++++++ .../evaluator/InProcessEvaluatorTest.java | 27 +++++++++ .../evaluator/RemoteEvaluatorTest.java | 31 ++++++++++ 9 files changed, 260 insertions(+), 21 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index a1eed54eaf..48456b3b3a 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -68,19 +68,20 @@ The `targetingKey` is mandatory for GO Feature Flag in order to evaluate the fea You can configure the provider with several options to customize its behavior. The following options are available: -| name | mandatory | Description | -|-----------------------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | -| **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.
  • If you want to have a local evaluation, you should use IN_PROCESS.
  • If you want to have an evaluation on the relay-proxy directly, you should use REMOTE.
Default: IN_PROCESS
| -| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | -| **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | -| **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | -| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | -| **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | -| **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | -| **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| -| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | -| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | +| name | mandatory | Description | +|-----------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | +| **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.
  • If you want to have a local evaluation, you should use IN_PROCESS.
  • If you want to have an evaluation on the relay-proxy directly, you should use REMOTE.
Default: IN_PROCESS
| +| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | +| **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | +| **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseURL`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | +| **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | +| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | +| **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | +| **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | +| **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| +| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | +| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag The OpenFeature client is used to retrieve values for the current `EvaluationContext`. For example, retrieving a boolean value for the flag **"my-flag"**: diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index 19813bf2c0..a6151899e2 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -7,7 +7,9 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import java.net.MalformedURLException; import java.net.URL; +import java.net.http.HttpRequest; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import lombok.Builder; @@ -57,6 +59,14 @@ public class GoFeatureFlagProviderOptions { * Default: null */ private String apiKey; + /** + * (optional) customHeaders are extra HTTP headers added to every request the provider makes, to the + * relay proxy and to dataCollectorBaseURL, for deployments behind a gateway that needs its own + * authentication. A configured apiKey always wins over a custom X-API-Key. Content-Type and + * If-None-Match are set by the provider, and they are refused here, as are the headers the Java HTTP + * client restricts (Host, Connection, Content-Length, Expect, Upgrade). Default: none + */ + private Map customHeaders; /** * (optional) interval time we publish statistics collection data to the proxy. The parameter is * used only if the cache is enabled, otherwise the collection of the data is done directly when @@ -131,6 +141,15 @@ public int getTimeout() { return timeout == 0 ? DEFAULT_TIMEOUT_MS : timeout; } + /** + * Get the extra HTTP headers added to every request to the relay proxy. + * + * @return the configured headers, an empty map if none was set + */ + public Map getCustomHeaders() { + return customHeaders == null ? Collections.emptyMap() : customHeaders; + } + /** * Get the interval time we publish statistics collection data to the proxy. * @@ -214,6 +233,8 @@ public void validate() throws InvalidOptions { } } + validateCustomHeaders(customHeaders); + if (wasmEvaluatorPoolSize != null && wasmEvaluatorPoolSize < 1) { throw new InvalidOptions("wasmEvaluatorPoolSize must be at least 1"); } @@ -229,4 +250,42 @@ public void validate() throws InvalidOptions { } } } + + /** + * validateCustomHeaders rejects, at construction, a custom header the provider would otherwise + * send wrongly or fail on at every request. The messages name the header but never carry its + * value, which is typically a gateway credential. + */ + private static void validateCustomHeaders(final Map headers) throws InvalidOptions { + if (headers == null || headers.isEmpty()) { + return; + } + + val probe = HttpRequest.newBuilder(); + val headersSet = new HashSet(); + for (Map.Entry header : headers.entrySet()) { + val name = header.getKey(); + if (name == null) { + throw new InvalidOptions("customHeaders cannot contain a null header name"); + } + if (Const.HTTP_HEADER_CONTENT_TYPE.equalsIgnoreCase(name) + || Const.HTTP_HEADER_IF_NONE_MATCH.equalsIgnoreCase(name)) { + throw new InvalidOptions("custom header " + name + " is set by the provider itself"); + } + try { + probe.header(name, "value"); + } catch (IllegalArgumentException e) { + throw new InvalidOptions("invalid custom header name, " + e.getMessage()); + } + + if (header.getValue() == null) { + throw new InvalidOptions("null value for header: " + name); + } + + val setSuccess = headersSet.add(name.toLowerCase()); + if (!setSuccess) { + throw new InvalidOptions("more than one header configured with the name, " + name); + } + } + } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 367ccfa041..f8dbc9e3b3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -56,6 +56,9 @@ public final class GoFeatureFlagApi { /** timeout is the timeout in milliseconds for the HTTP requests. */ private final int timeout; + /** customHeaders are the extra headers added to every request, before the provider's own. */ + private final Map customHeaders; + /** * GoFeatureFlagController is the constructor of the controller to contact the GO Feature Flag * relay proxy. @@ -70,6 +73,7 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva } options.validate(); this.apiKey = options.getApiKey(); + this.customHeaders = Map.copyOf(options.getCustomHeaders()); try { this.endpoint = asBaseUri(options.getEndpoint()); @@ -261,21 +265,21 @@ private static URI asBaseUri(final String url) throws URISyntaxException { * @return HttpRequest ready to be sent * @throws JsonProcessingException - if an error occurred while processing the json */ - private HttpRequest prepareHttpRequest(final URI url, final T requestBody, final String... customHeaders) + private HttpRequest prepareHttpRequest(final URI url, final T requestBody, final String... requestHeaders) throws JsonProcessingException { HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(url) .timeout(Duration.ofMillis(timeout)) - .header(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON) .POST(HttpRequest.BodyPublishers.ofByteArray( Const.SERIALIZE_OBJECT_MAPPER.writeValueAsBytes(requestBody))); - if (customHeaders != null && customHeaders.length > 0) { - reqBuilder.headers(customHeaders); + this.customHeaders.forEach(reqBuilder::header); + reqBuilder.setHeader(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON); + for (int i = 0; i + 1 < requestHeaders.length; i += 2) { + reqBuilder.setHeader(requestHeaders[i], requestHeaders[i + 1]); } - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_API_KEY, this.apiKey); + reqBuilder.setHeader(Const.HTTP_HEADER_API_KEY, this.apiKey); } return reqBuilder.build(); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 20950ab7fd..5a3a703d31 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -13,6 +13,7 @@ import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Value; import java.time.Duration; +import java.util.HashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; @@ -46,8 +47,10 @@ public class RemoteEvaluator implements IEvaluator { */ public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer emitter) { this.emitter = emitter; - val headers = ImmutableMap.>builder(); + val headers = new HashMap>(); + opts.getCustomHeaders().forEach((name, value) -> headers.put(name, ImmutableList.of(value))); if (opts.getApiKey() != null && !opts.getApiKey().isEmpty()) { + headers.keySet().removeIf(Const.HTTP_HEADER_API_KEY::equalsIgnoreCase); headers.put(Const.HTTP_HEADER_API_KEY, ImmutableList.of(opts.getApiKey())); } @@ -55,7 +58,7 @@ public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of(name, "value")) + .build())); + } + + @DisplayName("Should error if a custom header has no name or no value") + @ParameterizedTest(name = "null {0}") + @ValueSource(strings = {"name", "value"}) + void shouldErrorIfACustomHeaderHasNoNameOrNoValue(String missing) { + val headers = new HashMap(); + headers.put("name".equals(missing) ? null : "X-Gateway-Token", "value".equals(missing) ? null : "token"); + + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(headers) + .build())); + } + + @DisplayName("Should error if a custom header is configured twice under different cases") + @Test + void shouldErrorIfACustomHeaderIsConfiguredTwiceUnderDifferentCases() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of("X-Gateway-Token", "a", "x-gateway-token", "b")) + .build())); + } + @DisplayName("Should error if invalid flush interval is set") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java index 332b04166c..b553cbf4bf 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java @@ -12,6 +12,7 @@ import dev.openfeature.sdk.ProviderState; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; +import java.util.Map; import lombok.SneakyThrows; import lombok.val; import okhttp3.mockwebserver.MockWebServer; @@ -198,6 +199,26 @@ void shouldErrorIfEvaluatingTheWrongType() { assertEquals(want, got); } + @DisplayName("Should send the custom headers with a remote evaluation") + @SneakyThrows + @Test + void shouldSendTheCustomHeadersWithARemoteEvaluation() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + + assertEquals( + "gateway-token", + goffAPIMock.getEvaluateRequestsHistory().get(0).getHeader("X-Gateway-Token"), + "the provider did not hand its custom headers to the remote evaluator"); + } + @DisplayName("Should resolve a valid boolean flag") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index edfd63c889..cc49718b82 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -250,6 +250,27 @@ public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { } } + @SneakyThrows + @DisplayName("dataCollectorBaseURL should carry the custom headers") + @Test + public void dataCollectorBaseUrlShouldCarryTheCustomHeaders() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseURL(collectorServer.url("").toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); + + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + assertEquals("gateway-token", request.getHeader("X-Gateway-Token")); + } + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test @@ -416,6 +437,36 @@ public void requestShouldHaveAnAPIKey() { assertNull(request.getHeader("Authorization")); } + @SneakyThrows + @DisplayName("request should carry the custom headers") + @Test + public void requestShouldCarryTheCustomHeaders() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + assertEquals("gateway-token", server.takeRequest().getHeader("X-Gateway-Token")); + } + + @SneakyThrows + @DisplayName("a configured api key should win over a custom header of the same name") + @Test + public void aConfiguredApiKeyShouldWinOverACustomHeaderOfTheSameName() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .apiKey("my-api-key") + .customHeaders(Map.of("x-api-key", "custom-key")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val request = server.takeRequest(); + assertEquals(List.of("my-api-key"), request.getHeaders().values(Const.HTTP_HEADER_API_KEY)); + } + @SneakyThrows @DisplayName("request should call the configuration endpoint") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index db60910f1c..9a368702be 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -470,6 +470,33 @@ void aFallbackRequestShouldCarryTheConfiguredApiKey() { } } + @SneakyThrows + @DisplayName("a fallback request should carry the custom headers") + @Test + void aFallbackRequestShouldCarryTheCustomHeaders() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val evaluator = new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, (event, details) -> {}); + evaluator.initialize(new ImmutableContext()); + + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(1, mock.getEvaluateRequestsHistory().size()); + assertEquals( + "gateway-token", + mock.getEvaluateRequestsHistory().get(0).getHeader("X-Gateway-Token"), + "the fallback left the gateway's headers behind"); + } + } + @SneakyThrows @DisplayName("a fallback request should honour the configured timeout") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java index 62e945c144..bc697586d0 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.function.BiConsumer; import lombok.SneakyThrows; import lombok.val; @@ -52,6 +53,10 @@ private RemoteEvaluator evaluator( emitter); } + private RemoteEvaluator evaluator(GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder options) { + return new RemoteEvaluator(options.timeout(1000).build(), (event, details) -> {}); + } + private RemoteEvaluator evaluator() { return evaluator(this.server.url("").toString(), null, (event, details) -> {}); } @@ -223,6 +228,32 @@ void shouldSendTheApiKeyAsAnApiKeyHeader() { assertEquals("my-api-key", request.getHeader(Const.HTTP_HEADER_API_KEY)); } + @SneakyThrows + @DisplayName("should send the custom headers") + @Test + void shouldSendTheCustomHeaders() { + evaluator(GoFeatureFlagProviderOptions.builder() + .endpoint(this.server.url("").toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token"))) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals("gateway-token", this.server.takeRequest().getHeader("X-Gateway-Token")); + } + + @SneakyThrows + @DisplayName("a configured api key should win over a custom header of the same name") + @Test + void aConfiguredApiKeyShouldWinOverACustomHeaderOfTheSameName() { + evaluator(GoFeatureFlagProviderOptions.builder() + .endpoint(this.server.url("").toString()) + .apiKey("my-api-key") + .customHeaders(Map.of("x-api-key", "custom-key"))) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals(List.of("my-api-key"), request.getHeaders().values(Const.HTTP_HEADER_API_KEY)); + } + @SneakyThrows @DisplayName("should not send an API key header when none is configured") @Test From c6ee9e683029cd4e15d9df9dcb03fe43bb310302 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 11:56:17 +0200 Subject: [PATCH 70/93] fix(go-feature-flag)!: remove the data collector hook's cache option GOFF-CFG-010 requires vestigial options from removed features to be deleted rather than left inert. The two OkHttp options it was first aimed at went with GOFF-CFG-009. What remained was collectUnCachedEvaluation, a leftover of the remote cache: left unset, the hook recorded only evaluations served from that cache. Inside the provider it was inert, since the provider always set it to true. Outside it was worse than inert: nothing produces a CACHED evaluation any more, so a DataCollectorHook built directly without the option silently recorded nothing at all. The option, the filter it drove and the field that held it are gone, and the hook records every trackable evaluation the engine answered. DataCollectorHookOptions is public, so a caller setting the option no longer compiles and should drop the call. Signed-off-by: Thomas Poignant --- .../gofeatureflag/GoFeatureFlagProvider.java | 1 - .../gofeatureflag/hook/DataCollectorHook.java | 9 +---- .../hook/DataCollectorHookOptions.java | 5 --- .../hook/DataCollectorHookTest.java | 39 +++++++++++++++++++ 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 65d39fcba8..709f692f03 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -136,7 +136,6 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw if (!this.options.isDisableDataCollection() && this.options.getEvaluationType() != EvaluationType.REMOTE) { this.hooks.add(new DataCollectorHook(DataCollectorHookOptions.builder() .eventsPublisher(this.eventsPublisher) - .collectUnCachedEvaluation(true) .evaluator(this.evaluator) .build())); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 1fd202248e..c932a297c5 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -10,7 +10,6 @@ import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.Reason; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -20,8 +19,6 @@ */ @Slf4j public final class DataCollectorHook implements Hook> { - /** options contains all the options of this hook. */ - private final DataCollectorHookOptions options; /** eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ private final EventsPublisher eventsPublisher; /** evaluator is the service to evaluate the flags. */ @@ -40,16 +37,12 @@ public DataCollectorHook(final DataCollectorHookOptions options) throws InvalidO options.validate(); eventsPublisher = options.getEventsPublisher(); evaluator = options.getEvaluator(); - this.options = options; } @Override public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { // the relay proxy evaluated this flag itself and recorded it server side as it did so - if (wasEvaluatedRemotely(details) - || !this.evaluator.isFlagTrackable(ctx.getFlagKey()) - || (!Boolean.TRUE.equals(this.options.getCollectUnCachedEvaluation()) - && !Reason.CACHED.name().equals(details.getReason()))) { + if (wasEvaluatedRemotely(details) || !this.evaluator.isFlagTrackable(ctx.getFlagKey())) { return; } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java index 369a4a4d51..9bbaf60e05 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java @@ -14,11 +14,6 @@ @Builder @Getter public class DataCollectorHookOptions { - /** - * collectUnCachedEvent (optional) set to true if you want to send all events not only the cached - * evaluations. - */ - private Boolean collectUnCachedEvaluation; /** * eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java index b947ed5f8e..48793a5fd8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java @@ -1,9 +1,23 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Reason; +import java.util.Map; import lombok.SneakyThrows; +import lombok.val; import org.junit.jupiter.api.Test; public class DataCollectorHookTest { @@ -20,4 +34,29 @@ void shouldErrorIfNoEventsPublisherProvided() { InvalidOptions.class, () -> new DataCollectorHook(DataCollectorHookOptions.builder().build())); } + + @SneakyThrows + @Test + void shouldRecordAnEvaluationThatWasNotServedFromACache() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + val evaluator = mock(IEvaluator.class); + when(evaluator.isFlagTrackable("flag")).thenReturn(true); + val hook = new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .evaluator(evaluator) + .build()); + + hook.after( + HookContext.from( + "flag", FlagValueType.BOOLEAN, null, null, new ImmutableContext("key"), false), + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(true) + .variant("enabled") + .reason(Reason.TARGETING_MATCH.name()) + .build(), + Map.of()); + + verify(eventsPublisher).add(any()); + } } From b8ecca2ac224d40ff907646983b6599a2a99fca7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 12:03:22 +0200 Subject: [PATCH 71/93] fix(go-feature-flag): log what the evaluation engine prints The specification recommends a logger option so that a host can route the provider's diagnostics. In Java SLF4J is the language default, and every class of the provider already logs through it, so a host routes them by logger name through its own backend, without an option threaded through the provider. One diagnostic escaped it. Each WASM instance inherited the JVM's standard streams, so when the engine trapped it printed its panic straight to the process's stdout, where a host could neither route nor silence it. Both of the engine's streams now go to EvaluationWasm's logger, one event per line, at error level: the engine writes nothing while evaluating normally, so whatever it prints reports a fault. The README says which loggers to configure. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 5 ++ .../gofeatureflag/wasm/EvaluationWasm.java | 9 +++- .../gofeatureflag/wasm/WasmGuestOutput.java | 41 ++++++++++++++++ .../wasm/WasmEvaluatorPoolTest.java | 48 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 48456b3b3a..1cccaf0208 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -125,3 +125,8 @@ The `wasm` module is compiled into Java bytecode when the provider is built, so When the provider is configured to use remote evaluation, it will call the GO Feature Flag relay-proxy for each flag evaluation. It will perform an HTTP request to the GO Feature Flag relay-proxy API with the flag name and the evaluation context for each flag evaluation. + +### Logging +The provider logs through [SLF4J](https://www.slf4j.org/), so its diagnostics go wherever your SLF4J backend sends them. Every logger it uses sits under `dev.openfeature.contrib.providers.gofeatureflag`, and remote evaluation also logs under `dev.openfeature.contrib.providers.ofrep`. + +What the evaluation engine prints, such as the panic it reports before a trap, is logged too, at error level by `dev.openfeature.contrib.providers.gofeatureflag.wasm.EvaluationWasm`, rather than written to the process's standard streams. diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 831fa3b510..8949125487 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -31,6 +31,10 @@ public final class EvaluationWasm implements AutoCloseable { private final ExportFunction evaluate; private final ExportFunction malloc; private final ExportFunction free; + /** the engine writes nothing while evaluating normally, so anything it prints reports a fault. */ + private final WasmGuestOutput stdout = new WasmGuestOutput(line -> log.error("evaluation engine: {}", line)); + + private final WasmGuestOutput stderr = new WasmGuestOutput(line -> log.error("evaluation engine: {}", line)); /** * poisoned is set when the guest faults. A trap does not unwind the module's shadow-stack @@ -48,7 +52,8 @@ public final class EvaluationWasm implements AutoCloseable { public EvaluationWasm() throws WasmFileNotFound { this.wasi = WasiPreview1.builder() .withOptions(WasiOptions.builder() - .inheritSystem() + .withStdout(this.stdout) + .withStderr(this.stderr) .withThrowOnExit0(false) .build()) .build(); @@ -72,6 +77,8 @@ public EvaluationWasm() throws WasmFileNotFound { @Override public void close() { this.wasi.close(); + this.stdout.close(); + this.stderr.close(); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java new file mode 100644 index 0000000000..1395e8fc02 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java @@ -0,0 +1,41 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +/** + * WasmGuestOutput turns what the evaluation engine writes to one of its standard streams into log + * events, one per line, so that a host routes the engine's diagnostics, such as the panic it prints + * before trapping, through its logging backend like the rest of the provider's. + */ +final class WasmGuestOutput extends OutputStream { + private final Consumer sink; + private final ByteArrayOutputStream line = new ByteArrayOutputStream(); + + WasmGuestOutput(final Consumer sink) { + this.sink = sink; + } + + @Override + public synchronized void write(final int b) { + if (b == '\n') { + emit(); + } else if (b != '\r') { + line.write(b); + } + } + + @Override + public synchronized void close() { + emit(); + } + + private void emit() { + if (line.size() > 0) { + sink.accept(line.toString(StandardCharsets.UTF_8)); + line.reset(); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java index 10e12fc489..52ae56d4d8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -18,12 +18,21 @@ import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.Reason; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import lombok.SneakyThrows; import lombok.val; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -166,6 +175,45 @@ private static WasmInput trappingInput() { .build(); } + @SneakyThrows + @DisplayName("a guest's panic should reach the provider's log, not the process's standard streams") + @Test + void aGuestsPanicShouldReachTheProvidersLogNotTheProcessStandardStreams() { + val logger = (org.apache.logging.log4j.core.Logger) LogManager.getLogger(EvaluationWasm.class); + val logged = new CopyOnWriteArrayList(); + val appender = new AbstractAppender("guest-output-capture", null, null, true, Property.EMPTY_ARRAY) { + @Override + public void append(LogEvent event) { + if (Level.ERROR.equals(event.getLevel())) { + logged.add(event.getMessage().getFormattedMessage()); + } + } + }; + val processStreams = new ByteArrayOutputStream(); + val processStdout = System.out; + val processStderr = System.err; + appender.start(); + logger.addAppender(appender); + System.setOut(new PrintStream(processStreams, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(processStreams, true, StandardCharsets.UTF_8)); + try (val instance = new EvaluationWasm()) { + instance.preWarmWasm(); + instance.evaluate(trappingInput()); + } finally { + System.setOut(processStdout); + System.setErr(processStderr); + logger.removeAppender(appender); + appender.stop(); + } + + assertTrue( + logged.stream().anyMatch(line -> line.contains("panic: runtime error")), + "the engine's panic did not reach the log as an error: " + logged); + assertFalse( + processStreams.toString(StandardCharsets.UTF_8).contains("panic"), + "the engine still writes to the process's standard streams"); + } + @SneakyThrows @DisplayName("a real trap should poison the instance") @Test From 19ac502dabef5ee108fe5cfbd32b83cd7533161f Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 12:11:24 +0200 Subject: [PATCH 72/93] fix(go-feature-flag): pass checkstyle and SpotBugs The per-step checks of the conformance work ran the tests but not the quality gates bound to verify, and the branch had drifted into failing both, which CI runs on every pull request. Checkstyle rejected the indentation spotless gives a block lambda nested in the polling chain, so the refresh it held becomes refreshFlagConfiguration(), and it rejected the three capitals of dataCollectorBaseURL. That option was added on this branch and has never been released, so it takes the Java casing, dataCollectorBaseUrl, which the specification allows for a canonical name. SpotBugs flagged four option getters that unboxed the configured value only to box it again, now Objects.requireNonNullElse, and the ThreadLocalRandom behind the polling jitter. Predictability is harmless there, so it is excluded for that one method, as flagd's backoff jitter already is. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 2 +- .../GoFeatureFlagProviderOptions.java | 28 +++++++++---------- .../gofeatureflag/api/GoFeatureFlagApi.java | 8 +++--- .../evaluator/InProcessEvaluator.java | 18 ++++++++---- .../api/GoFeatureFlagApiTest.java | 20 ++++++------- spotbugs-exclusions.xml | 7 +++++ 6 files changed, 48 insertions(+), 35 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 1cccaf0208..c2019952ef 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -74,7 +74,7 @@ You can configure the provider with several options to customize its behavior. T | **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.
  • If you want to have a local evaluation, you should use IN_PROCESS.
  • If you want to have an evaluation on the relay-proxy directly, you should use REMOTE.
Default: IN_PROCESS
| | **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | | **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | -| **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseURL`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | +| **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseUrl`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | | **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | | **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | | **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index a6151899e2..5d573240a6 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -12,6 +12,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import lombok.Builder; import lombok.Getter; import lombok.val; @@ -41,12 +42,12 @@ public class GoFeatureFlagProviderOptions { */ private String endpoint; /** - * (optional) dataCollectorBaseURL is the base URL used to publish the evaluation data, when the + * (optional) dataCollectorBaseUrl is the base URL used to publish the evaluation data, when the * data collector is not served by the relay proxy itself. It replaces the whole base of the * collector route, scheme, host, port and path prefix included, and applies to that route only: * the flag configuration and the evaluations keep using endpoint. Default: endpoint */ - private String dataCollectorBaseURL; + private String dataCollectorBaseUrl; /** * (optional) timeout in millisecond we are waiting when calling the go-feature-flag relay proxy * API. Default: 10000 ms @@ -61,7 +62,7 @@ public class GoFeatureFlagProviderOptions { private String apiKey; /** * (optional) customHeaders are extra HTTP headers added to every request the provider makes, to the - * relay proxy and to dataCollectorBaseURL, for deployments behind a gateway that needs its own + * relay proxy and to dataCollectorBaseUrl, for deployments behind a gateway that needs its own * authentication. A configured apiKey always wins over a custom X-API-Key. Content-Type and * If-None-Match are set by the provider, and they are refused here, as are the headers the Java HTTP * client restricts (Host, Connection, Content-Length, Expect, Upgrade). Default: none @@ -128,8 +129,8 @@ public EvaluationType getEvaluationType() { * * @return the configured data collector base URL, the endpoint if none was set */ - public String getDataCollectorBaseURL() { - return dataCollectorBaseURL == null || dataCollectorBaseURL.isEmpty() ? endpoint : dataCollectorBaseURL; + public String getDataCollectorBaseUrl() { + return dataCollectorBaseUrl == null || dataCollectorBaseUrl.isEmpty() ? endpoint : dataCollectorBaseUrl; } /** @@ -156,7 +157,7 @@ public Map getCustomHeaders() { * @return the configured interval, 60000 ms if none was set */ public Long getFlushIntervalMs() { - return flushIntervalMs == null ? Const.DEFAULT_FLUSH_INTERVAL_MS : flushIntervalMs; + return Objects.requireNonNullElse(flushIntervalMs, Const.DEFAULT_FLUSH_INTERVAL_MS); } /** @@ -165,7 +166,7 @@ public Long getFlushIntervalMs() { * @return the configured maximum, 10000 if none was set */ public Integer getMaxPendingEvents() { - return maxPendingEvents == null ? Const.DEFAULT_MAX_PENDING_EVENTS : maxPendingEvents; + return Objects.requireNonNullElse(maxPendingEvents, Const.DEFAULT_MAX_PENDING_EVENTS); } /** @@ -192,9 +193,8 @@ public List getEvaluationFlagList() { * @return the configured interval, 120000 ms if none was set */ public Long getFlagChangePollingIntervalMs() { - return flagChangePollingIntervalMs == null - ? Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS - : flagChangePollingIntervalMs; + return Objects.requireNonNullElse( + flagChangePollingIntervalMs, Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS); } /** @@ -203,7 +203,7 @@ public Long getFlagChangePollingIntervalMs() { * @return the configured pool size, the number of available CPU cores if none was set */ public Integer getWasmEvaluatorPoolSize() { - return wasmEvaluatorPoolSize == null ? Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE : wasmEvaluatorPoolSize; + return Objects.requireNonNullElse(wasmEvaluatorPoolSize, Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE); } /** @@ -225,11 +225,11 @@ public void validate() throws InvalidOptions { throw new InvalidEndpoint("malformed endpoint: " + endpoint); } - if (dataCollectorBaseURL != null && !dataCollectorBaseURL.isEmpty()) { + if (dataCollectorBaseUrl != null && !dataCollectorBaseUrl.isEmpty()) { try { - new URL(dataCollectorBaseURL); + new URL(dataCollectorBaseUrl); } catch (MalformedURLException e) { - throw new InvalidEndpoint("malformed dataCollectorBaseURL: " + dataCollectorBaseURL); + throw new InvalidEndpoint("malformed dataCollectorBaseUrl: " + dataCollectorBaseUrl); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index f8dbc9e3b3..f06ab19dc0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -50,8 +50,8 @@ public final class GoFeatureFlagApi { /** endpoint is the endpoint of the GO Feature Flag relay proxy. */ private final URI endpoint; - /** dataCollectorBaseURL is the base of the data collector route, the endpoint unless overridden. */ - private final URI dataCollectorBaseURL; + /** dataCollectorBaseUrl is the base of the data collector route, the endpoint unless overridden. */ + private final URI dataCollectorBaseUrl; /** timeout is the timeout in milliseconds for the HTTP requests. */ private final int timeout; @@ -77,7 +77,7 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva try { this.endpoint = asBaseUri(options.getEndpoint()); - this.dataCollectorBaseURL = asBaseUri(options.getDataCollectorBaseURL()); + this.dataCollectorBaseUrl = asBaseUri(options.getDataCollectorBaseUrl()); } catch (URISyntaxException e) { throw new InvalidEndpoint(e); } @@ -153,7 +153,7 @@ public Optional retrieveFlagConfiguration(final String etag, public void sendEventToDataCollector(final List eventsList, final Map exporterMetadata) { try { ExporterRequest requestBody = new ExporterRequest(eventsList, exporterMetadata); - URI url = route(this.dataCollectorBaseURL, Const.PATH_DATA_COLLECTOR); + URI url = route(this.dataCollectorBaseUrl, Const.PATH_DATA_COLLECTOR); HttpRequest request = prepareHttpRequest(url, requestBody); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 1c0c6b6722..d12af005f4 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -472,12 +472,7 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { Observable.timer(nextPollDelayMs(pollingIntervalMs), TimeUnit.MILLISECONDS, Schedulers.io())) .repeat(); Observable apiCallObservable = pollObservable - .flatMap(tick -> Observable.fromCallable(() -> { - val configuration = this.api.retrieveFlagConfiguration( - this.state.etag, options.getEvaluationFlagList()); - emitRefreshSuccessEvent(); - return configuration; - }) + .flatMap(tick -> Observable.fromCallable(this::refreshFlagConfiguration) .onErrorResumeNext(e -> { log.error("error while calling flag configuration API", e); emitRefreshFailureEvent(); @@ -502,6 +497,17 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { throwable -> log.error("flag configuration polling has stopped and will not resume", throwable)); } + /** + * refreshFlagConfiguration fetches the flag configuration once and counts it as a successful refresh. + * + * @return the configuration, or an empty Optional when the relay proxy answered not modified + */ + private Optional refreshFlagConfiguration() { + val configuration = this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()); + emitRefreshSuccessEvent(); + return configuration; + } + /** * nextPollDelayMs is the polling interval with jitter applied, so that a fleet restarted together * does not poll the relay proxy in lockstep for as long as it stays up. diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index cc49718b82..d297b54e8d 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -162,7 +162,7 @@ public void requestShouldKeepThePathPrefixOfTheEndpoint() { } @SneakyThrows - @DisplayName("request should go to dataCollectorBaseURL when it is set") + @DisplayName("request should go to dataCollectorBaseUrl when it is set") @Test public void requestShouldGoToDataCollectorBaseUrlWhenItIsSet() { try (val collectorServer = new MockWebServer()) { @@ -170,7 +170,7 @@ public void requestShouldGoToDataCollectorBaseUrlWhenItIsSet() { collectorServer.start(); val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) - .dataCollectorBaseURL( + .dataCollectorBaseUrl( collectorServer.url("/collector-prefix/").toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); @@ -185,7 +185,7 @@ public void requestShouldGoToDataCollectorBaseUrlWhenItIsSet() { } @SneakyThrows - @DisplayName("the other routes should keep using the endpoint when dataCollectorBaseURL is set") + @DisplayName("the other routes should keep using the endpoint when dataCollectorBaseUrl is set") @Test public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSet() { try (val collectorServer = new MockWebServer()) { @@ -193,7 +193,7 @@ public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSe collectorServer.start(); val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) - .dataCollectorBaseURL(collectorServer.url("").toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); @@ -204,7 +204,7 @@ public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSe } @SneakyThrows - @DisplayName("dataCollectorBaseURL should carry the configured timeout") + @DisplayName("dataCollectorBaseUrl should carry the configured timeout") @Test public void dataCollectorBaseUrlShouldCarryTheConfiguredTimeout() { try (val collectorServer = new MockWebServer()) { @@ -217,7 +217,7 @@ public void dataCollectorBaseUrlShouldCarryTheConfiguredTimeout() { collectorServer.start(); val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) - .dataCollectorBaseURL(collectorServer.url("").toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) .timeout(200) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); @@ -229,7 +229,7 @@ public void dataCollectorBaseUrlShouldCarryTheConfiguredTimeout() { } @SneakyThrows - @DisplayName("dataCollectorBaseURL should carry the api key and the timeout") + @DisplayName("dataCollectorBaseUrl should carry the api key and the timeout") @Test public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { try (val collectorServer = new MockWebServer()) { @@ -238,7 +238,7 @@ public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { val apiKey = "my-api-key"; val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) - .dataCollectorBaseURL(collectorServer.url("").toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) .apiKey(apiKey) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); @@ -251,7 +251,7 @@ public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { } @SneakyThrows - @DisplayName("dataCollectorBaseURL should carry the custom headers") + @DisplayName("dataCollectorBaseUrl should carry the custom headers") @Test public void dataCollectorBaseUrlShouldCarryTheCustomHeaders() { try (val collectorServer = new MockWebServer()) { @@ -259,7 +259,7 @@ public void dataCollectorBaseUrlShouldCarryTheCustomHeaders() { collectorServer.start(); val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) - .dataCollectorBaseURL(collectorServer.url("").toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); diff --git a/spotbugs-exclusions.xml b/spotbugs-exclusions.xml index 7f1d7ae129..9bfb12b6d9 100644 --- a/spotbugs-exclusions.xml +++ b/spotbugs-exclusions.xml @@ -17,6 +17,13 @@ + + + + + + + From fd6ee177f466665af34cbe41b856e7bd7f451ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Andr=C3=A9?= <92062874+matheusandre1@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:28:42 -0300 Subject: [PATCH 73/93] fix: update Config defaults to boolean types and enhance environment variable handling (#1851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matheus André Signed-off-by: Todd Baert Co-authored-by: Todd Baert Signed-off-by: Thomas Poignant --- .../contrib/providers/flagd/Config.java | 46 ++++++++++++++--- .../contrib/providers/flagd/FlagdOptions.java | 6 +-- .../providers/flagd/FlagdOptionsTest.java | 49 +++++++++++++++++++ 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java index 1e152ff433..f479f8188a 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java @@ -13,7 +13,7 @@ public final class Config { static final Resolver DEFAULT_RESOLVER_TYPE = Resolver.RPC; static final String DEFAULT_RPC_PORT = "8013"; static final String DEFAULT_IN_PROCESS_PORT = "8015"; - static final String DEFAULT_TLS = "false"; + static final boolean DEFAULT_TLS = false; static final String DEFAULT_HOST = "localhost"; static final int DEFAULT_DEADLINE = 500; @@ -23,7 +23,7 @@ public final class Config { static final int DEFAULT_MAX_CACHE_SIZE = 1000; static final int DEFAULT_OFFLINE_POLL_MS = 5000; static final long DEFAULT_KEEP_ALIVE = 0; - static final String DEFAULT_REINITIALIZE_ON_ERROR = "false"; + static final boolean DEFAULT_REINITIALIZE_ON_ERROR = false; static final String RESOLVER_ENV_VAR = "FLAGD_RESOLVER"; static final String HOST_ENV_VAR_NAME = "FLAGD_HOST"; @@ -84,21 +84,55 @@ static String fallBackToEnvOrDefault(String key, String defaultValue) { } static int fallBackToEnvOrDefault(String key, int defaultValue) { + final String value = System.getenv(key); + if (value == null) { + return defaultValue; + } try { - return System.getenv(key) != null ? Integer.parseInt(System.getenv(key)) : defaultValue; - } catch (Exception e) { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + logInvalidEnvValue(key, value, "a whole number", defaultValue); return defaultValue; } } static long fallBackToEnvOrDefault(String key, long defaultValue) { + final String value = System.getenv(key); + if (value == null) { + return defaultValue; + } try { - return System.getenv(key) != null ? Long.parseLong(System.getenv(key)) : defaultValue; - } catch (Exception e) { + return Long.parseLong(value); + } catch (NumberFormatException e) { + logInvalidEnvValue(key, value, "a whole number", defaultValue); return defaultValue; } } + static boolean fallBackToEnvOrDefault(String key, boolean defaultValue) { + final String value = System.getenv(key); + if (value == null) { + return defaultValue; + } + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + logInvalidEnvValue(key, value, "'true' or 'false'", defaultValue); + return defaultValue; + } + + private static void logInvalidEnvValue(String key, String value, String expected, Object defaultValue) { + log.error( + "Invalid value '{}' for environment variable {}: expected {}. Falling back to default value '{}'.", + value, + key, + expected, + defaultValue); + } + static List fallBackToEnvOrDefaultList(String key, List defaultValue) { try { return System.getenv(key) != null diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java index 1859100d1d..33a3df59aa 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java @@ -60,7 +60,7 @@ public class FlagdOptions { * Use TLS connectivity. */ @Builder.Default - private boolean tls = Boolean.parseBoolean(fallBackToEnvOrDefault(Config.TLS_ENV_VAR_NAME, Config.DEFAULT_TLS)); + private boolean tls = fallBackToEnvOrDefault(Config.TLS_ENV_VAR_NAME, Config.DEFAULT_TLS); /** * TLS certificate overriding if TLS connectivity is used. @@ -235,8 +235,8 @@ public class FlagdOptions { * Particularly useful for troubleshooting network issues related to proxies or service meshes. */ @Builder.Default - private boolean reinitializeOnError = Boolean.parseBoolean( - fallBackToEnvOrDefault(Config.REINITIALIZE_ON_ERROR_ENV_VAR_NAME, Config.DEFAULT_REINITIALIZE_ON_ERROR)); + private boolean reinitializeOnError = + fallBackToEnvOrDefault(Config.REINITIALIZE_ON_ERROR_ENV_VAR_NAME, Config.DEFAULT_REINITIALIZE_ON_ERROR); /** * !EXPERIMENTAL! diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java index f6c3cab2f7..6d09c70951 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java @@ -1,18 +1,23 @@ package dev.openfeature.contrib.providers.flagd; +import static dev.openfeature.contrib.providers.flagd.Config.DEADLINE_MS_ENV_VAR_NAME; import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_CACHE; +import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_DEADLINE; import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_HOST; import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_IN_PROCESS_PORT; +import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_KEEP_ALIVE; import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_MAX_CACHE_SIZE; import static dev.openfeature.contrib.providers.flagd.Config.DEFAULT_RPC_PORT; import static dev.openfeature.contrib.providers.flagd.Config.KEEP_ALIVE_MS_ENV_VAR_NAME; import static dev.openfeature.contrib.providers.flagd.Config.KEEP_ALIVE_MS_ENV_VAR_NAME_OLD; +import static dev.openfeature.contrib.providers.flagd.Config.MAX_CACHE_SIZE_ENV_VAR_NAME; import static dev.openfeature.contrib.providers.flagd.Config.PORT_ENV_VAR_NAME; import static dev.openfeature.contrib.providers.flagd.Config.RESOLVER_ENV_VAR; import static dev.openfeature.contrib.providers.flagd.Config.RESOLVER_IN_PROCESS; import static dev.openfeature.contrib.providers.flagd.Config.RESOLVER_RPC; import static dev.openfeature.contrib.providers.flagd.Config.SYNC_PORT_ENV_VAR_NAME; import static dev.openfeature.contrib.providers.flagd.Config.TARGET_URI_ENV_VAR_NAME; +import static dev.openfeature.contrib.providers.flagd.Config.TLS_ENV_VAR_NAME; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -329,4 +334,48 @@ void testTargetOverrideFromEnv() { assertThat(flagdOptions.getTargetUri()).isEqualTo("envoy://localhost:1234/foo.service"); } + + @Nested + class InvalidEnvironmentVariables { + + @Test + @SetEnvironmentVariable(key = DEADLINE_MS_ENV_VAR_NAME, value = "not-a-number") + void invalidIntFallsBackToDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getDeadline()).isEqualTo(DEFAULT_DEADLINE); + } + + @Test + @SetEnvironmentVariable(key = MAX_CACHE_SIZE_ENV_VAR_NAME, value = "10.5") + void invalidIntWithDecimalFallsBackToDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getMaxCacheSize()).isEqualTo(DEFAULT_MAX_CACHE_SIZE); + } + + @Test + @SetEnvironmentVariable(key = KEEP_ALIVE_MS_ENV_VAR_NAME, value = "abc") + void invalidLongFallsBackToDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getKeepAlive()).isEqualTo(DEFAULT_KEEP_ALIVE); + } + + @Test + @SetEnvironmentVariable(key = TLS_ENV_VAR_NAME, value = "yes") + void invalidBooleanFallsBackToDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.isTls()).isFalse(); + } + + @Test + @SetEnvironmentVariable(key = TLS_ENV_VAR_NAME, value = "TRUE") + void booleanIsParsedCaseInsensitively() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.isTls()).isTrue(); + } + } } From 364f53919cc8116e7181172da4c26513690e8596 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:42:06 -0400 Subject: [PATCH 74/93] chore(main): release dev.openfeature.contrib.providers.flagd 0.14.2 (#1854) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Thomas Poignant --- .release-please-manifest.json | 2 +- providers/flagd/CHANGELOG.md | 8 ++++++++ providers/flagd/README.md | 2 +- providers/flagd/pom.xml | 2 +- providers/flagd/version.txt | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ddd0ff26e8..7e3e7f267e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,7 +1,7 @@ { "hooks/open-telemetry": "3.3.1", "hooks/codereadiness": "0.1.1", - "providers/flagd": "0.14.1", + "providers/flagd": "0.14.2", "providers/go-feature-flag": "1.2.1", "providers/flagsmith": "0.0.13", "providers/env-var": "0.0.12", diff --git a/providers/flagd/CHANGELOG.md b/providers/flagd/CHANGELOG.md index 7f8329a8d7..0ab3d2c707 100644 --- a/providers/flagd/CHANGELOG.md +++ b/providers/flagd/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.14.2](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.providers.flagd-v0.14.1...dev.openfeature.contrib.providers.flagd-v0.14.2) (2026-09-14) + + +### 🐛 Bug Fixes + +* shutdown blocks for unary deadline time ([#1848](https://github.com/open-feature/java-sdk-contrib/issues/1848)) ([4a433dc](https://github.com/open-feature/java-sdk-contrib/commit/4a433dce21f82e2f2d21f9f25b9326df59070942)) +* update Config defaults to boolean types and enhance environment variable handling ([#1851](https://github.com/open-feature/java-sdk-contrib/issues/1851)) ([eaf611d](https://github.com/open-feature/java-sdk-contrib/commit/eaf611d399e73a2487e2ecad3c494db0cb323300)) + ## [0.14.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.providers.flagd-v0.14.0...dev.openfeature.contrib.providers.flagd-v0.14.1) (2026-08-20) diff --git a/providers/flagd/README.md b/providers/flagd/README.md index bf67417a1e..3b88352791 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -8,7 +8,7 @@ This provider is designed to use flagd's [evaluation protocol](https://github.co dev.openfeature.contrib.providers flagd - 0.14.1 + 0.14.2 ``` diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index fb114bd0ce..8980e8f090 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -11,7 +11,7 @@ dev.openfeature.contrib.providers flagd - 0.14.1 + 0.14.2 diff --git a/providers/flagd/version.txt b/providers/flagd/version.txt index 930e3000bd..e867cc2a66 100644 --- a/providers/flagd/version.txt +++ b/providers/flagd/version.txt @@ -1 +1 @@ -0.14.1 +0.14.2 From c9e5c22cae458fa5289c737305d4522d5f07411e Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:27:39 -0400 Subject: [PATCH 75/93] chore(main): release dev.openfeature.contrib.tools.flagdapitestkit 0.3.0 (#1782) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Thomas Poignant --- .release-please-manifest.json | 2 +- tools/flagd-api-testkit/CHANGELOG.md | 17 +++++++++++++++++ tools/flagd-api-testkit/pom.xml | 2 +- tools/flagd-api-testkit/version.txt | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7e3e7f267e..4b973e0ea6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -16,7 +16,7 @@ "tools/junit-openfeature": "0.2.2", "tools/flagd-http-connector": "0.0.5", "tools/flagd-api": "1.0.0", - "tools/flagd-api-testkit": "0.2.1", + "tools/flagd-api-testkit": "0.3.0", "tools/flagd-core": "2.0.1", ".": "1.0.0", "providers/optimizely": "1.0.0" diff --git a/tools/flagd-api-testkit/CHANGELOG.md b/tools/flagd-api-testkit/CHANGELOG.md index cca1d7ea8f..6063364d95 100644 --- a/tools/flagd-api-testkit/CHANGELOG.md +++ b/tools/flagd-api-testkit/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.3.0](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdapitestkit-v0.2.1...dev.openfeature.contrib.tools.flagdapitestkit-v0.3.0) (2026-09-14) + + +### ⚠ BREAKING CHANGES + +* DISABLED is a successful evaluation (still defaults) ([#1800](https://github.com/open-feature/java-sdk-contrib/issues/1800)) + +### 🐛 Bug Fixes + +* **security:** update dependency com.fasterxml.jackson.core:jackson-databind to v2.22.1 [security] ([#1823](https://github.com/open-feature/java-sdk-contrib/issues/1823)) ([4f1ae9b](https://github.com/open-feature/java-sdk-contrib/commit/4f1ae9b5ac1bba9242aa3ff2ec5c43c94f7989fe)) +* various custom operator conformance fixes ([#1778](https://github.com/open-feature/java-sdk-contrib/issues/1778)) ([cced215](https://github.com/open-feature/java-sdk-contrib/commit/cced215af4ecbff010ae7bb4af82072224bc6eb0)) + + +### ✨ New Features + +* DISABLED is a successful evaluation (still defaults) ([#1800](https://github.com/open-feature/java-sdk-contrib/issues/1800)) ([d732c35](https://github.com/open-feature/java-sdk-contrib/commit/d732c355597f85cbd914107f4ff63e9ef73d677d)) + ## [0.2.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdapitestkit-v0.2.0...dev.openfeature.contrib.tools.flagdapitestkit-v0.2.1) (2026-04-01) diff --git a/tools/flagd-api-testkit/pom.xml b/tools/flagd-api-testkit/pom.xml index 54cd921e90..910f780827 100644 --- a/tools/flagd-api-testkit/pom.xml +++ b/tools/flagd-api-testkit/pom.xml @@ -10,7 +10,7 @@ dev.openfeature.contrib.tools flagd-api-testkit - 0.2.1 + 0.3.0 ${groupId}.flagdapitestkit diff --git a/tools/flagd-api-testkit/version.txt b/tools/flagd-api-testkit/version.txt index 0c62199f16..0d91a54c7d 100644 --- a/tools/flagd-api-testkit/version.txt +++ b/tools/flagd-api-testkit/version.txt @@ -1 +1 @@ -0.2.1 +0.3.0 From 498ab9701e060e3b3c3bc4bdd08e22dcdc093740 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 23 Sep 2026 15:12:03 -0400 Subject: [PATCH 76/93] chore: standardize/update release please config (#1864) Signed-off-by: Todd Baert Signed-off-by: Thomas Poignant --- .github/workflows/release-please.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2575345ce9..3b6edad8e0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -2,6 +2,11 @@ on: push: branches: - main + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + name: Run Release Please jobs: release-please: @@ -13,7 +18,7 @@ jobs: # Release-please creates a PR that tracks all changes steps: - - uses: googleapis/release-please-action@c2a5a2bd6a758a0937f1ddb1e8950609867ed15c # v4 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 id: release with: token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} From 3cf9a8e7cb0d0fdbbccc74c61b9dbf3b1716a360 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:45:00 +0200 Subject: [PATCH 77/93] chore(deps): update dependency io.github.gzsombor:json-logic-java to v1.1.6 (#1866) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Thomas Poignant --- providers/jsonlogic-eval-provider/pom.xml | 2 +- tools/flagd-core/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/jsonlogic-eval-provider/pom.xml b/providers/jsonlogic-eval-provider/pom.xml index bd9beac285..783a5d53f9 100644 --- a/providers/jsonlogic-eval-provider/pom.xml +++ b/providers/jsonlogic-eval-provider/pom.xml @@ -34,7 +34,7 @@ io.github.gzsombor json-logic-java - 1.1.5 + 1.1.6 org.json diff --git a/tools/flagd-core/pom.xml b/tools/flagd-core/pom.xml index 71315ae713..17fd61079a 100644 --- a/tools/flagd-core/pom.xml +++ b/tools/flagd-core/pom.xml @@ -49,7 +49,7 @@ io.github.gzsombor json-logic-java - 1.1.5 + 1.1.6 From 033447d01075c7719f7b74410950bb8f79bfa815 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 15:18:26 +0200 Subject: [PATCH 78/93] fix(go-feature-flag): evaluate again after a shutdown and re-initialization The provider reuses its evaluator across shutdown() and initialize(), but shutdown() released things initialize() never rebuilt. In process, the WASM pool stayed closed, so every configured flag answered GENERAL and went to a fallback evaluator that had been shut down too. In REMOTE mode the evaluator kept the same OfrepProvider, whose shutdown terminates the executor its HTTP client runs on and which has no way to restart it. initialize() now replaces a closed WASM pool and initializes the fallback, and RemoteEvaluator builds a new OfrepProvider when initialized after a shutdown. The existing re-init test only evaluated a missing flag, which never reaches the engine, so it could not see this. The new tests evaluate a configured flag and the relay proxy fallback after a re-init, at the evaluators and at the provider in both evaluation modes. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 13 ++++--- .../evaluator/RemoteEvaluator.java | 36 +++++++++++++++-- .../gofeatureflag/wasm/WasmEvaluatorPool.java | 2 + .../GoFeatureFlagProviderTest.java | 22 +++++++++++ .../evaluator/InProcessEvaluatorTest.java | 39 +++++++++++++++++++ .../evaluator/RemoteEvaluatorTest.java | 16 ++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index d12af005f4..a0bfc98744 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -56,7 +56,7 @@ public class InProcessEvaluator implements IEvaluator { /** API to contact GO Feature Flag. */ private final GoFeatureFlagApi api; /** Pool of WASM evaluation engine instances for thread-safe concurrent evaluation. */ - private final WasmEvaluatorPool evaluationPool; + private volatile WasmEvaluatorPool evaluationPool; /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; /** Method to call to emit a provider event to the SDK. */ @@ -129,10 +129,7 @@ public InProcessEvaluator( this.emitter = emitter; this.fallbackEvaluator = new RemoteEvaluator(options, emitter); this.state = EvaluatorState.notLoaded(); - int poolSize = options.getWasmEvaluatorPoolSize() != null - ? options.getWasmEvaluatorPoolSize() - : Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE; - this.evaluationPool = new WasmEvaluatorPool(poolSize); + this.evaluationPool = new WasmEvaluatorPool(options.getWasmEvaluatorPoolSize()); } private GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { @@ -178,6 +175,12 @@ public void initialize(EvaluationContext ctx) throws Exception { // We ensure that no polling is happening before starting the initialization. stopPolling(); + // shutdown() closes the pool and the fallback, and the provider reuses this evaluator on re-init + if (this.evaluationPool.isClosed()) { + this.evaluationPool = new WasmEvaluatorPool(options.getWasmEvaluatorPoolSize()); + } + this.fallbackEvaluator.initialize(ctx); + // an empty response means the configuration has not been modified, so the state we already // hold is still current and must not be overwritten. api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 5a3a703d31..931afdf0b0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -32,8 +32,12 @@ public class RemoteEvaluator implements IEvaluator { */ private static final String OFREP_AUTHENTICATION_ERROR = "authentication/authorization error for flag:"; + /** Options to configure the provider, kept to rebuild the OFREP provider after a shutdown. */ + private final GoFeatureFlagProviderOptions options; /** OFREP provider doing the actual remote evaluation. */ - private final OfrepProvider ofrep; + private volatile OfrepProvider ofrep; + /** true once shutdown() has stopped the OFREP provider, which cannot be restarted. */ + private volatile boolean ofrepShutDown; /** Method to call to emit a provider event to the SDK. */ private final BiConsumer emitter; /** Guards against re-reporting the same authentication failure on every later evaluation. */ @@ -46,7 +50,18 @@ public class RemoteEvaluator implements IEvaluator { * @param emitter - method to call to emit a provider event to the SDK */ public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer emitter) { + this.options = opts; this.emitter = emitter; + this.ofrep = newOfrepProvider(opts); + } + + /** + * newOfrepProvider builds the OFREP provider from the provider options. + * + * @param opts - options to configure the provider + * @return a new OFREP provider + */ + private static OfrepProvider newOfrepProvider(final GoFeatureFlagProviderOptions opts) { val headers = new HashMap>(); opts.getCustomHeaders().forEach((name, value) -> headers.put(name, ImmutableList.of(value))); if (opts.getApiKey() != null && !opts.getApiKey().isEmpty()) { @@ -54,7 +69,7 @@ public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer pool; private final Supplier instanceFactory; + @Getter private volatile boolean closed; /** diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index 5c2745df96..bb806e02ce 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; @DisplayName("Provider lifecycle") @@ -96,6 +98,26 @@ void shouldNotRegisterTheHooksTwiceWhenInitializedTwice() { afterSecond.stream().map(Object::getClass).collect(Collectors.toList())); } + @DisplayName("Should evaluate a configured flag after shutdown and re-init") + @ParameterizedTest(name = "{0} evaluation") + @CsvSource({"IN_PROCESS, bool_targeting_match", "REMOTE, bool_flag"}) + @SneakyThrows + void shouldEvaluateAConfiguredFlagAfterShutdownAndReInit(EvaluationType type, String flagKey) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(type) + .build()); + provider.initialize(new ImmutableContext()); + provider.shutdown(); + provider.initialize(new ImmutableContext()); + + val evaluated = provider.getBooleanEvaluation(flagKey, false, TestUtils.defaultEvaluationContext); + provider.shutdown(); + + assertNull(evaluated.getErrorCode()); + assertEquals(true, evaluated.getValue()); + } + @DisplayName("Should record one evaluation once after being initialized twice") @SneakyThrows @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 9a368702be..e4db9c4983 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; @@ -680,6 +681,44 @@ void shouldStayLoadedAcrossShutdownAndReInit() { evaluator.shutdown(); } + @SneakyThrows + @DisplayName("should evaluate a configured flag after shutdown and re-init") + @Test + void shouldEvaluateAConfiguredFlagAfterShutdownAndReInit() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val evaluated = + evaluator.getBooleanEvaluation("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + evaluator.shutdown(); + + assertNull(evaluated.getErrorCode()); + assertEquals(true, evaluated.getValue()); + } + + @SneakyThrows + @DisplayName("should fall back to the relay proxy after shutdown and re-init") + @Test + void shouldFallBackToTheRelayProxyAfterShutdownAndReInit() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val evaluated = + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, evaluated.getValue()); + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + } + } + @SneakyThrows @DisplayName("a missing targeting key should be passed through to the engine") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java index bc697586d0..26b0af21b9 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -112,6 +112,22 @@ void shouldResolveAnObjectFlag() { assertNull(got.getErrorCode()); } + @SneakyThrows + @DisplayName("should evaluate after shutdown and re-init") + @Test + void shouldEvaluateAfterShutdownAndReInit() { + val evaluator = evaluator(); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val got = evaluator.getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + @DisplayName("should pass the relay proxy metadata through untouched") @Test void shouldPassTheRelayProxyMetadataThrough() { From 4fce088301425a556de0519b841aeb4cf2d1fc9d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 15:26:14 +0200 Subject: [PATCH 79/93] fix(go-feature-flag): stop an evaluation waiting on a closed WASM pool An evaluation that passed the closed check waited in take() with no bound. close() drains the queue and returnToPool closes a returning instance instead of offering it back, so nothing could ever wake that evaluation, and it hung through the provider shutdown. It now waits for an instance in 100 ms slices and rechecks the closed flag between them, answering the pool-closed error instead. A sentinel offered on close would wake waiters immediately, but it would have to be an EvaluationWasm, a final class whose constructor loads the whole engine. Signed-off-by: Thomas Poignant --- .../gofeatureflag/wasm/WasmEvaluatorPool.java | 20 +++++++--- .../wasm/WasmEvaluatorPoolTest.java | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java index 877061bce0..6aea3eb461 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -7,6 +7,7 @@ import dev.openfeature.sdk.Reason; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -18,6 +19,9 @@ */ @Slf4j public final class WasmEvaluatorPool implements AutoCloseable { + /** how long an evaluation waits for an instance before checking again whether the pool was closed. */ + private static final long CLOSED_CHECK_INTERVAL_MS = 100L; + private final BlockingQueue pool; private final Supplier instanceFactory; @@ -53,18 +57,22 @@ private EvaluationWasm newInstance() { /** * Evaluates a feature flag by borrowing one WASM instance from the pool, * delegating to it, and returning it when done. - * Blocks if all instances are busy until one becomes available. + * Blocks if all instances are busy until one becomes available, or until the pool is closed. * * @param wasmInput evaluation input * @return evaluation result */ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { - if (closed) { - return errorResponse("WASM evaluator pool is closed"); - } - EvaluationWasm instance; + EvaluationWasm instance = null; try { - instance = pool.take(); + // close() empties the queue and nothing is offered back afterwards, so a plain take() + // would wait forever: waiting in slices lets a waiter notice the pool has been closed. + while (instance == null) { + if (closed) { + return errorResponse("WASM evaluator pool is closed"); + } + instance = pool.poll(CLOSED_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return errorResponse("WASM evaluator pool interrupted while waiting for an available instance"); diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java index 52ae56d4d8..0915ebae89 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -24,6 +24,9 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import lombok.SneakyThrows; @@ -330,4 +333,38 @@ void aClosedPoolShouldAnswerWithAnErrorInsteadOfBlockingOnAnEmptyQueue() { assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); assertEquals(Reason.ERROR.name(), got.getReason()); } + + @SneakyThrows + @DisplayName("closing the pool should release an evaluation waiting for an instance") + @Test + void closingThePoolShouldReleaseAnEvaluationWaitingForAnInstance() { + val busy = new CountDownLatch(1); + val release = new CountDownLatch(1); + val instance = mock(EvaluationWasm.class); + when(instance.evaluate(any())).thenAnswer(invocation -> { + busy.countDown(); + release.await(); + return new GoFeatureFlagResponse(); + }); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(instance))); + val executor = Executors.newFixedThreadPool(2); + try { + val holder = executor.submit(() -> pool.evaluate(input().value)); + busy.await(); + // the only instance is busy, so this evaluation waits for it + val waiter = executor.submit(() -> pool.evaluate(input().value)); + Thread.sleep(100L); + + pool.close(); + release.countDown(); + holder.get(2, TimeUnit.SECONDS); + + // the returning instance is closed rather than handed over, so nothing else wakes the waiter + val got = waiter.get(2, TimeUnit.SECONDS); + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } } From dbfdfdf419a75442b9bd93d6d57cc819d7eb64e8 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 15:30:57 +0200 Subject: [PATCH 80/93] fix(go-feature-flag): reject a data collector hook built without an evaluator DataCollectorHookOptions.validate() checked the events publisher only. A hook built without an evaluator was accepted, then threw a NullPointerException in after and error on every evaluation. The SDK swallows hook exceptions, so every usage event was lost without a clear error. The builder is public, so callers outside the provider can reach this; the provider always passes an evaluator. Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/DataCollectorHookOptions.java | 3 +++ .../gofeatureflag/hook/DataCollectorHookTest.java | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java index 9bbaf60e05..608fed7c76 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java @@ -33,5 +33,8 @@ public void validate() throws InvalidOptions { if (getEventsPublisher() == null) { throw new InvalidOptions("No events publisher provided"); } + if (getEvaluator() == null) { + throw new InvalidOptions("No evaluator provided"); + } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java index 48793a5fd8..cecf23de51 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java @@ -35,6 +35,17 @@ void shouldErrorIfNoEventsPublisherProvided() { () -> new DataCollectorHook(DataCollectorHookOptions.builder().build())); } + @SneakyThrows + @Test + void shouldErrorIfNoEvaluatorProvided() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + assertThrows( + InvalidOptions.class, + () -> new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .build())); + } + @SneakyThrows @Test void shouldRecordAnEvaluationThatWasNotServedFromACache() { From 94ac39453d9e27e406772739aa6474695afd360d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 15:32:41 +0200 Subject: [PATCH 81/93] docs(go-feature-flag): correct the flush interval default and drop cache wording The README documented a flushIntervalMs default of 1000 ms, while the option resolves to Const.DEFAULT_FLUSH_INTERVAL_MS, one minute. It and disableDataCollection were also still described in terms of the remote cache, which no longer exists: the interval flushes the evaluation and tracking events, and disabling collection stops both. The option javadoc said the same. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 4 ++-- .../gofeatureflag/GoFeatureFlagProviderOptions.java | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index c2019952ef..67548410f6 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -75,9 +75,9 @@ You can configure the provider with several options to customize its behavior. T | **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | | **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | | **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseUrl`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | -| **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | +| **`flushIntervalMs`** | `false` | interval time in millisecond we publish the collected evaluation and tracking events to the data collector. _(default: `60000` (1 minute))_ | | **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | -| **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | +| **`disableDataCollection`** | `false` | set to true if you don't want to send the evaluation and tracking events to the data collector. _(default: `false`)_ | | **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | | **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| | **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index 5d573240a6..fb07ca9582 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -69,9 +69,8 @@ public class GoFeatureFlagProviderOptions { */ private Map customHeaders; /** - * (optional) interval time we publish statistics collection data to the proxy. The parameter is - * used only if the cache is enabled, otherwise the collection of the data is done directly when - * calling the evaluation API. default: 60000 ms (1 minute) + * (optional) interval time in millisecond we publish the collected evaluation and tracking events + * to the data collector. default: 60000 ms (1 minute) */ private Long flushIntervalMs; /** @@ -80,8 +79,8 @@ public class GoFeatureFlagProviderOptions { */ private Integer maxPendingEvents; /** - * (optional) disableDataCollection set to true if you don't want to collect the usage of flags - * retrieved in the cache. default: false + * (optional) disableDataCollection set to true if you don't want to send the evaluation and + * tracking events to the data collector. default: false */ private boolean disableDataCollection; @@ -152,7 +151,7 @@ public Map getCustomHeaders() { } /** - * Get the interval time we publish statistics collection data to the proxy. + * Get the interval time we publish the collected events to the data collector. * * @return the configured interval, 60000 ms if none was set */ From 072d6312ff7e346f175c2252032496dbe7dc68da Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 16:10:16 +0200 Subject: [PATCH 82/93] test(go-feature-flag): give the polling change tests time to see a poll Two polling tests waited about 100 ms for a configuration change while polling every 100 ms. Since the polling interval is jittered by up to 10% and re-armed only once the previous refresh returns, the first poll can land after 110 ms, before the HTTP round trip and the configuration apply. The Java 21 CI job failed shouldEmitConfigurationChangeEventIfConfigHasChanged on exactly that. Both tests now wait up to 2 s, like the 304 polling test beside them. The loop still exits as soon as the change arrives. Signed-off-by: Thomas Poignant --- .../gofeatureflag/ProviderConfigurationPollingTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java index cc1b4307b0..69ebb3d838 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java @@ -46,7 +46,7 @@ void shouldEmitConfigurationChangeEventIfConfigHasChanged() { client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); // waiting to get a flag change - int maxWait = 10; + int maxWait = 200; while (!configurationChangedCalled.get() && maxWait > 0) { maxWait--; Thread.sleep(10L); @@ -159,7 +159,7 @@ void shouldChangeEvaluationValueIfConfigHasChanged() { val got1 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); // waiting to get a flag change - int maxWait = 10; + int maxWait = 200; while (!configurationChangedCalled.get() && maxWait > 0) { maxWait--; Thread.sleep(10L); From 27e71c4ae39ffa2f8f496b9f9be8fe17b41cf78b Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 21:35:38 +0200 Subject: [PATCH 83/93] fix(go-feature-flag): keep the engine's reason and variant for a null value When the engine returned a null value without an error, toProviderEvaluation returned the caller's default with a hard-coded DEFAULT reason and no variant. The spec requires the engine's reason and variant to be preserved (GOFF-EVAL-006) and the reason to be passed through as an opaque string (GOFF-EVAL-007), so an engine answer such as TARGETING_MATCH_SPLIT reached the application as DEFAULT. With no variant, the after stage of DataCollectorHook then sent a feature event with "variation": null. It now falls back to SdkDefault, as the error stage already does (GOFF-COLL-008). The test that pinned the old behaviour now expects the engine's details. REMOTE mode is unchanged: OFREP still maps a null value to FLAG_NOT_FOUND. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 3 +- .../gofeatureflag/hook/DataCollectorHook.java | 2 +- .../evaluator/InProcessEvaluatorTest.java | 6 ++-- .../hook/DataCollectorHookTest.java | 32 +++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index a0bfc98744..7cb2e93c39 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -367,7 +367,8 @@ static ProviderEvaluation toProviderEvaluation( if (response.getValue() == null) { return ProviderEvaluation.builder() .value(defaultValue) - .reason(Reason.DEFAULT.name()) + .reason(response.getReason()) + .variant(response.getVariationType()) .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) .build(); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index c932a297c5..59a8064b74 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -52,7 +52,7 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { .source("INPROCESS") .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .defaultValue(false) - .variation(details.getVariant()) + .variation(details.getVariant() != null ? details.getVariant() : "SdkDefault") .value(details.getValue()) .userKey(EvaluationContextUtil.userKey(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index e4db9c4983..78ff5a7a13 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -820,13 +820,15 @@ void shouldNotLetADecimalNumberSatisfyTheIntegerResolver() { @Test void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { val response = responseWithValue(null); + response.setReason("TARGETING_MATCH_SPLIT"); + response.setVariationType("nullVariation"); response.setMetadata(Map.of("description", "a flag with no value")); ProviderEvaluation result = toProviderEvaluation("test-flag", true, response, Boolean.class); assertEquals(true, result.getValue()); - assertEquals(Reason.DEFAULT.name(), result.getReason()); - assertNull(result.getVariant()); + assertEquals("TARGETING_MATCH_SPLIT", result.getReason(), "the engine reason must be passed through"); + assertEquals("nullVariation", result.getVariant(), "the engine variant must be kept"); assertEquals("a flag with no value", result.getFlagMetadata().getString("description")); assertNull(result.getErrorCode()); } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java index cecf23de51..2c27d04cef 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java @@ -1,11 +1,13 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; @@ -19,6 +21,7 @@ import lombok.SneakyThrows; import lombok.val; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; public class DataCollectorHookTest { @SneakyThrows @@ -70,4 +73,33 @@ void shouldRecordAnEvaluationThatWasNotServedFromACache() { verify(eventsPublisher).add(any()); } + + @SneakyThrows + @Test + void shouldRecordSdkDefaultAsVariationWhenTheEvaluationHasNoVariant() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + val evaluator = mock(IEvaluator.class); + when(evaluator.isFlagTrackable("flag")).thenReturn(true); + val hook = new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .evaluator(evaluator) + .build()); + + hook.after( + HookContext.from( + "flag", FlagValueType.BOOLEAN, null, null, new ImmutableContext("key"), false), + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(false) + .reason(Reason.DEFAULT.name()) + .build(), + Map.of()); + + ArgumentCaptor event = ArgumentCaptor.forClass(IEvent.class); + verify(eventsPublisher).add(event.capture()); + assertEquals( + "SdkDefault", + ((FeatureEvent) event.getValue()).getVariation(), + "variation must be the resolved variant, or SdkDefault when there is none"); + } } From 8a6cb6baad6dd6c02664d2a46076f54a3263e4af Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 21:42:08 +0200 Subject: [PATCH 84/93] fix(go-feature-flag): accept integers beyond the long range in object values Jackson decodes an integer beyond the long range as BigInteger, and Value.objectToValue has no branch for it, so an object flag holding such a number returned TYPE_MISMATCH instead of its value. That value is representable, so GOFF-EVAL-002 does not allow rejecting it. convertValue now replaces every BigInteger in the decoded object with a Double before building the Value, at any depth. The engine writes these numbers from a float64 in plain form, so a Double holds them exactly. REMOTE mode is unchanged: OFREP calls Value.objectToValue itself. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 3 +- .../gofeatureflag/util/JsonValueUtil.java | 46 +++++++++++++++++++ .../evaluator/InProcessEvaluatorTest.java | 26 +++++++++++ .../gofeatureflag/util/JsonValueUtilTest.java | 36 +++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java create mode 100644 providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 7cb2e93c39..de4f633b39 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -8,6 +8,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.JsonValueUtil; import dev.openfeature.contrib.providers.gofeatureflag.util.MetadataUtil; import dev.openfeature.contrib.providers.gofeatureflag.wasm.WasmEvaluatorPool; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; @@ -412,7 +413,7 @@ private static T convertValue(final Object value, final Class expectedTyp } return (T) value; } - return (T) objectToValue(value); + return (T) objectToValue(JsonValueUtil.widenBigIntegers(value)); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java new file mode 100644 index 0000000000..64bc9ebb4a --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * JsonValueUtil is a utility class to prepare values decoded from JSON before they are converted to + * an Open Feature Value. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JsonValueUtil { + /** + * widenBigIntegers replaces every BigInteger in a decoded JSON value with its Double equivalent. + * + *

Jackson decodes an integer beyond the long range as BigInteger, which Value.objectToValue + * rejects. The engine writes those numbers from a float64, so a Double holds them exactly. + * + * @param value - a value decoded from the engine's JSON output + * @return the same value, with BigInteger replaced by Double at any depth + */ + public static Object widenBigIntegers(final Object value) { + if (value instanceof BigInteger) { + return ((BigInteger) value).doubleValue(); + } + if (value instanceof Map) { + Map widened = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + widened.put(entry.getKey(), widenBigIntegers(entry.getValue())); + } + return widened; + } + if (value instanceof List) { + List widened = new ArrayList<>(); + for (Object item : (List) value) { + widened.add(widenBigIntegers(item)); + } + return widened; + } + return value; + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 78ff5a7a13..035610fc3e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -833,6 +833,32 @@ void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { assertNull(result.getErrorCode()); } + @SneakyThrows + @DisplayName("Should accept an object value holding an integer beyond the long range") + @Test + void shouldAcceptAnObjectValueHoldingAnIntegerBeyondTheLongRange() { + val response = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"value\":{\"limit\":10000000000000000000,\"tiers\":[{\"max\":18446744073709551616}]}," + + "\"reason\":\"STATIC\",\"variationType\":\"big\"}", + GoFeatureFlagResponse.class); + + ProviderEvaluation result = + toProviderEvaluation("test-flag", new Value("caller-default"), response, Value.class); + + Structure value = result.getValue().asStructure(); + assertEquals(1e19, value.getValue("limit").asDouble(), "a float64 the engine wrote in plain form"); + assertEquals( + 18446744073709551616.0, + value.getValue("tiers") + .asList() + .get(0) + .asStructure() + .getValue("max") + .asDouble(), + "nested values beyond the long range must be kept too"); + assertEquals("big", result.getVariant()); + } + @DisplayName("Should keep the flag metadata when the flag is disabled") @Test void shouldKeepTheFlagMetadataWhenTheFlagIsDisabled() { diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java new file mode 100644 index 0000000000..1591e7c9be --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java @@ -0,0 +1,36 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonValueUtilTest { + private static final BigInteger BEYOND_LONG = new BigInteger("18446744073709551616"); + + @Test + void testWidenBigIntegers_TopLevel() { + assertEquals(18446744073709551616.0, JsonValueUtil.widenBigIntegers(BEYOND_LONG)); + } + + @Test + void testWidenBigIntegers_Nested() { + Object widened = JsonValueUtil.widenBigIntegers( + Map.of("limit", BEYOND_LONG, "tiers", List.of(Map.of("max", BEYOND_LONG)))); + + assertEquals( + Map.of("limit", 18446744073709551616.0, "tiers", List.of(Map.of("max", 18446744073709551616.0))), + widened); + } + + @Test + void testWidenBigIntegers_OtherValuesUnchanged() { + assertEquals(42L, JsonValueUtil.widenBigIntegers(42L)); + assertEquals("text", JsonValueUtil.widenBigIntegers("text")); + assertEquals(List.of(1, true, "a"), JsonValueUtil.widenBigIntegers(List.of(1, true, "a"))); + assertNull(JsonValueUtil.widenBigIntegers(null)); + } +} From 33ac6ff040f9839bcc72224990248fc8dafc58cf Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 22:02:04 +0200 Subject: [PATCH 85/93] fix(go-feature-flag): keep the relay proxy's TYPE_MISMATCH on the fallback path When the engine failed with PARSE_ERROR or GENERAL, evaluateRemotely kept the relay proxy's answer only if it carried no error, and otherwise returned the engine's error. A relay proxy that did evaluate the flag but served a boolean to a numeric resolver, or a decimal to the integer resolver, answers TYPE_MISMATCH. The caller got the engine's GENERAL instead, which breaks GOFF-EVAL-004 and GOFF-EVAL-005. A remote TYPE_MISMATCH is now returned as the relay proxy's verdict, with the evaluated-remotely marker. Any other remote error still leaves the engine's error standing. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 9 +++-- .../evaluator/InProcessEvaluatorTest.java | 40 +++++++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 1 + .../flag-the-proxy-serves-as-a-decimal.json | 7 ++++ 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index de4f633b39..f215dc525a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -273,9 +273,10 @@ private ProviderEvaluation genericEvaluation( /** * evaluateRemotely asks the relay proxy about a flag the engine could not evaluate. * - *

An empty result means the relay proxy could not answer either, in which case the caller is - * owed the engine's error rather than the proxy's: the engine failing is the root cause, and the - * proxy merely failed to make up for it. The remote failure is logged here because it is about to + *

A TYPE_MISMATCH is kept: the relay proxy did evaluate the flag, and its value does not fit the + * type asked for. An empty result means the relay proxy could not answer either, in which case the + * caller is owed the engine's error rather than the proxy's: the engine failing is the root cause, + * and the proxy merely failed to make up for it. The remote failure is logged here because it is about to * disappear from the answer entirely.

* * @param key - name of the flag @@ -292,7 +293,7 @@ private Optional> evaluateRemotely( final RemoteResolver remoteResolver) { try { val remote = remoteResolver.resolve(this.fallbackEvaluator, key, defaultValue, ctx); - if (remote.getErrorCode() == null) { + if (remote.getErrorCode() == null || remote.getErrorCode() == ErrorCode.TYPE_MISMATCH) { return Optional.of(markEvaluatedRemotely(remote)); } log.error( diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 035610fc3e..28f918d3ce 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -613,6 +613,46 @@ void aRelayProxyThatFailsTooShouldLeaveTheEnginesErrorStanding(String flagKey) { } } + @SneakyThrows + @DisplayName("a boolean from the relay proxy should report TYPE_MISMATCH on a numeric resolver") + @Test + void aBooleanFromTheRelayProxyShouldReportTypeMismatchOnANumericResolver() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val asInteger = + evaluator.getIntegerEvaluation("flag-with-a-broken-query", 7, new ImmutableContext("user-key")); + val asDouble = + evaluator.getDoubleEvaluation("flag-with-a-broken-query", 7.5, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(ErrorCode.TYPE_MISMATCH, asInteger.getErrorCode(), "the relay proxy served a boolean"); + assertEquals(7, asInteger.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, asDouble.getErrorCode(), "the relay proxy served a boolean"); + assertEquals(7.5, asDouble.getValue()); + } + } + + @SneakyThrows + @DisplayName("a decimal from the relay proxy should report TYPE_MISMATCH on the integer resolver") + @Test + void aDecimalFromTheRelayProxyShouldReportTypeMismatchOnTheIntegerResolver() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val evaluated = evaluator.getIntegerEvaluation( + "flag-the-proxy-serves-as-a-decimal", 7, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(ErrorCode.TYPE_MISMATCH, evaluated.getErrorCode(), "the relay proxy served 101.25"); + assertEquals(7, evaluated.getValue()); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 5bc8c55aa2..f581049bf2 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -184,6 +184,7 @@ private MockResponse misconfiguredFlagsConfig() { + " \"flag-the-proxy-does-not-have\": " + BROKEN_QUERY_FLAG + "," + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "," + " \"flag-the-proxy-answers-slowly\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-serves-as-a-decimal\": " + BROKEN_QUERY_FLAG + "," + " \"healthy-flag\": {\"variations\": {\"on\": true}," + " \"defaultRule\": {\"variation\": \"on\"}}}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json new file mode 100644 index 0000000000..42dcc7179f --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json @@ -0,0 +1,7 @@ +{ + "key": "flag-the-proxy-serves-as-a-decimal", + "value": 101.25, + "reason": "TARGETING_MATCH", + "variant": "on", + "metadata": {} +} From d747a957e36a6439ba51d9a7bb541efb80a9b958 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 22:11:36 +0200 Subject: [PATCH 86/93] fix(go-feature-flag): announce a change to the evaluation context enrichment applyFlagConfiguration decided whether a polled configuration was a change by comparing the flags only. A new evaluationContextEnrichment with the same flags replaced the state and reached the engine, so evaluations could flip, yet no PROVIDER_CONFIGURATION_CHANGED was emitted (GOFF-EVT-002). The enrichment is now compared too, with null treated as empty since that is what a nil Go map marshals to. When it changes, every flag of the old and new configuration is reported in flagsChanged, as the enrichment can change the result of any of them. Signed-off-by: Thomas Poignant --- .../evaluator/InProcessEvaluator.java | 35 ++++++++++++++++++- .../evaluator/InProcessEvaluatorTest.java | 26 ++++++++++++++ .../gofeatureflag/util/GoffApiMock.java | 12 +++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index f215dc525a..fc662da818 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -598,7 +599,10 @@ private void applyFlagConfiguration(final FlagConfigResponse response) { return; } - val flagChanges = findFlagConfigurationChanges(current.flags, response.getFlags()); + val flagChanges = + enrichmentChanged(current.evaluationContextEnrichment, response.getEvaluationContextEnrichment()) + ? allFlagKeys(current.flags, response.getFlags()) + : findFlagConfigurationChanges(current.flags, response.getFlags()); this.state = new EvaluatorState( response.getFlags(), response.getEvaluationContextEnrichment(), @@ -618,6 +622,35 @@ private void applyFlagConfiguration(final FlagConfigResponse response) { this.emitter.accept(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, changeDetails); } + /** + * enrichmentChanged reports whether the evaluation context enrichment differs between two + * configurations. A null enrichment is the same as an empty one. + * + * @param original - enrichment currently in use + * @param updated - enrichment of the new configuration + * @return true if the enrichment has changed + */ + private static boolean enrichmentChanged(final Map original, final Map updated) { + return !Optional.ofNullable(original) + .orElse(Collections.emptyMap()) + .equals(Optional.ofNullable(updated).orElse(Collections.emptyMap())); + } + + /** + * allFlagKeys lists every flag of either configuration: a new enrichment can change the result of + * any of them. + * + * @param originalFlags - list of original flags + * @param newFlags - list of new flags + * @return the keys of every flag in either configuration + */ + private static List allFlagKeys( + final Map originalFlags, final Map newFlags) { + Set keys = new LinkedHashSet<>(newFlags.keySet()); + keys.addAll(originalFlags.keySet()); + return new ArrayList<>(keys); + } + /** * findFlagConfigurationChanges is a function that will find the flags that have changed. * diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java index 28f918d3ce..f28b87da26 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -653,6 +653,32 @@ void aDecimalFromTheRelayProxyShouldReportTypeMismatchOnTheIntegerResolver() { } } + @SneakyThrows + @DisplayName("an enrichment-only change should be announced as a configuration change") + @Test + void anEnrichmentOnlyChangeShouldBeAnnouncedAsAConfigurationChange() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENRICHMENT_CHANGES_AFTER_INIT).dispatcher); + val changes = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED) { + changes.add(details); + } + }); + + evaluator.initialize(new ImmutableContext()); + val before = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val after = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(false, before.getValue()); + assertEquals(true, after.getValue(), "the new enrichment should reach the engine"); + assertEquals(1, changes.size(), "the evaluations changed, but no configuration change was announced"); + assertEquals(List.of("TEST"), changes.get(0).getFlagsChanged()); + } + } + @SneakyThrows @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index f581049bf2..8fdd6e538e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -295,6 +295,17 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount % 2 == 0 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case ENRICHMENT_CHANGES_AFTER_INIT: + // the flags never change, only the enrichment the engine evaluates them with + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true, \"off\": false}," + + " \"targeting\": [{\"query\": \"env eq \\\"staging\\\"\", \"variation\": \"on\"}]," + + " \"defaultRule\": {\"variation\": \"off\"}}}," + + " \"evaluationContextEnrichment\": {\"env\": \"" + + (configurationCallCount > 1 ? "staging" : "production") + "\"}}") + .addHeader( + Const.HTTP_HEADER_ETAG, "\"enrichment-" + Math.min(configurationCallCount, 2) + "\""); case SIMPLE_CONFIG: configLocation = "valid-flag-config.json"; break; @@ -407,5 +418,6 @@ public enum MockMode { SCHEDULED_ROLLOUT_FLAG_CONFIG, EMPTY_FLAG_CONFIG, MISCONFIGURED_FLAGS, + ENRICHMENT_CHANGES_AFTER_INIT, } } From 58af45665843ab6020c391f7b01178b69bbb82f5 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 22:39:18 +0200 Subject: [PATCH 87/93] fix(go-feature-flag): contain every engine fault and rebuild the WASM pool An engine fault surfacing as a java.lang.Error, such as an OutOfMemoryError thrown by memory.grow inside the guest, escaped EvaluationWasm, which caught only Exception, and reached the application instead of degrading to GENERAL and the caller's default (GOFF-IP-013). The instance was not poisoned either, so free then ran on the aborted guest (GOFF-WASM-012). evaluateRaw now poisons the instance on any RuntimeException or Error from the guest, freeInput contains them the same way, and evaluate turns any Exception or Error into a GENERAL response. The pool could also lose instances for good. A rebuild on a thread interrupted during the faulting evaluation fails, because the module checks the interrupt flag while it starts, and a failed rebuild only logged that the pool had shrunk. Once every instance was lost, each later evaluation waited forever (GOFF-WASM-008). rebuild() now clears the interrupt flag while it runs and restores it afterwards, contains an Error as well, and counts an instance it could not build as missing. The next evaluation that finds the pool empty rebuilds it, and answers GENERAL rather than waiting if that fails too. Also includes the engine warm-up work: preWarmWasm runs a full throwaway evaluation with a targeting rule instead of a bare malloc/free, so the engine's lazy initialisation happens at start-up; free is called with the pointer only; and the README documents the per-instance memory cost and the -XX:-DontCompileHugeMethods option. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 7 +- .../gofeatureflag/wasm/EvaluationWasm.java | 38 ++++---- .../gofeatureflag/wasm/WasmEvaluatorPool.java | 62 ++++++++++-- .../wasm/WasmEvaluatorPoolTest.java | 94 +++++++++++++++++++ 4 files changed, 176 insertions(+), 25 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 67548410f6..86079d3977 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -81,7 +81,7 @@ You can configure the provider with several options to customize its behavior. T | **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | | **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| | **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | -| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | +| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Each instance holds about 2.3 MiB of memory once warm. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag The OpenFeature client is used to retrieve values for the current `EvaluationContext`. For example, retrieving a boolean value for the flag **"my-flag"**: @@ -121,6 +121,11 @@ The provider will call the GO Feature Flag relay-proxy API to fetch the flag con The `wasm` module is compiled into Java bytecode when the provider is built, so it ships as classes inside the provider jar. There is no `.wasm` file to locate at runtime, and repackaging the provider (shaded or fat jars) keeps the engine with it. The engine version is pinned by the provider release. +#### Performance +Some of the engine's functions compile to Java methods larger than the JVM's default JIT limit (`HugeMethodLimit`, 8000 bytes of bytecode), and HotSpot leaves such methods to the bytecode interpreter. This includes the evaluation path itself, so the engine runs about 3x slower than it could: roughly 350 µs instead of 110 µs per evaluation on an Apple M4 Pro with JDK 21. + +If this matters to you, start the JVM with `-XX:-DontCompileHugeMethods`. The flag is process-wide and lifts the limit for every class, not only the engine's. + ### Remote evaluation When the provider is configured to use remote evaluation, it will call the GO Feature Flag relay-proxy for each flag evaluation. diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index 8949125487..738ea28ec5 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -5,10 +5,8 @@ import com.dylibso.chicory.runtime.ImportValues; import com.dylibso.chicory.runtime.Instance; import com.dylibso.chicory.runtime.Memory; -import com.dylibso.chicory.runtime.WasmException; import com.dylibso.chicory.wasi.WasiOptions; import com.dylibso.chicory.wasi.WasiPreview1; -import com.dylibso.chicory.wasm.ChicoryException; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -36,6 +34,18 @@ public final class EvaluationWasm implements AutoCloseable { private final WasmGuestOutput stderr = new WasmGuestOutput(line -> log.error("evaluation engine: {}", line)); + /** + * A complete evaluation with a targeting rule, the shape of the canonical ABI vector. The rule + * matters: parsing its query is the bulk of what the engine initialises lazily on first use. + */ + private static final byte[] WARM_UP_INPUT = ("{\"flagKey\":\"warm-up\"," + + "\"flag\":{\"variations\":{\"enabled\":true,\"disabled\":false}," + + "\"targeting\":[{\"query\":\"targetingKey eq \\\"warm-up\\\"\",\"variation\":\"enabled\"}]," + + "\"defaultRule\":{\"variation\":\"disabled\"}}," + + "\"evalContext\":{\"targetingKey\":\"warm-up\"}," + + "\"flagContext\":{\"defaultSdkValue\":false}}") + .getBytes(StandardCharsets.UTF_8); + /** * poisoned is set when the guest faults. A trap does not unwind the module's shadow-stack * pointer, so the instance is permanently unusable and must never serve another evaluation. @@ -82,17 +92,12 @@ public void close() { } /** - * preWarmWasm is a function that is called to pre-warm the WASM module - * It calls the malloc function to allocate memory for the WASM module - * and then calls the free function to free the memory. + * preWarmWasm runs one throwaway evaluation so that the engine's lazy initialisation, and the + * loading of the classes it was compiled to, happen now rather than on the first real evaluation + * this instance serves. The answer is discarded; a fault propagates like any other. */ public void preWarmWasm() { - val message = "".getBytes(StandardCharsets.UTF_8); - Memory memory = this.instance.memory(); - int len = message.length; - int ptr = (int) malloc.apply(len)[0]; - memory.write(ptr, message); - this.free.apply(ptr, len); + evaluateRaw(WARM_UP_INPUT); } /** @@ -105,7 +110,7 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { try { val output = evaluateRaw(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); - } catch (Exception e) { + } catch (Exception | Error e) { return errorResponse(e); } } @@ -128,7 +133,8 @@ String evaluateRaw(final byte[] message) { int valuePosition = (int) ((resultPointer[0] >>> 32) & 0xFFFFFFFFL); int valueSize = (int) (resultPointer[0] & 0xFFFFFFFFL); return memory.readString(valuePosition, valueSize); - } catch (ChicoryException | WasmException e) { + } catch (RuntimeException | Error e) { + // anything escaping the guest, an OutOfMemoryError from memory.grow included, aborted it mid-call this.poisoned = true; throw e; } finally { @@ -148,14 +154,14 @@ private void freeInput(final int ptr, final int len) { return; } try { - this.free.apply(ptr, len); - } catch (ChicoryException | WasmException e) { + this.free.apply(ptr); + } catch (RuntimeException | Error e) { this.poisoned = true; log.error("failed to free the WASM input buffer, the instance will be discarded", e); } } - private GoFeatureFlagResponse errorResponse(final Exception e) { + private GoFeatureFlagResponse errorResponse(final Throwable e) { val response = new GoFeatureFlagResponse(); response.setErrorCode(ErrorCode.GENERAL.name()); response.setReason(Reason.ERROR.name()); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java index 6aea3eb461..a850ca0c8a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -8,6 +8,7 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -24,14 +25,16 @@ public final class WasmEvaluatorPool implements AutoCloseable { private final BlockingQueue pool; private final Supplier instanceFactory; + /** instances discarded after a fault whose replacement could not be built yet. */ + private final AtomicInteger missing = new AtomicInteger(); @Getter private volatile boolean closed; /** * Creates a pool of {@code size} independent EvaluationWasm instances. - * All instances are allocated eagerly so that first-call latency is - * absorbed in provider initialisation time. + * All instances are allocated and warmed up eagerly so that first-call latency is + * absorbed in provider initialisation time: each slot costs roughly 20 ms of warm-up. * * @param size number of WASM instances; must be >= 1 * @throws WasmFileNotFound if the embedded WASM module cannot be loaded @@ -72,6 +75,13 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { return errorResponse("WASM evaluator pool is closed"); } instance = pool.poll(CLOSED_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS); + if (instance == null && claimMissingInstance()) { + instance = rebuild(); + if (instance == null) { + missing.incrementAndGet(); + return errorResponse("no WASM instance is available and none could be rebuilt"); + } + } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -87,18 +97,17 @@ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { /** * returnToPool gives the instance back, replacing it first if the guest faulted while it was * serving. A trapped instance is permanently poisoned and must never be reused, so it is dropped - * and a fresh one takes its place; if the replacement cannot be built the pool simply shrinks, - * which is preferable to handing out a corrupted instance. + * and a fresh one takes its place; if the replacement cannot be built, an evaluation that finds + * the pool empty builds it later. */ private void returnToPool(final EvaluationWasm instance) { EvaluationWasm toReturn = instance; if (instance.isPoisoned()) { log.warn("discarding a WASM instance whose guest faulted, and rebuilding it"); closeQuietly(instance); - try { - toReturn = newInstance(); - } catch (Exception e) { - log.error("failed to rebuild a WASM instance, the evaluation pool has shrunk", e); + toReturn = rebuild(); + if (toReturn == null) { + missing.incrementAndGet(); return; } } @@ -112,6 +121,43 @@ private void returnToPool(final EvaluationWasm instance) { } } + /** + * rebuild builds a replacement instance. The interrupt flag is cleared while it runs, because + * the module checks it and would fail the build on a thread interrupted during the evaluation + * that poisoned the previous instance. + * + * @return the new instance, or null if it could not be built + */ + private EvaluationWasm rebuild() { + boolean interrupted = Thread.interrupted(); + try { + return newInstance(); + } catch (Exception | Error e) { + log.error("failed to rebuild a WASM instance", e); + return null; + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * claimMissingInstance takes responsibility for rebuilding one of the missing instances. + * + * @return true if an instance is missing and this caller must rebuild it + */ + private boolean claimMissingInstance() { + int current = missing.get(); + while (current > 0) { + if (missing.compareAndSet(current, current - 1)) { + return true; + } + current = missing.get(); + } + return false; + } + /** * close releases every instance the pool owns. An instance still serving an evaluation is not in * the queue, so returnToPool closes it as soon as it comes back rather than handing it to a pool diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java index 0915ebae89..5228ea5c16 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -116,6 +116,24 @@ void aHealthyInstanceShouldBeReturnedToThePoolUnchanged() { assertSame(healthy, factory.handedOut.get(0)); } + @SneakyThrows + @DisplayName("the warm-up should be an evaluation the engine accepts") + @Test + void theWarmUpShouldBeAnEvaluationTheEngineAccepts() { + val instance = new EvaluationWasm(); + val field = EvaluationWasm.class.getDeclaredField("WARM_UP_INPUT"); + field.setAccessible(true); + + // preWarmWasm discards the answer, so the input is replayed here to see what it was + instance.preWarmWasm(); + val answer = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + instance.evaluateRaw((byte[]) field.get(instance)), GoFeatureFlagResponse.class); + + assertFalse(instance.isPoisoned()); + assertEquals("", answer.getErrorCode(), answer.getErrorDetails()); + assertEquals(true, answer.getValue(), "the targeting rule should match, that is what warms the rule parser"); + } + @SneakyThrows @DisplayName("a real instance should not be poisoned by a successful evaluation") @Test @@ -276,6 +294,82 @@ void anEvaluationThatDidNotTrapShouldHaveItsInputBufferFreed() { assertEquals(1, freeCalls.get()); } + @SneakyThrows + @DisplayName("an Error raised by the guest should poison the instance and answer GENERAL") + @Test + void anErrorRaisedByTheGuestShouldPoisonTheInstanceAndAnswerGeneral() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + val field = EvaluationWasm.class.getDeclaredField("evaluate"); + field.setAccessible(true); + field.set(instance, (ExportFunction) args -> { + throw new OutOfMemoryError("Java heap space"); + }); + + val got = instance.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals("Java heap space", got.getErrorDetails()); + assertTrue(instance.isPoisoned(), "the guest was aborted mid-execution, its memory cannot be trusted"); + assertEquals(0, freeCalls.get(), "free was called on an aborted instance"); + } + + @SneakyThrows + @DisplayName("an instance poisoned on an interrupted thread should still be rebuilt") + @Test + void anInstancePoisonedOnAnInterruptedThreadShouldStillBeRebuilt() { + val interrupted = mock(EvaluationWasm.class); + when(interrupted.evaluate(any())).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + return new GoFeatureFlagResponse(); + }); + when(interrupted.isPoisoned()).thenReturn(true); + val instances = new ArrayList>(List.of(() -> interrupted, EvaluationWasm::new)); + + val pool = new WasmEvaluatorPool(1, () -> instances.remove(0).get()); + val executor = Executors.newSingleThreadExecutor(); + try { + val callerKeptItsInterrupt = executor.submit(() -> { + pool.evaluate(input().value); + return Thread.interrupted(); + }) + .get(10, TimeUnit.SECONDS); + val next = executor.submit(() -> pool.evaluate(input().value)).get(10, TimeUnit.SECONDS); + + assertTrue(callerKeptItsInterrupt, "the caller's interrupt should be kept"); + assertEquals(true, next.getValue(), "the rebuild failed on the interrupted thread"); + } finally { + executor.shutdownNow(); + pool.close(); + } + } + + @SneakyThrows + @DisplayName("a failed rebuild should neither escape nor leave the pool without an instance") + @Test + void aFailedRebuildShouldNeitherEscapeNorLeaveThePoolWithoutAnInstance() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val healthy = instanceThatIsPoisonedAfterEvaluating(false); + val instances = new ArrayList>(List.of( + () -> trapped, + () -> { + throw new OutOfMemoryError("Java heap space"); + }, + () -> healthy)); + val pool = new WasmEvaluatorPool(1, () -> instances.remove(0).get()); + val executor = Executors.newSingleThreadExecutor(); + try { + pool.evaluate(input().value); + executor.submit(() -> pool.evaluate(input().value)).get(2, TimeUnit.SECONDS); + + verify(healthy).evaluate(any()); + } finally { + executor.shutdownNow(); + pool.close(); + } + } + @SneakyThrows @DisplayName("a real trap should leave the pool able to serve the next evaluation") @Test From bfe603d9ecf5f7d26d9ffc164dae284d20b9891d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 28 Sep 2026 22:48:34 +0200 Subject: [PATCH 88/93] fix(go-feature-flag): never post to the data collector on the evaluating thread When the buffer reached maxPendingEvents, add() published it synchronously on the calling thread, which is usually an evaluation hook or track(), and did so while holding publishLock across the HTTP call. An unreachable data collector therefore stalled every flag evaluation that filled the buffer for up to the configured timeout (GOFF-COLL-017). add() now hands the flush of a full buffer to the publisher's scheduler thread and returns at once. Requests made while one is already queued are merged, so an outage does not queue a task per event. Single-flight uses a non-blocking AtomicBoolean instead of a lock, and shutdown stops the scheduler, letting a publish in progress finish, before draining what is left. A full buffer is now usually posted together with the event that filled it, so the provider tests that counted exactly two collector requests now check that a full buffer is flushed before the interval instead. The buffer cap test lets the flushes triggered by its add loop finish before re-enabling the collector. Signed-off-by: Thomas Poignant --- .../service/EventsPublisher.java | 51 +++++++++++++------ .../ProviderDataCollectorTest.java | 15 ++++-- .../gofeatureflag/ProviderTrackingTest.java | 15 ++++-- .../service/EventsPublisherTest.java | 41 ++++++++++++++- 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index ea69d107c9..d74ec8a340 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -7,12 +7,12 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; @@ -28,7 +28,10 @@ public final class EventsPublisher { public final AtomicBoolean isShutdown = new AtomicBoolean(false); private final int maxPendingEvents; private final Consumer> publisher; - private final Lock publishLock = new ReentrantLock(); + /** true while a batch is being posted, so that a second publish skips rather than overlapping it. */ + private final AtomicBoolean publishing = new AtomicBoolean(false); + /** true while a flush of a full buffer is queued on the scheduler and has not started yet. */ + private final AtomicBoolean flushRequested = new AtomicBoolean(false); private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private final Lock readLock = readWriteLock.readLock(); @@ -36,7 +39,7 @@ public final class EventsPublisher { private final long flushIntervalMs; private final List eventsList; - private ScheduledExecutorService scheduledExecutorService; + private volatile ScheduledExecutorService scheduledExecutorService; /** * Constructor. @@ -91,8 +94,7 @@ public void add(T event) { } if (shouldPublish) { - log.warn("events collection is full. Publishing before adding new events."); - publish(); + requestFlush(); } try { @@ -106,6 +108,27 @@ public void add(T event) { } } + /** + * requestFlush has the scheduler's thread publish the buffer, so the thread adding an event, often + * one evaluating a flag, never waits for the data collector. Requests made while one is already + * queued are merged into it. + */ + private void requestFlush() { + if (!flushRequested.compareAndSet(false, true)) { + return; + } + log.warn("events collection is full, publishing it"); + try { + scheduledExecutorService.execute(() -> { + flushRequested.set(false); + publish(); + }); + } catch (RejectedExecutionException e) { + flushRequested.set(false); + log.debug("the publisher is shutting down, the final drain will publish the buffer"); + } + } + /** * discardOverflow keeps the buffer within twice maxPendingEvents, dropping the oldest events * first. Without a cap a data collector outage is an unbounded memory leak, and the oldest @@ -127,14 +150,14 @@ private void discardOverflow() { * @return count of publish events */ public int publish() { - if (!publishLock.tryLock()) { + if (!publishing.compareAndSet(false, true)) { log.debug("a publish is already in progress, skipping this one"); return 0; } try { return drainAndPost(); } finally { - publishLock.unlock(); + publishing.set(false); } } @@ -143,7 +166,7 @@ public int publish() { * data collector's availability cannot hold up an evaluation. A batch that fails to publish goes * back to the head of the buffer, keeping the events in chronological order. * - *

Callers must hold {@link #publishLock}.

+ *

Callers must have set {@link #publishing}, or have stopped the scheduler.

*/ private int drainAndPost() { List batch; @@ -176,18 +199,16 @@ private int drainAndPost() { } } - /** Shutdown: stop accepting events, drain what is buffered and stop the scheduler. */ + /** + * Shutdown: stop accepting events, stop the scheduler, letting a publish in progress finish, then + * drain what is buffered. + */ public synchronized void shutdown() { log.info("shutdown, draining remaining events"); isShutdown.set(true); - publishLock.lock(); - try { - drainAndPost(); - } finally { - publishLock.unlock(); - } if (scheduledExecutorService != null) { ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); } + drainAndPost(); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java index f084fca0d8..91c2c067a1 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -21,12 +22,12 @@ @DisplayName("Provider data collector") class ProviderDataCollectorTest extends AbstractGoFeatureFlagProviderTest { - @DisplayName("Should omit events if max pending events is reached") + @DisplayName("Should flush before the interval once max pending events is reached") @SneakyThrows @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { + void shouldFlushBeforeTheIntervalOnceMaxPendingEventsIsReached() { GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) + .flushIntervalMs(60_000L) .maxPendingEvents(1) .endpoint(baseUrl.toString()) .evaluationType(EvaluationType.IN_PROCESS) @@ -35,8 +36,12 @@ void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { val client = OpenFeatureAPI.getInstance().getClient(testName); client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); + for (int i = 0; i < 200 && goffAPIMock.getCollectorRequestsHistory().isEmpty(); i++) { + Thread.sleep(10L); + } + assertFalse( + goffAPIMock.getCollectorRequestsHistory().isEmpty(), + "a full buffer should be flushed without waiting for the interval"); } @DisplayName("Should not send evaluation event if flag has tracking disabled") diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java index 18637e06a3..c5e081db21 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; @@ -122,12 +123,12 @@ void shouldRecordNoTrackingEventWhenDataCollectionIsDisabled(EvaluationType type "a tracking event was sent although data collection is disabled"); } - @DisplayName("Should omit events if max pending events is reached") + @DisplayName("Should flush before the interval once max pending events is reached") @SneakyThrows @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { + void shouldFlushBeforeTheIntervalOnceMaxPendingEventsIsReached() { GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) + .flushIntervalMs(60_000L) .maxPendingEvents(1) .endpoint(baseUrl.toString()) .evaluationType(EvaluationType.IN_PROCESS) @@ -142,7 +143,11 @@ void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { "my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails().add("revenue", 567).add("user_id", "123ABC")); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); + for (int i = 0; i < 200 && goffAPIMock.getCollectorRequestsHistory().isEmpty(); i++) { + Thread.sleep(10L); + } + assertFalse( + goffAPIMock.getCollectorRequestsHistory().isEmpty(), + "a full buffer should be flushed without waiting for the interval"); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java index 7a3d8d8e59..00644a0cad 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -111,6 +111,42 @@ void addingAnEventShouldNotWaitForAnInFlightPublish() { publisher.shutdown(); } + @SneakyThrows + @DisplayName("a full buffer should be flushed off the thread adding the event") + @Test + void aFullBufferShouldBeFlushedOffTheThreadAddingTheEvent() { + val postingThreads = new CopyOnWriteArrayList(); + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + postingThreads.add(Thread.currentThread()); + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, + 60_000L, + 1); + + val added = new CountDownLatch(1); + val evaluatingThread = new Thread(() -> { + publisher.add("first"); + publisher.add("fills-the-buffer"); + added.countDown(); + }); + evaluatingThread.start(); + + assertTrue(added.await(2, TimeUnit.SECONDS), "add() waited on the data collector"); + assertTrue(posting.await(2, TimeUnit.SECONDS), "a full buffer should still trigger a flush"); + assertFalse(postingThreads.contains(evaluatingThread), "the flush ran on the thread adding the event"); + + releasePost.countDown(); + publisher.shutdown(); + } + @SneakyThrows @DisplayName("publishing should be single flight") @Test @@ -195,14 +231,15 @@ void theBufferShouldBeCappedAtTwiceMaxPendingEventsDiscardingTheOldest() { for (int i = 0; i < 40; i++) { publisher.add("event-" + i); } + // the flushes a full buffer triggers run on the scheduler: let them fail and re-queue first + Thread.sleep(FLUSH_INTERVAL_MS * 2); collectorIsDown.set(false); - publisher.publish(); + publisher.shutdown(); val delivered = attempts.get(attempts.size() - 1); assertEquals(2 * maxPendingEvents, delivered.size(), "the buffer grew past twice maxPendingEvents"); assertEquals("event-39", delivered.get(delivered.size() - 1), "the newest event should be kept"); assertEquals("event-32", delivered.get(0), "the oldest events should be the ones discarded"); - publisher.shutdown(); } } From 835221725abb3e67345dc1f0194e395527a04202 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 29 Sep 2026 09:39:45 +0200 Subject: [PATCH 89/93] fix(go-feature-flag): stop recording a mistyped relay proxy answer twice Since the fallback keeps a TYPE_MISMATCH from the relay proxy, such a result carries the evaluated-remotely marker and an error code. The SDK sends any result with an error code to the error stage, and error() only receives the exception, so it could not see the marker and recorded a feature event the relay proxy had already recorded (GOFF-FALLBACK-006). Failed evaluations are now recorded in finallyAfter, which receives the evaluation details and their flag metadata, and a result the relay proxy produced is skipped there as it already is in after(). Signed-off-by: Thomas Poignant --- .../gofeatureflag/hook/DataCollectorHook.java | 16 +++++++----- .../ProviderDataCollectorTest.java | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 59a8064b74..c04766e390 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -60,9 +60,15 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { eventsPublisher.add(event); } + /** + * finallyAfter records a failed evaluation. It stands in for the error stage, which only sees the + * exception and so cannot tell that the relay proxy produced the result. + */ @Override - public void error(HookContext ctx, Exception error, Map hints) { - if (!this.evaluator.isFlagTrackable(ctx.getFlagKey())) { + public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (details.getErrorCode() == null + || wasEvaluatedRemotely(details) + || !this.evaluator.isFlagTrackable(ctx.getFlagKey())) { return; } @@ -82,10 +88,8 @@ public void error(HookContext ctx, Exception error, Map hints) { /** * wasEvaluatedRemotely reports whether this result came from the relay proxy rather than the - * local engine. - * - *

Only the after stage consults it. The error stage is reached when the fallback failed as - * well, and a relay proxy that could not answer recorded nothing to duplicate.

+ * local engine. The relay proxy recorded such a result itself, so recording it here too would + * count it twice. * * @param details - result the SDK is about to hand to the caller * @return true if the relay proxy produced this result diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java index 91c2c067a1..f7487b1cb8 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java @@ -86,6 +86,32 @@ void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluated() { } } + @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated with the wrong type") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluatedWithTheWrongType() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getIntegerDetails("flag-with-a-broken-query", 1, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals( + ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + assertEquals(0, mock.getCollectorRequestsHistory().size()); + } + } + @DisplayName("Should send an evaluation event for a flag the engine evaluated") @SneakyThrows @Test From 89ba11a8a1c799d6da8bcacc8f7062faac382870 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 29 Sep 2026 09:44:39 +0200 Subject: [PATCH 90/93] fix(go-feature-flag): keep the events buffer cap from overflowing discardOverflow computed twice maxPendingEvents as an int, so any value above 2^30, such as Integer.MAX_VALUE to never flush by size, wrapped to a negative cap. The first event then made subList throw IndexOutOfBoundsException from add(), and since the SDK does not contain after hooks, every trackable evaluation returned the caller's default with GENERAL and track() threw. The cap is now computed as a long, so every positive maxPendingEvents stays valid. Signed-off-by: Thomas Poignant --- .../gofeatureflag/service/EventsPublisher.java | 4 ++-- .../gofeatureflag/service/EventsPublisherTest.java | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index d74ec8a340..42b1967968 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -137,10 +137,10 @@ private void requestFlush() { *

Callers must hold {@link #writeLock}.

*/ private void discardOverflow() { - int overflow = eventsList.size() - (2 * maxPendingEvents); + long overflow = eventsList.size() - (2L * maxPendingEvents); if (overflow > 0) { log.warn("events buffer is full, discarding the {} oldest events", overflow); - eventsList.subList(0, overflow).clear(); + eventsList.subList(0, (int) overflow).clear(); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java index 00644a0cad..3d88be8c45 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -242,4 +242,17 @@ void theBufferShouldBeCappedAtTwiceMaxPendingEventsDiscardingTheOldest() { assertEquals("event-39", delivered.get(delivered.size() - 1), "the newest event should be kept"); assertEquals("event-32", delivered.get(0), "the oldest events should be the ones discarded"); } + + @SneakyThrows + @DisplayName("the largest maxPendingEvents should still accept events") + @Test + void theLargestMaxPendingEventsShouldStillAcceptEvents() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, Integer.MAX_VALUE); + + publisher.add("event"); + publisher.shutdown(); + + assertEquals(List.of("event"), published, "twice maxPendingEvents overflowed the buffer cap"); + } } From df76995e344f36b3f56bf4843ffe689385dbcd57 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 29 Sep 2026 09:51:49 +0200 Subject: [PATCH 91/93] fix(go-feature-flag): suppress the polling jitter warning inside the module The PREDICTABLE_RANDOM exclusion for the polling jitter was added to the repository-wide spotbugs-exclusions.xml, a file this provider's changes should not reach. The suppression now sits on nextPollDelayMs as a @SuppressFBWarnings annotation, and the shared file is back to its state on main. Signed-off-by: Thomas Poignant --- .../gofeatureflag/evaluator/InProcessEvaluator.java | 2 ++ spotbugs-exclusions.xml | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index fc662da818..f81132f0ee 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -22,6 +22,7 @@ import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.ExceptionUtils; import dev.openfeature.sdk.exceptions.TypeMismatchError; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -522,6 +523,7 @@ private Optional refreshFlagConfiguration() { * @param pollingIntervalMs - the configured polling interval * @return the interval, randomly shortened or lengthened by up to {@link Const#POLLING_JITTER_RATIO} */ + @SuppressFBWarnings(value = "PREDICTABLE_RANDOM", justification = "the poll jitter is not security-relevant") static long nextPollDelayMs(final long pollingIntervalMs) { return (long) (pollingIntervalMs * ThreadLocalRandom.current() diff --git a/spotbugs-exclusions.xml b/spotbugs-exclusions.xml index 9bfb12b6d9..7f1d7ae129 100644 --- a/spotbugs-exclusions.xml +++ b/spotbugs-exclusions.xml @@ -17,13 +17,6 @@ - - - - - - - From 0a25072581c65f5bc4556529eb34928c8fae4ce3 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 29 Sep 2026 10:04:13 +0200 Subject: [PATCH 92/93] fix(go-feature-flag): reject a flag configuration with trailing tokens Jackson stops reading once it has a complete JSON value, so a 200 whose body was cut or garbled after one, such as {"flags":{}}}, was accepted as an empty configuration. That wiped every flag and advanced the ETag, and the following polls then answered 304 and kept the empty state (GOFF-IP-008). The flag configuration is now read with FAIL_ON_TRAILING_TOKENS, so such a body is a failed refresh and the configuration in hand is kept. The feature is enabled on that read alone, since the shared mapper also reads the evaluation engine's output. Signed-off-by: Thomas Poignant --- .../gofeatureflag/api/GoFeatureFlagApi.java | 8 +++++++- .../gofeatureflag/api/GoFeatureFlagApiTest.java | 14 ++++++++++++++ .../providers/gofeatureflag/util/GoffApiMock.java | 6 ++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index f06ab19dc0..1ee692f25f 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.providers.gofeatureflag.api; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; @@ -193,7 +194,12 @@ public void sendEventToDataCollector(final List eventsList, final Map response, final String body) throws JsonProcessingException { - val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, FlagConfigApiResponse.class); + // without FAIL_ON_TRAILING_TOKENS, a body cut or garbled after a complete JSON value would + // be read as that value, and {"flags":{}}} would wipe every flag and advance the ETag. + final FlagConfigApiResponse goffResp = Const.DESERIALIZE_OBJECT_MAPPER + .readerFor(FlagConfigApiResponse.class) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readValue(body); // A 200 that decodes to no flag map is a failed refresh, not an empty configuration: // accepting it would wipe every flag and advance the ETag, making the empty state permanent. diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index d297b54e8d..961eb9982e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -577,6 +577,20 @@ public void a200WithANullFlagMapShouldBeAFailedRefresh() { () -> api.retrieveFlagConfiguration("null-flags", Collections.emptyList())); } + @SneakyThrows + @DisplayName("a 200 with trailing tokens should be a failed refresh") + @Test + public void a200WithTrailingTokensShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("trailing-tokens", Collections.emptyList())); + } + @SneakyThrows @DisplayName("a 200 whose body is the json literal null should be a failed refresh") @Test diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 8fdd6e538e..2c75b15c45 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -358,6 +358,12 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { .setBody("{\"flags\": null, \"evaluationContextEnrichment\": {\"env\": \"production\"}}") .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\"") .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "trailing-tokens": + // a 200 whose valid, empty flag map is followed by a stray token + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\""); case "null-body": // a 200 whose body is the JSON literal null return new MockResponse() From 0207adb2fabbada989814685f7c05dce1f14e97b Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 29 Sep 2026 10:19:07 +0200 Subject: [PATCH 93/93] docs(go-feature-flag): describe maxPendingEvents and polling as they behave maxPendingEvents was documented as dropping a new event once the buffer is full. A full buffer is in fact published without waiting for flushIntervalMs, and when events cannot be published at most twice maxPendingEvents are kept, the oldest dropped first. The flagChangePollingIntervalMs Javadoc still described a cache that no longer exists, and the README had no row for dataCollectorBaseUrl. Signed-off-by: Thomas Poignant --- providers/go-feature-flag/README.md | 5 +++-- .../gofeatureflag/GoFeatureFlagProviderOptions.java | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index 86079d3977..a936e52706 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -74,13 +74,14 @@ You can configure the provider with several options to customize its behavior. T | **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.
  • If you want to have a local evaluation, you should use IN_PROCESS.
  • If you want to have an evaluation on the relay-proxy directly, you should use REMOTE.
Default: IN_PROCESS
| | **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | | **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | +| **`dataCollectorBaseUrl`** | `false` | base URL used to publish the evaluation and tracking events, when the data collector is not served by the relay proxy itself. It replaces the whole base of the collector route, scheme, host, port and path prefix included; the flag configuration and the evaluations keep using `endpoint`. _(default: `endpoint`)_ | | **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseUrl`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | | **`flushIntervalMs`** | `false` | interval time in millisecond we publish the collected evaluation and tracking events to the data collector. _(default: `60000` (1 minute))_ | -| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | +| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. Once that many events are pending they are published without waiting for `flushIntervalMs`. If they cannot be published, at most twice that many are kept and the oldest are dropped. _(default: `10000`)_ | | **`disableDataCollection`** | `false` | set to true if you don't want to send the evaluation and tracking events to the data collector. _(default: `false`)_ | | **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | | **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| -| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | +| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if the flag configuration it holds should be refreshed. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | | **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Each instance holds about 2.3 MiB of memory once warm. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index fb07ca9582..187515bfb6 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -75,7 +75,8 @@ public class GoFeatureFlagProviderOptions { private Long flushIntervalMs; /** * (optional) max pending events aggregated before publishing for collection data to the proxy. - * When an event is added while an events collection is full, the event is omitted. default: 10000 + * Once that many events are pending they are published without waiting for flushIntervalMs. If + * they cannot be published, at most twice that many are kept and the oldest are dropped. default: 10000 */ private Integer maxPendingEvents; /** @@ -100,9 +101,9 @@ public class GoFeatureFlagProviderOptions { private List evaluationFlagList; /** - * (optional) interval time we poll the proxy to check if the configuration has changed. If the - * cache is enabled, we will poll the relay-proxy every X milliseconds to check if the - * configuration has changed. default: 120000 + * (optional) interval time in millisecond we poll the relay proxy to check if the flag + * configuration has changed, for in process evaluation. Each poll is randomly shortened or + * lengthened by up to 10%. default: 120000 */ private Long flagChangePollingIntervalMs;