Extras Module

Table of contents

  1. Overview
  2. HOCON Provider
    1. Configuration
    2. Usage
    3. Supported types
    4. Manual reload
  3. Environment Variable Provider
    1. Key mapping
    2. Usage
    3. Type coercion
    4. Testing
  4. OFREP Provider
    1. Dependency
    2. Usage
    3. Configuration options
    4. Async initialization
    5. Failure surfacing
    6. Transitive dependencies
  5. Circuit Breaker Provider
    1. When to use
    2. State machine
    3. Two tripping mechanisms
    4. Usage
    5. Configuration
    6. Stale policy
    7. Failover latency comparison
    8. Error classification
    9. State-driven failover example (Optimizely)
  6. Caching Provider
    1. Benefits
    2. Usage
    3. Cache behavior
    4. High-cardinality contexts
    5. Invalidation
    6. Combining providers

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 Structure values

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 / 5xx from the OFREP endpoint: the contrib provider catches the response and returns a successful FlagResolution with errorCode populated (typically General) and errorMessage set. The ZIO FeatureFlags layer hands this back to callers as a successful effect — operators are expected to alert on resolution.errorCode.isDefined.
  • Network-level failures (DNS, ConnectException, connection reset): the contrib provider’s HTTP client throws synchronously, the throw escapes attemptBlocking, and FeatureFlagError.classify maps it to a typed error — Unreachable for the known network exception types, ProviderError as the fallback. These arrive in the effect’s error channel.
  • Evaluation timeout (via FeatureFlags.fromProvider(provider, evaluationTimeout)): surfaces as ProviderError wrapping a TimeoutException.

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

  1. Failure-count: After failureThreshold consecutive evaluation failures (including timeouts), the circuit opens.
  2. State-driven: Before each evaluation, the delegate’s state is checked. If ERROR or FATAL, the circuit opens immediately — no failed evaluations needed. When the delegate recovers to READY, 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:

  1. Startup → datafile fetch fails → provider reports ERROR → circuit opens instantly → fallback to EnvVarProvider
  2. 30s later → next poll succeeds → provider reports READY → circuit closes → evaluations resume via Optimizely
  3. 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 CACHED reason
  • 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=true in the environment → overrides everything
  • Add new-checkout = true to application.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

Copyright © 2026 Mohsen Zainalpour. Distributed under the Apache 2.0 license.

This site uses Just the Docs, a documentation theme for Jekyll.