Skip to content

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_FOUND for a key it does not hold. An answer with reason DEFAULT and no error code counts as a match and ends the chain.
  • With firstMatch, when no provider has the flag, you get FLAG_NOT_FOUND and the caller's default. With firstSuccessful, you get a GENERAL error 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's Strategy interface).
  • Long flags are read through each provider's own long getter, exact across the whole Long range. That holds for FeatureFlags.multiProvider and the default fromAcquireAsync compose. A MultiProvider you build yourself with new MultiProvider(...) has no long getter: it reads each provider's double getter instead, which fails integer values with TYPE_MISMATCH. Use FeatureFlags.multiProvider, or wrap your chain in IntegerWideningLongProvider for values in Int range.

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:

fromAcquireAsync: from the first moment evaluations are answered by the fallback provider while a background fiber acquires the real provider, retries after a failed attempt, verifies the candidate knows a sentinel flag, and initializes it while the fallback keeps answering, then switches to it; the status stays Ready throughout, AcquireStatus moves from Constructing to Live, and later evaluations get real values.

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. defaultClient is 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 next getClient tries 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:

  1. The status becomes NotReady. Evaluations that start now fail with ProviderNotReady; ones already running finish on the old provider. Swaps are serialized.
  2. The SDK initializes the new provider. setProvider waits for this for at most initTimeout, the layer's init timeout. Once the new provider is ready, the SDK shuts down the old one.
  3. On success, the status becomes Ready and the new provider serves.
  4. If the new provider's initialize() throws, setProvider fails with ProviderInitializationFailed, the previous provider is registered again, and the status returns to Ready. If even that fails, the status stays Error.
  5. If initialize() has not finished within initTimeout, setProvider fails with ProviderInitializationFailed wrapping a TimeoutException, and the status becomes Error. This swap is not rolled back: the new provider stays registered, and the status becomes Ready on 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