Extras Module
Table of contents
- Overview
- HOCON Provider
- Environment Variable Provider
- Deferred Provider
- OFREP Provider
- Circuit Breaker Provider
- Caching Provider
- Integer Widening Long 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
A key that is present returns STATIC as the resolution reason, since the value came from config.
Absent keys and provider chains
A key the config does not contain is reported as FLAG_NOT_FOUND. On the typed tier (boolean, *Details, value, …) that is a typed FeatureFlagError.FlagNotFound(key) failure; on the total tier (*OrDefault, resolveOrDefault) you still get your default as the value, with reason = Error and errorCode = FlagNotFound on the resolution. Either way hooks see the error stage rather than after — so Hook.logging() reports an absent key at error level where it previously reported it at info. That is the spec-correct stage for an error-coded resolution, though if you use a provider as an opt-in source where most keys are deliberately absent, evaluate through the total tier and tune logError/errorLevel on your hooks.
Reporting not-found is what makes a chain work:
// The HOCON file has no "checkout.v2" key, so the chain moves on to the next provider.
FeatureFlags.multiProvider(List(HoconProvider(), EnvVarProvider()))
MultiProviderStrategy.firstMatch advances to the next provider only when a provider reports FLAG_NOT_FOUND. A result carrying reason = DEFAULT is treated as an answer and ends the chain — which is why “no such key” has to be reported as not-found rather than as a default. It also means an operator can tell “configured to this value” from “not configured here”, which a DEFAULT answer hides.
Each provider in a chain needs a distinct metadata name. The SDK keys providers by
getMetadata.getName, so twoHoconProviders over different configs collapse into one — and the survivor is the last one, not the first, whatever the strategy is called. The SDK logsduplicated provider nameat INFO, so it is easy to miss unless SDK info logging is on. Chain different provider types, or wrap one in a provider reporting another name.
Manual reload
// Re-read config without restarting. `reload()` refreshes the source the provider was built from:
// the classpath path for `HoconProvider(path)`, or the injected config for `HoconProvider.fromConfig(...)`.
val provider = HoconProvider("custom-path")
provider.reload()
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
A variable that is set but unparseable is a PARSE_ERROR — a typed ParseError failure on the typed tier, errorCode = ParseError on the total tier’s resolution, and the error hook stage on both — rather than quietly behaving like the default, so a typo in a deployed value is visible.
A variable that is not set is FLAG_NOT_FOUND — the same two-tier behaviour, and for the same chaining reason, as an absent HOCON key.
Testing
Use withLookup to provide a custom env var source:
val testEnv = Map("FF_MY_FLAG" -> "true")
val provider = EnvVarProvider.withLookup(testEnv.get)
Deferred Provider
DeferredProvider adapts a constructor-blocking provider — one that does all its network work in its Java constructor — into an initialize()-blocking one. It defers construction to initialize(ctx), which the OpenFeature SDK runs on its own init executor, so any InitMode.Async config already keeps that work off the caller’s thread.
import zio.openfeature.extras.DeferredProvider
// `construct` runs on the SDK init executor, not the layer-build thread
val deferred = DeferredProvider("optimizely")(() => new CofOptimizelyLocalProvider(options))
val layer = FeatureFlags.fromProviderAsync(deferred)
Behaviour:
- Stable metadata —
getMetadatareturns the given name before and after construction, so the event bridge andMultiProviderkeying see one identity. - No NPE before ready — evaluations before construction completes return a typed
ProviderEvaluationwithErrorCode.PROVIDER_NOT_READY. - Clean shutdown race —
shutdown()racing an in-flightinitialize()shuts the delegate down once construction finishes, instead of leaking its poller/HTTP client. - Hooks forwarded —
getProviderHooksdelegates once active.
Use DeferredProvider when you want plain async semantics (typed PROVIDER_NOT_READY until ready). When a fallback must answer during the init window, use FeatureFlags.fromAcquireAsync instead — inside a MultiProvider, DeferredProvider still gates overall readiness, since the SDK awaits all children.
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.2— 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 aFlagResolutionwitherrorCodepopulated (typicallyGeneral) anderrorMessageset. The ZIO typed tier surfaces that code as a typed failure (ProviderErrorforGeneral); the total tier hands back your default with the code on the resolution — operators alert onresolution.errorCode.isDefinedthere. - 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
FeatureFlagsConfig().withEvaluationTimeout(d)): 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 the total tier’s resolutions AND Unreachable/ProviderError/timeout in the typed tier’s 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. |
Three tripping mechanisms
- Failure-count: After
failureThresholdconsecutive evaluation failures (including timeouts), the circuit opens. - State-driven: The delegate’s state is polled at most once per
stateCheckInterval(default1.second) rather than on every call, keeping the hot path cheap. If the observed state isERRORorFATAL, the circuit opens immediately — no failed evaluations needed. When the delegate recovers toREADY, the circuit closes automatically. SetstateCheckIntervaltoDuration.Zeroto poll the delegate state on every evaluation. - Event-driven: The wrapper takes ownership of the delegate’s event channel, so delegate events trip the breaker as soon as they are emitted — a
PROVIDER_ERRORopens the circuit, aPROVIDER_READYresets an externally-opened circuit, and aPROVIDER_STALEapplies the configuredstalePolicy. This detects an unhealthy delegate without waiting for the next state poll or a failed evaluation. This is the one mechanism that needs anEventProviderdelegate — see below.
Wrapping a plain FeatureProvider
The delegate is a FeatureProvider, so a provider that does not extend EventProvider can be wrapped too — including third-party and in-house providers that implement only FeatureProvider (and perhaps Tracking). No adapter is needed and none is interposed: the breaker holds your provider as you passed it.
Only mechanism 3 needs the richer type, and it degrades cleanly: no event channel is attached, no delegate events arrive, and the breaker relies on the failure-count and state-driven mechanisms — exactly as it already does for an EventProvider that never emits. Two things follow:
- A plain delegate that does not override the SDK’s deprecated
getState()reportsREADYby default, so mechanism 2 never trips for it. Mechanism 1 is unaffected and still opens the circuit onfailureThresholdconsecutive failures. - Recovery still works: after
resetTimeoutthe half-open probe closes the circuit on success, without needing aPROVIDER_READYevent.
Everything else is forwarded unchanged — all six resolvers (including getLongEvaluation), both initialize overloads, isDomainScoped, getProviderHooks, track, shutdown, getMetadata and getState.
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.fromProvider(
FeatureFlags.multiProvider(List(resilientProvider, EnvVarProvider()), MultiProviderStrategy.firstSuccessful),
FeatureFlagsConfig()
)
Or using the ZIO-based factory:
for
cb <- CircuitBreakerProvider.make(optimizelyProvider, CircuitBreakerConfig(
evaluationTimeout = 50.millis
))
layer = FeatureFlags.fromProvider(
FeatureFlags.multiProvider(List(cb, EnvVarProvider()), MultiProviderStrategy.firstSuccessful),
FeatureFlagsConfig()
)
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 |
stateCheckInterval | 1.second | Minimum interval between delegate state polls (Duration.Zero polls on every call) |
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
- Failures are never cached: an evaluation that throws or resolves with an error code is returned to the caller but not stored, so the next call retries the delegate (a transient error never poisons the entry for the full 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) |
Wrapping a plain FeatureProvider
The delegate is a FeatureProvider, so a provider that does not extend EventProvider can be cached too — including third-party and in-house providers that implement only FeatureProvider. No adapter is needed and none is interposed: the cache holds your provider as you passed it. Everything is forwarded unchanged — all six resolvers (including getLongEvaluation), both initialize overloads, isDomainScoped, getProviderHooks, track, shutdown, getMetadata and getState.
Know what you lose. A plain delegate has no event channel, so it cannot emit PROVIDER_CONFIGURATION_CHANGED — the automatic invalidation described below never fires for one. That removes the only change-driven eviction: TTL expiry, LRU eviction at maxEntries, shutdown, and an explicit invalidateAll still apply, but a flag changed at the provider can keep being served from cache for up to ttl. Size ttl for how stale you are willing to be rather than relying on the provider to tell you.
This is inherent to the delegate having no way to signal anything; it is not specific to this wrapper. An EventProvider that simply never emits behaves identically.
Watch out for nesting. The widening also makes these compile for the first time, and both silently lose invalidation even though the innermost provider can emit — DeferredProvider and IntegerWideningLongProvider are plain FeatureProviders that do not forward a wrapped EventProvider’s events:
CachingProvider(DeferredProvider(someEventProvider)) // config-change invalidation lost
CachingProvider(IntegerWideningLongProvider(someProvider)) // same
Put CachingProvider innermost (closest to the event-capable provider), or invalidate manually.
Invalidating manually without events. The onConfigurationChanged snippet further down is driven by a provider event, so it is inert for a plain delegate. Drive it from something you control instead:
for
cached <- CachingProvider.make(myPlainProvider, CachingConfig(ttl = 5.minutes))
// your own refresh signal — a deploy hook, an admin endpoint, a poll
_ <- cached.invalidateAll.repeat(Schedule.fixed(1.minute)).forkDaemon
yield cached
Invalidation
The wrapper takes ownership of the delegate’s event channel, so a PROVIDER_CONFIGURATION_CHANGED emitted by the wrapped provider invalidates the whole cache automatically — and the event is re-emitted through the wrapper, so FeatureFlags.events subscribers still see it. (A delegate supports exactly one attachment, so don’t register the same wrapped instance directly with an OpenFeatureAPI.) This applies to EventProvider delegates only — see above for a plain FeatureProvider.
You can still invalidate manually for changes the delegate doesn’t signal:
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.fromProvider(FeatureFlags.multiProvider(List(envProvider, hoconProvider, cachedRemote)), FeatureFlagsConfig())
// 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
Integer Widening Long Provider
An escape hatch for third-party providers that predate OpenFeature Java SDK 1.22.0. You only need it if you hit the symptom below.
Background: Long is now resolved natively
FeatureFlags.long / longDetails call the SDK’s native long surface, so the provider’s own getLongEvaluation decides the result. Previously this library chose a resolver for you — an int-range default went to the provider’s integer resolver, anything larger to its double resolver, which is exact only up to 2^53 and silently lossy beyond it. A 64-bit id or a cents-denominated cap could come back quietly wrong.
Going native trades that silent loss for one of two loud, correct outcomes:
- a provider that implements
getLongEvaluationresolves the full 64-bit range exactly; - a provider that does not inherits the SDK’s default, which answers from
getDoubleEvaluationand returnsTYPE_MISMATCH(echoing your default back) outside ±(2^53−1) — a visible error instead of a wrong number.
Every provider shipped with this library already implements
getLongEvaluationnatively —HoconProvider,EnvVarProvider,TestFeatureProviderandOptimizelyFeatureProvider. If you use one of them, there is no behaviour change and nothing to do here.
It also means long evaluations are now visible to SDK-level long hooks and report FlagValueType.Long rather than FlagValueType.Int — so a hook that narrowed supportedFlagTypes to Int no longer fires for them, and one narrowed to Long now does.
Who needs the wrapper
A third-party provider written against SDK < 1.22.0 that never overrode getLongEvaluation. Its integer-stored flags now meet its double resolver, which may TYPE_MISMATCH. Wrapping it restores the old int-range routing:
import zio.openfeature.*
import zio.openfeature.extras.*
val layer = FeatureFlags.fromProvider(IntegerWideningLongProvider(legacyProvider))
Behaviour
- A default that fits in an
Intis resolved through the wrapped provider’sgetIntegerEvaluationand widened back toLong, preserving the underlyingreason,variant, error code and flag metadata. - A default outside
Intrange falls through to the wrapped provider’s own long path (i.e. the SDK default’s double resolver) rather than truncating. - A
nulldefault is passed through rather than unboxed, matching the interface contract.
Like
DeferredProvider, this is an evaluation-only wrapper: it does not forward a wrappedEventProvider’s events. See the wrapper table in Providers for the full comparison.
Prefer fixing the provider — implementing getLongEvaluation upstream — over carrying the wrapper indefinitely.