Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions OpenFeature.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ added to the `customData` property of the `DevCycleUser`.

DevCycle allows the following data types for custom data values: **boolean**, **integer**, **double**, **float**, and **String**. Other data types will be ignored

### Provider Events

The provider emits [OpenFeature provider events](https://openfeature.dev/specification/sections/events), so
applications can react to configuration changes and to the provider losing its connection to DevCycle.

```java
Client openFeatureClient = api.getClient();

openFeatureClient.onProviderConfigurationChanged(details ->
System.out.println("DevCycle config updated, ETag " + details.getEventMetadata().getString("configETag")));
openFeatureClient.onProviderStale(details ->
System.out.println("DevCycle config could not be refreshed: " + details.getMessage()));
```

| Event | When it is emitted |
| --- | --- |
| `PROVIDER_READY` | The DevCycle client has loaded a configuration. Also emitted when config fetching recovers after a failure. |
| `PROVIDER_CONFIGURATION_CHANGED` | A newly fetched configuration differs from the one previously in use, either from polling or from a realtime update. |
| `PROVIDER_STALE` | A configuration fetch failed while a previously fetched configuration is still being served. Evaluations continue against that cached configuration. |
| `PROVIDER_ERROR` | A configuration fetch failed and no configuration has ever been loaded. Reported with error code `PROVIDER_FATAL` when the SDK key is unauthorized, which means the provider will not recover. |

`PROVIDER_CONFIGURATION_CHANGED` does not include a `flagsChanged` list. DevCycle resolves variables per-user at
evaluation time, so the set of variable keys whose value actually changed for a given user is not known when the
configuration is fetched.

Only the Local Bucketing client (`DevCycleLocalClient`) holds a configuration, so configuration change, stale, and
error events apply to it. The Cloud Bucketing client (`DevCycleCloudClient`) evaluates against the DevCycle API on
every request and becomes ready immediately.

### JSON Flag Limitations

The OpenFeature spec for JSON flags allows for any type of valid JSON value to be set as the flag value.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ public synchronized FeatureProvider getOpenFeatureProvider() {
localBucketing.setPlatformData(platformData.toString());
if (openFeatureProvider == null) {
openFeatureProvider = new DevCycleProvider(this);
// the provider listens for config updates itself so it can emit provider events
configManager.addConfigUpdateListener(openFeatureProvider);
}
return openFeatureProvider;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.devcycle.sdk.server.local.managers;

import com.devcycle.sdk.server.common.exception.DevCycleException;

/**
* Callback for the lifecycle of the locally cached project configuration.
* <p>
* Implementations are invoked on the config polling thread, or on the SSE message thread when a
* realtime update triggers a refetch, and must not block.
*/
public interface ConfigUpdateListener {

/**
* A config fetch completed successfully.
*
* @param etag ETag of the config now in use
* @param firstLoad true if this is the first config that has been loaded
* @param changed true if the fetched config differs from the previously stored one
*/
void onConfigLoaded(String etag, boolean firstLoad, boolean changed);

/**
* A config fetch failed. A previously fetched config, if any, remains in use.
*
* @param error the failure
* @param fatal true if the failure is unrecoverable, ie. the SDK key is unauthorized
*/
void onConfigError(DevCycleException error, boolean fatal);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@
import java.net.URISyntaxException;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

public final class EnvironmentConfigManager {
private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper();
Expand All @@ -38,6 +42,16 @@ public final class EnvironmentConfigManager {
private boolean isSSEConnected = false;
private final DevCycleLocalOptions options;

/**
* Copy-on-write so the polling and SSE threads can iterate while a listener is being added.
*/
private final List<ConfigUpdateListener> configUpdateListeners = new CopyOnWriteArrayList<>();

/**
* Retained so a listener registered after an unrecoverable failure still learns about it.
*/
private volatile DevCycleException fatalConfigError;

private ProjectConfig config;
private String configETag = "";
private String configLastModified = "";
Expand Down Expand Up @@ -75,19 +89,28 @@ public void run() {
}
} catch (DevCycleException e) {
DevCycleLogger.error("Failed to load config: " + e.getMessage(), e);
}
notifyConfigError(e);
}
}
};

public boolean isConfigInitialized() {
return config != null;
}

private ProjectConfig getConfig() throws DevCycleException {
private ProjectConfig getConfig() throws DevCycleException {
boolean firstLoad = this.config == null;
String previousETag = this.configETag;

Call<ProjectConfig> config = this.configApiClient.getConfig(this.sdkKey, this.configETag, this.configLastModified);
ProjectConfig fetchedConfig = getResponseWithRetries(config, 1);
this.config = fetchedConfig;


if (this.config != null) {
// a 304, or a config older than the one already stored, leaves the ETag untouched
notifyConfigLoaded(firstLoad, !Objects.equals(previousETag, this.configETag));
}

if (!this.options.isDisableRealtimeUpdates() && this.config != null && this.config.getSse() != null) {
try {
URI uri = new URI(this.config.getSse().getHostname() + this.config.getSse().getPath());
Expand All @@ -102,6 +125,45 @@ private ProjectConfig getConfig() throws DevCycleException {
return this.config;
}

/**
* Register a listener for the config lifecycle. If the config has already failed
* unrecoverably, the listener is told immediately rather than waiting for the next attempt.
*/
public void addConfigUpdateListener(ConfigUpdateListener listener) {
configUpdateListeners.add(listener);

DevCycleException fatal = fatalConfigError;
if (fatal != null) {
notifyListener(listener, l -> l.onConfigError(fatal, true));
}
}

private void notifyConfigLoaded(boolean firstLoad, boolean changed) {
for (ConfigUpdateListener listener : configUpdateListeners) {
notifyListener(listener, l -> l.onConfigLoaded(this.configETag, firstLoad, changed));
}
}

private void notifyConfigError(DevCycleException error) {
HttpResponseCode responseCode = error.getHttpResponseCode();
boolean fatal = responseCode == HttpResponseCode.UNAUTHORIZED || responseCode == HttpResponseCode.FORBIDDEN;
if (fatal) {
fatalConfigError = error;
}

for (ConfigUpdateListener listener : configUpdateListeners) {
notifyListener(listener, l -> l.onConfigError(error, fatal));
}
}

private void notifyListener(ConfigUpdateListener listener, Consumer<ConfigUpdateListener> notification) {
try {
notification.accept(listener);
} catch (Exception e) {
DevCycleLogger.warning("Config update listener threw an exception: " + e.getMessage());
}
}

private Void handleSSEMessage(MessageEvent messageEvent) {
DevCycleLogger.debug("Received message: " + messageEvent.getData());
if (!isSSEConnected)
Expand Down
Loading
Loading