Wiring a provider¶
A FeatureFlags layer wraps one OpenFeature provider: the component that actually holds
your flags, whether that is a vendor SDK, a config file or an HTTP service. This page shows how to build that layer,
configure it, combine providers and replace them at runtime.
Use any OpenFeature provider¶
Pass the provider to FeatureFlags.fromProvider, and provide the layer to your program:
// Any OpenFeature Java provider, e.g. `new FlagdProvider()` from
// "dev.openfeature.contrib.providers" % "flagd"
def run(provider: FeatureProvider): ZIO[Any, Throwable | FeatureFlagError, Boolean] =
program.provide(FeatureFlags.fromProvider(provider))
Any provider written for the OpenFeature Java SDK works. Some that are ready to use:
| Provider | Dependency |
|---|---|
| HOCON, environment variables, caching, circuit breaker | "io.github.etacassiopeia" %% "zio-openfeature-extras" % "<version>", see Extras |
| OFREP (remote evaluation over HTTP) | "io.github.etacassiopeia" %% "zio-openfeature-ofrep" % "<version>", see OFREP |
| Optimizely | "io.github.etacassiopeia" %% "zio-openfeature-optimizely" % "<version>", see Optimizely |
| flagd | "dev.openfeature.contrib.providers" % "flagd" % "<version>" |
| LaunchDarkly | "dev.openfeature.contrib.providers" % "launchdarkly" % "<version>" |
| Flagsmith | "dev.openfeature.contrib.providers" % "flagsmith" % "<version>" |
| Flipt | "dev.openfeature.contrib.providers" % "flipt" % "<version>" |
| Unleash | "dev.openfeature.contrib.providers" % "unleash" % "<version>" |
| ConfigCat | "dev.openfeature.contrib.providers" % "configcat" % "<version>" |
| GO Feature Flag | "dev.openfeature.contrib.providers" % "go-feature-flag" % "<version>" |
The OpenFeature ecosystem
lists the rest, and Maven Central has
their current versions. For tests, use the in-memory TestFeatureProvider from the testkit.
Pick a factory¶
| You want | Use |
|---|---|
| Start the provider before the app starts, and fail at startup if it cannot | FeatureFlags.fromProvider(provider) |
| Start the app at once, and let the provider become ready in the background | FeatureFlags.fromProviderAsync(provider) |
| A domain, hooks, timeouts, or any other setting | FeatureFlags.fromProvider(provider, FeatureFlagsConfig(...)) |
| Several providers asked in order | FeatureFlags.multiProvider(providers), then any factory above |
| Serve a fallback while a slow provider is built | FeatureFlags.fromAcquireAsync |
| One client per domain, created on demand | FeatureFlagRegistry.fromProvider |
// Waits for the provider to be ready; a provider that cannot start fails the layer
def blocking(provider: FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProvider(provider)
// Returns at once; evaluations fail with ProviderNotReady until the provider is ready
def nonBlocking(provider: FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProviderAsync(provider)
Every factory returns a layer that owns its scope, so you provide it directly with no Scope. When the layer is
released, the instance shuts down.
Configure it with FeatureFlagsConfig¶
fromProvider(provider, config) is the one factory with every setting. FeatureFlagsConfig() with no changes is
exactly fromProvider(provider).
def configured(provider: FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProvider(
provider,
FeatureFlagsConfig()
.withDomain("checkout") // a named client
.withVersion("2.4.0") // reported in clientMetadata
.withHook(FeatureHook.logging()) // client-level hooks from the start
.withEvaluationTimeout(300.millis) // bound every evaluation (default 1 second)
.withInitTimeout(10.seconds) // bound initialization (default 30 seconds)
.withAsyncInit // do not block the layer build
.withFallbackLogging(FallbackLogging.Off)
)
| Field | Default | Setter | What it does |
|---|---|---|---|
domain |
none | withDomain(d) |
Binds the client to a named domain. |
version |
none | withVersion(v) |
Client version, reported by clientMetadata and passed to the SDK client. |
initialHooks |
none | withHooks(list), withHook(h) |
Client-level hooks installed from the start. |
evaluationTimeout |
1 second | withEvaluationTimeout(d), withoutEvaluationTimeout |
Bounds each evaluation. On timeout the call fails with ProviderError wrapping a TimeoutException; the provider call keeps running in the background, holding a blocking-pool thread until it returns (see maxAbandonedCalls). Per-call override: see Evaluating flags. |
initTimeout |
30 seconds | withInitTimeout(d) |
Bounds initialization, see Sync or async start. |
initMode |
InitMode.Sync |
withAsyncInit, withSyncInit |
Sync or async start. |
apiOwnership |
ApiOwnership.Auto |
withApiOwnership(o) |
Whether shutdown also stops the OpenFeature API, see Who shuts down the API. |
contextSource |
none | withContextSource(s) |
Ambient context read on every evaluation, see Evaluation context. |
fallbackLogging |
one line per flag per minute | withFallbackLogging(p) |
How *OrDefault calls log a served default: FallbackLogging.Off, Always or Throttled(window). See Evaluating flags. |
maxAbandonedCalls |
256 | withMaxAbandonedCalls(n) |
Cap on timed-out provider calls still running. At the cap, timed evaluations fail fast with ProviderError wrapping a RejectedExecutionException instead of parking another thread; Int.MaxValue disables it. Must be at least 1. See Evaluating flags. |
Sync or async start¶
Providers usually need a moment to start: open a connection, download their rules. The layer can wait for that, or not.
Sync (InitMode.Sync, the default; fromProvider). Building the layer waits until the provider is Ready or
Stale. If that takes longer than initTimeout, the layer fails with a TimeoutException. If the provider ends up in
any other state, the layer fails with an IllegalStateException. Either way the provider is shut down, and a wrong SDK
key or an unreachable service stops the app at startup instead of serving defaults. Closing the scope after a timeout
does not wait for a stuck initialize(), and the scope-close api.shutdown() is itself capped at 15 seconds.
Async (InitMode.Async; fromProviderAsync). The layer is ready at once. Until the provider reports Ready,
evaluations fail with ProviderNotReady. If the provider never gets there within initTimeout, the status becomes
Fatal: the provider is shut down, an Error event with code ProviderFatal is published, and evaluations fail with
ProviderFatal. A provider that was ready once and later reports an error is never moved to Fatal this way.
Use sync when serving a default is worse than not starting. Use async to start fast, and gate traffic on readiness or answer with the default meanwhile. To start fast and answer with real values from the first request, put a local provider next to the remote one (multi-provider) or start on a fallback.
Lifecycle and events has the full set of statuses and what moves between them.
Who shuts down the API¶
Every instance talks to an OpenFeature API object. Shutting the API down stops every provider registered on it.
apiOwnership says whether this instance may do that when it shuts down:
apiOwnership |
On shutdown |
|---|---|
ApiOwnership.Auto (default) |
Owned without a domain, Shared with one. |
ApiOwnership.Owned |
Shuts down the API and its providers. |
ApiOwnership.Shared |
Leaves the API and its provider running. Other clients on it keep working. |
The default fits the usual setups: an app with one unnamed client owns the API, while named domains usually share it.
Warning
A domain client made with fromProvider(provider, FeatureFlagsConfig().withDomain(d)) uses the process-wide
API, which nothing else owns. With the default Shared ownership, its provider is never shut down. Use
withApiOwnership(ApiOwnership.Owned), or a FeatureFlagRegistry,
for providers that hold threads or connections.
Ask several providers in order¶
FeatureFlags.multiProvider combines providers into one, using the Java SDK's MultiProvider. Pass the result to any
factory:
// Ask `overrides` first; a flag it does not hold falls through to `remote`
def layered(overrides: FeatureProvider, remote: FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProvider(
FeatureFlags.multiProvider(List(overrides, remote)), // MultiProviderStrategy.firstMatch by default
FeatureFlagsConfig()
)
A strategy decides when the chain moves on to the next provider:
| Strategy | Moves on when a provider | Stops at |
|---|---|---|
MultiProviderStrategy.firstMatch (default) |
reports FLAG_NOT_FOUND |
the first answer that is not "flag not found", including an error |
MultiProviderStrategy.firstSuccessful |
reports any error, including FLAG_NOT_FOUND |
the first answer without an error |
// Also fall through on errors: a failing `primary` is answered by `backup`
def resilient(primary: FeatureProvider, backup: FeatureProvider): FeatureProvider =
FeatureFlags.multiProvider(List(primary, backup), MultiProviderStrategy.firstSuccessful)
Things to know:
- A provider must answer
FLAG_NOT_FOUNDfor a key it does not hold. An answer with reasonDEFAULTand no error code counts as a match and ends the chain. - With
firstMatch, when no provider has the flag, you getFLAG_NOT_FOUNDand the caller's default. WithfirstSuccessful, you get aGENERALerror carrying every provider's error. - Every provider stays in the chain, in order, even if two report the same name.
- For your own strategy, implement
MultiProviderStrategy.Strategy(the Java SDK'sStrategyinterface). Longflags are read through each provider's own long getter, exact across the wholeLongrange. That holds forFeatureFlags.multiProviderand the defaultfromAcquireAsynccompose. AMultiProvideryou build yourself withnew MultiProvider(...)has no long getter: it reads each provider's double getter instead, which fails integer values withTYPE_MISMATCH. UseFeatureFlags.multiProvider, or wrap your chain inIntegerWideningLongProviderfor values inIntrange.
Start on a fallback: fromAcquireAsync¶
Some providers do their network work in the constructor, so even fromProviderAsync blocks until they are built.
fromAcquireAsync takes the provider as an effect instead. The layer starts at once on a fallback provider, and a
background fiber builds the real one, checks it, initializes it, and then switches to it. The fallback answers every
evaluation until that moment, and the status is Ready the whole time:
def fallbackFirst(
buildRealProvider: () => FeatureProvider // e.g. a constructor that downloads a datafile
): URLayer[Any, FeatureFlags & AcquireStatus] =
FeatureFlags.fromAcquireAsync(
acquire = ZIO.attemptBlocking(buildRealProvider()),
fallback = ZIO.succeed(EnvVarProvider()), // a fresh instance each time it runs
verify = Verify.flagExists[Boolean]("kill-switch"),
onSwapped = ZIO.logInfo("real provider is live"),
onConstructionError = e => ZIO.logWarning(s"staying on env vars: ${e.getMessage}")
)
The layer's error type is Nothing: a real provider that never comes up cannot fail your app. It is retried, reported
to onConstructionError, and the fallback keeps serving.
| Parameter | Default | Meaning |
|---|---|---|
acquire |
Builds the real provider. Register cleanup in its Scope (for example with ZIO.acquireRelease) to have a rejected or unused provider closed. |
|
fallback |
Builds a fallback. It runs again for the swap, because the switch shuts the first fallback down, so it must return a fresh instance each time. | |
verify |
accept | Checks the real provider before the swap. A failure counts as a failed attempt. |
compose |
FeatureFlags.defaultAcquireCompose |
Combines real and fallback into the provider that is swapped in. The default asks the real provider first and falls back on any error (firstSuccessful). |
constructionRetry |
Schedule.recurs(3) && Schedule.exponential(1.second).jittered |
Retries acquire plus verify. |
constructionTimeout |
30 seconds | Bounds one attempt. |
onSwapped |
nothing | Runs once the real provider serves. |
onConstructionError |
nothing | Runs once all attempts have failed, or the swap failed. |
evaluationTimeout, initTimeout, contextSource, fallbackLogging |
as in FeatureFlagsConfig |
The same settings as above. |
Verify the candidate. A provider can be built with a wrong key or an empty configuration and still answer, with
wrong values. Verify.flagExists[A](key) asks the candidate for a flag you know exists, and rejects it if the answer
carries any error code (Verify.VerificationFailed(key, errorCode, message)). Verify.all(check1, check2) runs several
checks in order; any FeatureProvider => Task[Unit] is a check. The candidate is checked as acquire returned it,
before its initialize() runs and without global or ambient context. A provider that only answers after
initialize() must be initialized inside acquire.
Which provider is serving? The status is Ready from the start, so it cannot tell you. The layer also provides
AcquireStatus, whose state is Constructing, then either Live or Failed(cause):
// A readiness probe that turns true only once the real provider is serving
val ready: URIO[AcquireStatus, Boolean] =
AcquireStatus.get.map(_.isLive)
// Wait for the outcome, either way: Live or Failed(cause)
val outcome: URIO[AcquireStatus, Option[AcquireState]] =
AcquireStatus.changes.filter(_ != AcquireState.Constructing).runHead
AcquireStatus reports how construction ended, not ongoing health: Live stays Live if the real provider degrades
later, and the default compose then quietly answers from the fallback. For ongoing health, wrap the real provider in
a CircuitBreakerProvider. A failed swap is reported as ProviderSwapFailed(error).
How the swap works. Unlike setProvider, the swap never makes evaluations fail
with ProviderNotReady. The OpenFeature SDK only ever sees one provider on this path, an internal switch that starts
out answering from the fallback. The composed real-plus-fresh-fallback provider is initialized off to the side, bounded
by initTimeout, and the switch routes to it only once that has succeeded; it then shuts the first fallback down. If
initialization fails or times out, the composed provider is shut down, the fallback keeps serving, and the state
becomes Failed(ProviderSwapFailed(...)). If you call setProvider yourself before the switch, your provider wins:
the acquired one is discarded and the state becomes Failed.
If you want plain async start without a fallback for a provider whose constructor blocks, wrap it in a
DeferredProvider and pass it to fromProviderAsync.
Domains¶
A domain is a name for a client. Providers can be registered per domain, so different parts of an app can use
different providers. clientMetadata tells you which client you have:
val describe: URIO[FeatureFlags, String] =
for
client <- FeatureFlags.clientMetadata // ClientMetadata(domain, version)
provider <- FeatureFlags.providerMetadata // ProviderMetadata(name, version)
yield s"client $client served by ${provider.name}"
providerMetadata returns the name of the provider currently serving.
One client per domain: FeatureFlagRegistry¶
FeatureFlagRegistry builds clients on demand, one per domain, all on a private OpenFeature API that it owns:
val billingFlag: ZIO[FeatureFlagRegistry, FeatureFlagError, Boolean] =
for
billing <- FeatureFlagRegistry.getClient("billing") // built once, then cached
enabled <- billing.boolean("new-pricing", default = false)
yield enabled
def withRegistry(
defaultProvider: FeatureProvider,
billingProvider: FeatureProvider
): ZIO[Any, Throwable | FeatureFlagError, Boolean] =
(FeatureFlagRegistry.setProvider("billing", billingProvider) *> billingFlag)
.provide(FeatureFlagRegistry.fromProvider(defaultProvider))
getClient(domain)builds the client the first time and returns the same instance afterwards. Callers asking for the same domain at once share one build, and a slow domain does not hold up the others.- A domain without its own provider uses the default provider.
defaultClientis the client without a domain. setProvider(domain, provider)registers a provider for the domain. If its client already exists, the provider is swapped in.- A provider that does not become ready within 30 seconds, or fails, gives
ProviderInitializationFailed. The nextgetClienttries again. addZioApiHook(hook)adds an API-level hook to every client, current and future.- When the registry's scope closes, it shuts down its API and every provider on it.
Replace the provider at runtime¶
setProvider swaps the provider of a running instance. Hooks, context, event handlers and tracked events stay.
def swap(next: FeatureProvider): ZIO[FeatureFlags, FeatureFlagError, Unit] =
FeatureFlags.setProvider(next).catchSome { case e: FeatureFlagError.ProviderInitializationFailed =>
// The previous provider is back in place and still serving
ZIO.logWarning(s"kept the old provider: ${e.message}")
}
What happens:
- The status becomes
NotReady. Evaluations that start now fail withProviderNotReady; ones already running finish on the old provider. Swaps are serialized. - The SDK initializes the new provider.
setProviderwaits for this for at mostinitTimeout, the layer's init timeout. Once the new provider is ready, the SDK shuts down the old one. - On success, the status becomes
Readyand the new provider serves. - If the new provider's
initialize()throws,setProviderfails withProviderInitializationFailed, the previous provider is registered again, and the status returns toReady. If even that fails, the status staysError. - If
initialize()has not finished withininitTimeout,setProviderfails withProviderInitializationFailedwrapping aTimeoutException, and the status becomesError. This swap is not rolled back: the new provider stays registered, and the status becomesReadyon its own if it finishes later. The previous provider is not restored, because the SDK would shut it down as soon as the new one became ready.
In every failure case, setProvider again with a working provider recovers.
Once a swap has started it finishes, with one of the outcomes above, even if the calling fiber is interrupted: an
interrupt halfway would leave neither the new provider nor the old one properly in place. So setProvider(p).timeout(d)
returns only when the swap has settled, at most about twice initTimeout. A call still waiting for an earlier swap to
finish can be interrupted at once.
setProvider is the only way out of Fatal. When you use a wrapper such as CircuitBreakerProvider, swap in a new
wrapper around the new provider, not just the inner provider.
Shut down¶
Closing the layer's scope shuts the instance down. You can also call FeatureFlags.shutdown. See
Lifecycle and events for what it releases. Whether the provider stops too depends on
API ownership.
Older factories¶
These still compile but are deprecated. Each is a shorthand for a FeatureFlagsConfig:
| Deprecated | Write instead |
|---|---|
fromProvider(p, evaluationTimeout) |
fromProvider(p, FeatureFlagsConfig().withEvaluationTimeout(t)) |
fromProvider(p, evaluationTimeout, initTimeout) |
fromProvider(p, FeatureFlagsConfig().withEvaluationTimeout(t).withInitTimeout(i)) |
fromProviderWithDomain(p, domain[, version]) |
fromProvider(p, FeatureFlagsConfig().withDomain(d).withVersion(v)) |
fromProviderWithHooks(p, hooks) |
fromProvider(p, FeatureFlagsConfig().withHooks(hooks)) |
fromMultiProvider(ps[, strategy]) |
fromProvider(FeatureFlags.multiProvider(ps, strategy), FeatureFlagsConfig()) |
fromProviderAsync(p, evaluationTimeout[, initTimeout]) |
fromProvider(p, FeatureFlagsConfig().withAsyncInit.withEvaluationTimeout(t)) |
fromProviderWithDomainAsync, fromProviderWithHooksAsync, fromMultiProviderAsync |
the same as their sync form, plus .withAsyncInit |