Extras Module
Table of contents
- Overview
- HOCON Provider
- Environment Variable Provider
- OFREP Provider
- Circuit Breaker Provider
- Caching Provider
Overview
The zio-openfeature-extras module provides built-in providers for common use cases — reading flags from local config, environment variables, wrapping any provider with evaluation caching, and adding circuit breaker logic for fast failover.
libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-extras" % "<version>"
OFREP (HTTP-based remote evaluation) lives in its own module so its HTTP-client transitive deps (Jackson, Guava, etc.) don’t get pulled in for users who only need HOCON/env-var providers. See the OFREP Provider section below for the dependency snippet.
HOCON Provider
Reads flag values from Typesafe Config (application.conf / reference.conf).
Configuration
# application.conf
feature-flags {
new-checkout = true
max-items = 50
rate-limit = 2.5
welcome-message = "Hello!"
settings {
timeout = 30
retries = 3
}
}
Usage
import zio.*
import zio.openfeature.*
import zio.openfeature.extras.*
// Read from "feature-flags" path (default)
val layer = FeatureFlags.fromProvider(HoconProvider())
// Read from a custom path
val layer = FeatureFlags.fromProvider(HoconProvider("my-flags"))
// From a specific Config object
val layer = FeatureFlags.fromProvider(HoconProvider.fromConfig(myConfig))
Supported types
- Boolean:
flag = true - String:
flag = "value" - Integer:
flag = 42 - Double:
flag = 3.14 - Object: Nested config objects are converted to SDK
Structurevalues
All evaluations return STATIC as the resolution reason since values are loaded from config.
Manual reload
// Re-read config from disk without restarting
val provider = HoconProvider()
provider.reload() // or provider.reload("custom-path")
Environment Variable Provider
Reads flag values from environment variables with a configurable prefix and naming convention.
Key mapping
Flag keys are mapped to env var names by combining a prefix with a key transform:
env var name = prefix + keyTransform(flagKey)
The default transform uppercases the key and replaces - and . with _:
| Flag key | Env var (default) |
|---|---|
new-checkout | FF_NEW_CHECKOUT |
max-items | FF_MAX_ITEMS |
app.feature.enabled | FF_APP_FEATURE_ENABLED |
export FF_NEW_CHECKOUT=true
export FF_MAX_ITEMS=50
export FF_RATE_LIMIT=2.5
export FF_WELCOME_MSG="Hello!"
Usage
import zio.openfeature.*
import zio.openfeature.extras.*
// Default prefix: FF_
val layer = FeatureFlags.fromProvider(EnvVarProvider())
// Custom prefix
val layer = FeatureFlags.fromProvider(EnvVarProvider(prefix = "FEATURE_"))
// Custom key transform
val layer = FeatureFlags.fromProvider(
EnvVarProvider(keyTransform = _.toUpperCase.replace(".", "__"))
)
Type coercion
- Boolean:
true/false,yes/no,1/0,on/off - Integer: Parsed via
toInt - Double: Parsed via
toDouble - String: Raw env var value
Unparseable values fall back to the provided default.
Testing
Use withLookup to provide a custom env var source:
val testEnv = Map("FF_MY_FLAG" -> "true")
val provider = EnvVarProvider.withLookup(testEnv.get)
OFREP Provider
Evaluates flags via the OpenFeature Remote Evaluation Protocol (OFREP) — the standard HTTP protocol for vendor-neutral remote flag evaluation. Use this when your flags are served by an OFREP-compatible backend (flagd, OFREP relays, or any compliant server) and you want a vendor-agnostic client.
OFREPProvider is a small Scala-friendly factory over the OpenFeature Java SDK’s dev.openfeature.contrib.providers.ofrep.OfrepProvider. The Java provider handles HTTP requests, polling, caching, and state transitions; the Scala factory just sugars the construction.
Note: The underlying contrib provider is at version
0.0.1— the API may evolve as OFREP itself matures. Pin the dependency deliberately.
Dependency
OFREP lives in its own module so that callers who only want HOCON / env-var providers don’t pull in the HTTP-client transitive stack (Jackson, Guava, Commons Validator, SLF4J).
libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-ofrep" % "<version>"
Usage
import zio.*
import zio.openfeature.*
import zio.openfeature.ofrep.OFREPProvider
val program = ZIO.scoped {
for
provider <- OFREPProvider.make("https://flags.example.com")
.mapError(e => new RuntimeException(e.message))
env <- FeatureFlags.fromProviderAsync(provider).build
ff = env.get[FeatureFlags]
enabled <- ff.boolean("new-checkout", default = false)
.mapError(e => new RuntimeException(e.message))
yield enabled
}
OFREPProvider.make(baseUrl) parses and validates the URL before constructing — bad input fails layer build with FeatureFlagError.InvalidConfiguration rather than surfacing as an opaque ProviderError(MalformedURLException) on the first evaluation. The ZLayer convenience OFREPProvider.layer(baseUrl) does the same and exposes the result as ZLayer[Any, FeatureFlagError.InvalidConfiguration, OfrepProvider].
For full configuration (auth headers, timeouts, custom executor), use make(options):
import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions
import scala.jdk.CollectionConverters._
import java.time.Duration as JDuration
val options = OfrepProviderOptions.builder()
.baseUrl("https://flags.example.com")
.requestTimeout(JDuration.ofSeconds(5))
.connectTimeout(JDuration.ofSeconds(2))
.headers(Map("Authorization" -> "Bearer my-token").asJava)
.build()
val provider = OFREPProvider.make(options)
The legacy throwing factories — OFREPProvider(), OFREPProvider(baseUrl), OFREPProvider.fromOptions(options) — remain available but are deprecated. They accept any string and surface configuration mistakes only at the first evaluation; prefer make / layer for validated construction.
Configuration options
The full set of options is exposed by the Java SDK’s OfrepProviderOptions builder:
| Option | Default | Description |
|---|---|---|
baseUrl | http://localhost:8016 | OFREP server endpoint |
requestTimeout | 10s | Per-request HTTP timeout |
connectTimeout | 10s | TCP connect timeout |
headers | empty | Static headers applied to every request (e.g., bearer token) |
proxySelector | system default | Custom java.net.ProxySelector |
executor | fixed pool of 5 | Executor for HTTP work |
Async initialization
Like any other provider, the OFREP provider works with fromProviderAsync for non-blocking startup:
val layer = FeatureFlags.fromProviderAsync(OFREPProvider("https://flags.example.com"))
Evaluations fail with ProviderNotReady until the provider has fetched its initial flag set.
Failure surfacing
How a downstream failure shows up depends on where it originates:
- HTTP
4xx/5xxfrom the OFREP endpoint: the contrib provider catches the response and returns a successfulFlagResolutionwitherrorCodepopulated (typicallyGeneral) anderrorMessageset. The ZIOFeatureFlagslayer hands this back to callers as a successful effect — operators are expected to alert onresolution.errorCode.isDefined. - Network-level failures (DNS,
ConnectException, connection reset): the contrib provider’s HTTP client throws synchronously, the throw escapesattemptBlocking, andFeatureFlagError.classifymaps it to a typed error —Unreachablefor the known network exception types,ProviderErroras the fallback. These arrive in the effect’s error channel. - Evaluation timeout (via
FeatureFlags.fromProvider(provider, evaluationTimeout)): surfaces asProviderErrorwrapping aTimeoutException.
The OFREPFailureModeSpec in this module pins these behaviours so a contrib-provider upgrade doesn’t silently shift them. If you build alerting on top of the OFREP integration, alert on both branches: errorCode on resolutions AND Unreachable/ProviderError/timeout in the error channel.
Transitive dependencies
Adding zio-openfeature-ofrep pulls in Jackson (core/databind/jsr310), Guava, Commons Validator, and SLF4J via the contrib provider. This is intentionally isolated from the extras module so projects without OFREP keep their dependency footprint small.
Circuit Breaker Provider
A decorator that wraps any provider with circuit breaker logic for fast failover. When the delegate provider fails repeatedly or becomes unhealthy, the circuit opens and evaluations fail immediately (< 1ms) — enabling instant fallback when composed with MultiProvider and MultiProviderStrategy.firstSuccessful.
When to use
Use this when your primary provider is an external service (e.g., Optimizely, LaunchDarkly) and you need guaranteed fast failover to a local fallback (e.g., EnvVarProvider) if the service is slow or unavailable.
State machine
The circuit breaker has three states:
| State | Behavior |
|---|---|
| Closed | Normal operation. Evaluations forwarded to the delegate. Consecutive failures tracked. |
| Open | Evaluations fail immediately without calling the delegate (< 1ms). After resetTimeout, transitions to Half-Open. |
| Half-Open | A single probe evaluation is allowed through. On success → Closed. On failure → Open. |
Two tripping mechanisms
- Failure-count: After
failureThresholdconsecutive evaluation failures (including timeouts), the circuit opens. - State-driven: Before each evaluation, the delegate’s state is checked. If
ERRORorFATAL, the circuit opens immediately — no failed evaluations needed. When the delegate recovers toREADY, the circuit closes automatically.
Usage
import zio.*
import zio.openfeature.*
import zio.openfeature.extras.*
// Wrap the primary provider with circuit breaker
val resilientProvider = CircuitBreakerProvider(
optimizelyProvider,
CircuitBreakerConfig(
failureThreshold = 3, // open after 3 consecutive failures
resetTimeout = 30.seconds, // probe recovery after 30s
evaluationTimeout = 50.millis, // timeout per delegate call
halfOpenMaxCalls = 1, // probes before closing
stalePolicy = StalePolicy.Open
)
)
// Compose with fallback using MultiProvider
val layer = FeatureFlags.fromMultiProvider(
List(resilientProvider, EnvVarProvider()),
MultiProviderStrategy.firstSuccessful
)
Or using the ZIO-based factory:
for
cb <- CircuitBreakerProvider.make(optimizelyProvider, CircuitBreakerConfig(
evaluationTimeout = 50.millis
))
layer = FeatureFlags.fromMultiProvider(
List(cb, EnvVarProvider()),
MultiProviderStrategy.firstSuccessful
)
yield layer
Configuration
| Parameter | Default | Description |
|---|---|---|
failureThreshold | 5 | Consecutive failures before the circuit opens |
resetTimeout | 30.seconds | Time in open state before allowing a probe |
evaluationTimeout | 500.millis | Max duration for a single delegate evaluation |
halfOpenMaxCalls | 1 | Successful probes required to close the circuit |
stalePolicy | StalePolicy.Open | Behavior when delegate reports STALE state |
Stale policy
Controls how the circuit breaker reacts when the delegate provider is in STALE state:
| Policy | Behavior |
|---|---|
StalePolicy.Open | Treat stale as failure — open the circuit |
StalePolicy.Ignore | Keep the current circuit state |
StalePolicy.HalfOpen | Transition to half-open for probing |
Failover latency comparison
| Approach | During outage | Failover latency |
|---|---|---|
MultiProvider + firstSuccessful alone | Tries primary every time, waits for failure | Up to minutes |
| Add timeout only (e.g., 50ms) | Still tries primary every time | 50ms per call |
| Circuit breaker | Skips primary entirely when open | < 1ms |
Error classification
Not all errors indicate a provider health issue. The circuit breaker distinguishes between infrastructure failures and application-level errors:
| Error type | Counts toward threshold? | Examples |
|---|---|---|
| Infrastructure errors | Yes | Timeouts, connection refused, GeneralError, ProviderNotReadyError, FatalError |
| Application errors | No | FlagNotFoundError, TypeMismatchError, ParseError, TargetingKeyMissingError, InvalidContextError |
Application-level errors reset the consecutive failure counter because they prove the provider is reachable. A burst of FlagNotFoundError calls for missing flags will not trip the circuit — in fact, they actively prevent it from tripping by resetting the failure count.
State-driven failover example (Optimizely)
For providers like Optimizely Local that poll for configuration:
- Startup → datafile fetch fails → provider reports
ERROR→ circuit opens instantly → fallback toEnvVarProvider - 30s later → next poll succeeds → provider reports
READY→ circuit closes → evaluations resume via Optimizely - Later poll fails → provider reports
ERROR→ circuit opens again instantly
No evaluation failures needed — the circuit breaker reacts to the provider’s health state directly.
Caching Provider
A decorator that wraps any existing provider and adds evaluation caching backed by zio-cache.
Benefits
- Concurrent deduplication: If N fibers evaluate the same flag simultaneously, the underlying provider is called exactly once
- TTL-based expiration: Cached values expire after a configurable duration
- LRU eviction: Bounded cache size with least-recently-used eviction
Usage
import zio.*
import zio.openfeature.*
import zio.openfeature.extras.*
// Wrap any provider
val cachedProvider = CachingProvider(myRemoteProvider, CachingConfig(
maxEntries = 1000,
ttl = 5.minutes
))
val layer = FeatureFlags.fromProvider(cachedProvider)
Or using the ZIO-based factory:
for
cached <- CachingProvider.make(myRemoteProvider, CachingConfig(ttl = 1.minute))
layer = FeatureFlags.fromProvider(cached)
yield layer
Cache behavior
- First evaluation: Calls the underlying provider, caches the result
- Subsequent evaluations: Returns cached result with
CACHEDreason - Different contexts: Cached separately (cache key includes context hash)
- TTL expiry: Re-evaluates from the underlying provider after TTL
High-cardinality contexts
If your evaluation context includes per-request fields (e.g., a random UUID as targeting key), every evaluation produces a unique cache key — defeating the cache entirely.
Use contextKeys to specify which context attributes matter for caching:
val cached = CachingProvider(remoteProvider, CachingConfig(
ttl = 5.minutes,
contextKeys = Some(Set("plan", "region")) // only cache by plan + region
))
contextKeys value | Behavior |
|---|---|
None (default) | Full context hashed — every unique targeting key / attribute combo is a separate entry |
Some(Set("plan")) | Only the plan attribute is hashed — different users with the same plan share a cache entry |
Some(Set.empty) | Context ignored entirely — cache by flag key only (useful for flags that don’t depend on context) |
Invalidation
Invalidate the cache when receiving ConfigurationChanged events:
for
cached <- CachingProvider.make(myRemoteProvider)
_ <- FeatureFlags.onConfigurationChanged { (flags, _) =>
cached.invalidateAll
}
yield ()
Combining providers
The real power of the extras module comes from combining providers. The multi-provider pattern lets you layer local overrides on top of remote providers, with caching in between.
Example: env var overrides → HOCON defaults → cached remote provider
import zio.*
import zio.openfeature.*
import zio.openfeature.extras.*
object MyApp extends ZIOAppDefault:
val program = for
// Create providers — first match wins
envProvider <- ZIO.succeed(EnvVarProvider()) // Env vars: highest priority
hoconProvider <- ZIO.succeed(HoconProvider()) // application.conf: local defaults
cachedRemote <- CachingProvider.make( // Remote: cached, lowest priority
myRemoteProvider,
CachingConfig(maxEntries = 1000, ttl = 5.minutes)
)
// Combine: env vars → HOCON → cached remote
layer = FeatureFlags.fromMultiProvider(List(envProvider, hoconProvider, cachedRemote))
// Use feature flags
_ <- FeatureFlags.boolean("new-checkout", default = false).flatMap { enabled =>
ZIO.logInfo(s"new-checkout: $enabled")
}.provide(Scope.default >>> layer)
yield ()
def run = program
With this setup:
- Set
FF_NEW_CHECKOUT=truein the environment → overrides everything - Add
new-checkout = truetoapplication.conf→ overrides remote, but not env - If neither is set → falls through to the cached remote provider
- Remote evaluations are cached for 5 minutes with concurrent dedup
Example: cached remote provider with automatic invalidation
for
cached <- CachingProvider.make(remoteProvider, CachingConfig(ttl = 2.minutes))
layer = FeatureFlags.fromProvider(cached)
ff <- layer.build.map(_.get)
// Wire up automatic cache invalidation on config changes
_ <- ff.onConfigurationChanged { (changedFlags, _) =>
ZIO.logInfo(s"Flags changed: $changedFlags") *>
cached.invalidateAll
}
// Evaluations are now cached with automatic invalidation
result <- ff.boolean("feature", default = false)
yield result