Extras¶
zio-openfeature-extras adds providers you can use without a flag service: flags from application.conf or from
environment variables. It also adds wrappers that make any provider faster or safer: a cache, a circuit breaker, and
two adapters for awkward third-party providers.
| Provider | Use it to |
|---|---|
HoconProvider |
read flags from Typesafe Config (application.conf) |
EnvVarProvider |
read flags from environment variables |
CachingProvider |
cache another provider's answers for a while |
CircuitBreakerProvider |
stop calling a failing provider and fail over fast |
DeferredProvider |
move a provider's slow constructor off your startup path |
IntegerWideningLongProvider |
read Long flags from an old provider that has no long resolver |
All of them are plain OpenFeature FeatureProviders: pass them to any factory on
Wiring a provider, or combine them in a FeatureFlags.multiProvider chain.
HOCON provider¶
Reads flags from a block of Typesafe Config, feature-flags by default:
# application.conf
feature-flags {
new-checkout = true
max-items = 50
rate-limit = 2.5
welcome-message = "Hello!"
settings {
timeout = 30
retries = 3
}
}
val fromApplicationConf: HoconProvider = HoconProvider() // the `feature-flags` block
val fromOtherPath: HoconProvider = HoconProvider("my-app.flags") // any other path
val fromConfig: HoconProvider =
HoconProvider.fromConfig(ConfigFactory.parseString("new-checkout = true"))
val hoconFlags: ZLayer[Any, Throwable, FeatureFlags] = FeatureFlags.fromProvider(fromApplicationConf)
- Booleans, strings, ints, longs and doubles are read as their type. A nested block is an object flag.
- A key that is present answers with reason
Static. - A value of the wrong type is a
TypeMismatch; a value Typesafe Config cannot parse is aParseError. - HOCON has one number type, so an int, long or double read accepts any number, but only if the value does not
change:
1.5read as an int,1e300as a long or9007199254740993as a double is aTypeMismatch.2.0read as an int is2. - HOCON's automatic conversions apply: a string read of a number or boolean gives its text, and a number or boolean
read of a string such as
"42"or"true"parses it. - An absent key is
FLAG_NOT_FOUND: the typed tier fails withFlagNotFound, and the total tier serves your default with that error code. Hooks see theerrorstage. Reporting "not here" is what lets a provider chain move on to the next provider.
reload() re-reads the config the provider was built from. If the reload fails, the provider keeps the old config
and reports ERROR until a later reload succeeds:
val reload: Task[Unit] = fromApplicationConf.reload() // re-reads application.conf; on failure keeps the old config
A provider made with fromConfig has no source to re-read, so its reload() keeps the same config.
Environment variable provider¶
Reads each flag from one environment variable. The variable's name is a prefix plus the transformed flag key. By
default the prefix is FF_ and the key is upper-cased, with - and . turned into _:
| Flag key | Variable |
|---|---|
new-checkout |
FF_NEW_CHECKOUT |
max-items |
FF_MAX_ITEMS |
app.feature.enabled |
FF_APP_FEATURE_ENABLED |
val fromEnv: EnvVarProvider = EnvVarProvider() // new-checkout -> FF_NEW_CHECKOUT
val withPrefix: EnvVarProvider = EnvVarProvider(prefix = "MYAPP_") // new-checkout -> MYAPP_NEW_CHECKOUT
val customNames: EnvVarProvider = EnvVarProvider(keyTransform = _.toUpperCase.replace(".", "__"))
val fromAMap: EnvVarProvider = EnvVarProvider.withLookup(Map("FF_NEW_CHECKOUT" -> "true").get)
withLookup takes any String => Option[String], which is handy in tests.
| Flag type | Accepted values |
|---|---|
| Boolean | true/false, yes/no, on/off, 1/0 (any case) |
| Int, Long, Double | anything toInt, toLong or toDouble parses |
| String | the raw value |
| Object | the raw value, as a string |
A variable that is set but cannot be parsed is a ParseError, so a typo in a deployed value is visible instead of
quietly serving the default. An unset variable is FLAG_NOT_FOUND, exactly like an absent HOCON key.
Combining providers¶
FeatureFlags.multiProvider chains providers. With the default first-match strategy, each provider is asked in turn
until one has the flag:
// Environment variables override application.conf; a key neither holds falls back to the caller's default.
val layered: ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProvider(FeatureFlags.multiProvider(List(EnvVarProvider(), HoconProvider())))
Here FF_NEW_CHECKOUT=true overrides new-checkout = false in application.conf, and a key neither holds falls
back to the caller's default. The chain moves on only when a provider reports FLAG_NOT_FOUND. Any other error ends
it; use MultiProviderStrategy.firstSuccessful if errors should fall through too. See
Wiring a provider for the strategies.
Caching provider¶
Wraps any provider and keeps its answers for a while, using zio-cache:
def cached(remote: FeatureProvider): ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
ZLayer.scoped(CachingProvider.scoped(remote, CachingConfig(maxEntries = 1000, ttl = 1.minute))).flatMap { env =>
FeatureFlags.fromProvider(env.get[CachingProvider])
}
- The first evaluation of a flag calls the provider; repeats within
ttlare served from the cache with reasonCached. - Entries are keyed by flag key, flag type and evaluation context. Callers who ask for the same entry at the same time share one provider call.
- Errors are never cached, and neither are
DEFAULT-reason answers, so a transient failure is retried on the next call. - When the cache is full, the least recently used entry goes.
CachingConfig field |
Default | Meaning |
|---|---|---|
maxEntries |
1000 |
entries kept before the least recently used is evicted; at least 1 |
ttl |
5.minutes |
how long an entry is served; greater than zero, and Duration.Infinity never expires |
contextKeys |
None |
which context attributes make up the cache key; None uses the whole context |
If your context carries something unique per request, such as a request id, every evaluation becomes its own entry and nothing is ever served from the cache. Name the attributes that actually change the answer:
val byPlanAndRegion: CachingConfig = CachingConfig(ttl = 5.minutes, contextKeys = Some(Set("plan", "region")))
With contextKeys set, the targeting key is left out of the cache key too, and Some(Set.empty) caches by flag key
alone.
Keeping the cache fresh¶
When the wrapped provider emits PROVIDER_CONFIGURATION_CHANGED, the whole cache is cleared, and the event is passed
on to your FeatureFlags event handlers. That needs a provider that emits events (an OpenFeature EventProvider). A
provider that does not, including one wrapped in DeferredProvider or IntegerWideningLongProvider, can serve a
changed flag for up to ttl. Put CachingProvider closest to the event-capable provider, choose ttl for how stale
you can afford to be, or clear the cache yourself:
def refreshEveryMinute(cache: CachingProvider): URIO[Scope, Unit] =
cache.invalidateAll.repeat(Schedule.fixed(1.minute)).forkScoped.unit
Clearing applies to evaluations already in flight too: one that started before the clear still answers its caller, but its result is not cached.
Circuit breaker provider¶
A remote provider that is down can make every evaluation wait for a timeout. CircuitBreakerProvider notices
repeated failures and stops calling the provider for a while. While the circuit is open, calls fail at once, and the
next provider in a firstSuccessful chain answers instead:
def resilient(remote: FeatureProvider): ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
ZLayer
.scoped(
CircuitBreakerProvider.scoped(
remote,
CircuitBreakerProviderConfig(
failureThreshold = 3, // open after 3 consecutive infrastructure failures
resetTimeout = 30.seconds, // then wait 30 s before letting one probe through
evaluationTimeout = 200.millis // a call slower than this counts as a failure
)
)
)
.flatMap { env =>
// While the circuit is open, the breaker fails at once and the next provider answers
val chain = FeatureFlags.multiProvider(
List(env.get[CircuitBreakerProvider], EnvVarProvider()),
MultiProviderStrategy.firstSuccessful
)
FeatureFlags.fromProvider(chain)
}
The circuit has three states:
| State | What happens to an evaluation |
|---|---|
| Closed | It goes to the provider. Consecutive failures are counted. |
| Open | It fails at once, without calling the provider. After resetTimeout, the next call becomes a probe. |
| Half-open | One probe goes to the provider at a time. halfOpenMaxCalls successful probes close the circuit; a failed one opens it again. A slow call let through earlier, before the circuit opened, does not count. |
Three things open the circuit:
- Failures:
failureThresholdconsecutive infrastructure failures. A call slower thanevaluationTimeoutcounts as one. A failure counts whether the provider throws it or returns it as an error code (GENERAL,PROVIDER_NOT_READY,PROVIDER_FATAL); errors such asFLAG_NOT_FOUNDonly show the provider is reachable. - The provider's state: at most once per
stateCheckInterval, the breaker asks the provider for its state.ERRORorFATALopens the circuit at once, andREADYcloses a circuit opened this way. - The provider's events:
PROVIDER_ERRORopens the circuit andPROVIDER_READYcloses one opened by state or events. A circuit opened by an event, or by a failedinitialize, stays open through a polledREADY, since most providers reportREADYby default when polled; it closes on an eventREADYor afterresetTimeoutand a successful probe. This needs anEventProvider; any other provider relies on the first two.
Only failures that say the provider is unhealthy count. A FLAG_NOT_FOUND, type mismatch, parse error, missing
targeting key or invalid context proves the provider answered, so it resets the failure count instead.
CircuitBreakerProviderConfig field |
Default | Meaning |
|---|---|---|
failureThreshold |
5 |
consecutive failures that open the circuit; at least 1 |
resetTimeout |
30.seconds |
how long the circuit stays open before a probe; greater than zero, and Duration.Infinity means only a PROVIDER_READY event (or a polled READY, for a circuit a poll opened) closes it |
evaluationTimeout |
500.millis |
longest wait for one provider call, greater than zero; Duration.Infinity disables it |
halfOpenMaxCalls |
1 |
successful probes needed to close the circuit; at least 1 |
stalePolicy |
StalePolicy.Open |
what a STALE provider does: Open the circuit, HalfOpen it, or Ignore it |
stateCheckInterval |
1.second |
how often the provider's state is polled, zero or more; Duration.Zero polls on every call |
When failures open the circuit, the breaker emits PROVIDER_STALE; when a probe closes it, PROVIDER_READY. While it is
open, it reports its own state as ERROR, and STALE while half-open.
Timeouts and provider hooks
With a finite evaluationTimeout, the wrapped provider runs on another thread. If you wrap a whole
MultiProvider, the hooks of its members then see the domain "multiprovider" instead of your client's. Wrap
each member in its own breaker instead, or set evaluationTimeout = Duration.Infinity, which keeps the call on
the caller's thread but means a hung provider blocks the caller and never counts as a failure.
CircuitBreaker and CircuitBreakerConfig are the state machine behind the provider. CircuitBreakerConfig has the
same failureThreshold, resetTimeout and halfOpenMaxCalls, plus probeTimeout: how long a probe may hold the
half-open slot before another caller can take it. The provider sets it to evaluationTimeout plus one second.
Construction and lifecycle¶
Both wrappers check their config when you build them. make fails with a FeatureFlagError.InvalidConfiguration
naming every field outside the bounds in the tables above, for example
CircuitBreakerProviderConfig.failureThreshold must be >= 1, got 0. The unsafe apply constructors throw an
IllegalArgumentException with the same message.
scoped is make plus a finalizer: when the scope closes, the wrapper is shut down, which shuts down the wrapped
provider too. Wrap it in ZLayer.scoped(...) to get a layer, as the examples above do. It is safe to register a scoped
wrapper with FeatureFlags as well: a wrapper's shutdown() runs once, so the wrapped provider is not shut down a
second time.
Deferred provider¶
Some providers do all their network work in their constructor, which blocks wherever you build them.
DeferredProvider takes a function that builds the provider, and calls it only when OpenFeature initializes the
provider. With async initialization, that is on the SDK's own thread, so your startup does not wait:
// `construct` runs inside initialize(), on the SDK's init thread, not where the layer is built
def deferred(construct: () => FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProviderAsync(DeferredProvider("my-vendor")(construct))
- It reports the name you give it, before and after the real provider exists.
- Until the real provider is built, evaluations answer
PROVIDER_NOT_READYwith your default. - A shutdown during construction shuts the new provider down as soon as it is built.
- Once the real provider exists, provider hooks and
trackcalls go to it. - A
DeferredProviderthat was shut down, or failed to start, cannot be started again; build a new one. - It does not pass on the wrapped provider's events.
Use FeatureFlags.fromAcquireAsync instead if a fallback should answer while the real provider starts; see
Wiring a provider.
Integer widening long provider¶
FeatureFlags.long asks the provider's own long resolver, so 64-bit values are exact. A third-party provider written
before OpenFeature Java SDK 1.22 has no long resolver, and the SDK's stand-in answers from its double resolver, which
may reject an integer flag with a type mismatch. Wrap such a provider to read Long flags through its integer
resolver instead:
def widened(legacy: FeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
FeatureFlags.fromProvider(IntegerWideningLongProvider(legacy))
- A default that fits in an
Intis resolved by the integer resolver and widened toLong, keeping the reason, variant, error code and metadata. - A larger default goes to the provider's long path unchanged, so it is never truncated.
- If the integer resolver reports a type mismatch, for example because the stored value is a long beyond
Intrange, the evaluation is retried on the long path. A provider that silently truncates such a value instead cannot be detected. - It does not pass on the wrapped provider's events.
Every provider shipped with this library already resolves longs natively, so they never need it.
Wrapping rules¶
| Wrapper | Takes | Passes on events | Passes on provider hooks and track |
|---|---|---|---|
CachingProvider |
any FeatureProvider |
yes, if the wrapped provider emits them | yes |
CircuitBreakerProvider |
any FeatureProvider |
yes, if the wrapped provider emits them | yes |
DeferredProvider |
a function that builds one | no | hooks yes, track no |
IntegerWideningLongProvider |
any FeatureProvider |
no | yes |
CachingProvider and CircuitBreakerProvider take over the wrapped provider's event channel when initialized. Don't
also register that same provider instance with OpenFeature directly.