Skip to content

OFREP

The OpenFeature Remote Evaluation Protocol (OFREP) is a vendor-neutral HTTP API for evaluating flags on a server. flagd, OFREP relays and a growing number of flag services speak it. zio-openfeature-ofrep connects to any of them.

libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-ofrep" % "<version>"

It builds on the OpenFeature contrib ofrep provider, version 0.0.2. Both it and the protocol are young, so pin the version deliberately. The module brings Jackson, Guava, Commons Validator and SLF4J with it, which is why it is separate from Extras.

Quick start

val flags: ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
  ZLayer.scoped {
    for
      provider <- OFREPProvider.make("https://flags.example.com") // fails here if the URL is malformed
      env      <- FeatureFlags.fromProviderAsync(provider).build
    yield env.get[FeatureFlags]
  }

val checkout: ZIO[FeatureFlags, Nothing, Boolean] =
  FeatureFlags.booleanOrDefault("new-checkout", default = false, EvaluationContext("user-42"))

Each evaluation is one HTTP POST to <baseUrl>/ofrep/v1/evaluate/flags/<key>, carrying the evaluation context. The provider has no start-up step, so it is ready as soon as it is registered.

Creating the provider

OFREPProvider.make checks the base URL before building anything. It must be non-empty, parse as a URI, use http or https, and name a host. Anything else fails with FeatureFlagError.InvalidConfiguration, so a typo stops your app at startup instead of at the first evaluation.

Factory Returns Use it when
make(baseUrl) IO[InvalidConfiguration, OFREPFeatureProvider] you pass the provider to a FeatureFlags layer, which shuts it down
make(config: OFREPProviderConfig) same you need timeouts, headers or a proxy
make(options: OfrepProviderOptions) same you build the contrib provider's own options
scoped(...) ZIO[Scope, InvalidConfiguration, OFREPFeatureProvider] the provider should be shut down when a scope closes
layer(...) ZLayer[Any, InvalidConfiguration, OFREPFeatureProvider] you want the provider itself as a layer

scoped and layer take the same three kinds of argument as make. OFREPFeatureProvider is a FeatureProvider that wraps the contrib OfrepProvider and adds the Long support the contrib one lacks (see Long flags); .underlying gives you the contrib provider if you need it. The old OFREPProvider(), OFREPProvider(baseUrl) and OFREPProvider.fromOptions(options) still work but are deprecated: they skip the checks and return the bare contrib provider.

Configuration

val configured: IO[FeatureFlagError.InvalidConfiguration, OFREPFeatureProvider] =
  OFREPProvider.make(
    OFREPProviderConfig(
      baseUrl = "https://flags.example.com",
      requestTimeout = Some(java.time.Duration.ofSeconds(2)),
      connectTimeout = Some(java.time.Duration.ofSeconds(1)),
      headers = Map("Authorization" -> List("Bearer my-token"))
    )
  )
OFREPProviderConfig field Default Meaning
baseUrl required the OFREP server
requestTimeout 10 s longest wait for one HTTP request
connectTimeout 10 s longest wait to open a connection
headers none headers sent with every request, such as an Authorization token
proxy the JVM's default a java.net.ProxySelector for the HTTP traffic

The provider makes its HTTP calls on a pool of daemon threads, so it never keeps your JVM from exiting. If you build OfrepProviderOptions yourself, start from OFREPProvider.daemonOptionsBuilder() rather than OfrepProviderOptions.builder(): the contrib default is a pool of non-daemon threads.

Timeouts

Every evaluation waits for the network, so bound it. FeatureFlags gives up on an evaluation after evaluationTimeout, one second by default:

def bounded(provider: OFREPFeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
  FeatureFlags.fromProvider(
    provider,
    FeatureFlagsConfig(initMode = InitMode.Async)
      .withEvaluationTimeout(500.millis) // each evaluation is an HTTP call; give up on it after 500 ms
      .withInitTimeout(5.seconds)        // status goes Fatal if the provider is not usable by then
  )

requestTimeout bounds the HTTP request itself. Keep evaluationTimeout at or below it, so a slow server costs your callers no more than you choose.

When something goes wrong

What happens Typed tier (boolean, value, ...) Total tier (booleanOrDefault, resolveOrDefault, ...)
The server answers 401 or 403 fails with Unauthorized (a wrong or missing credential) your default, with error code General
The server answers another 4xx or 5xx fails with the error the response names, ProviderError for GENERAL your default, with the error code on the resolution
The server cannot be reached fails with Unreachable (or ProviderError) your default, with error code General
No answer within evaluationTimeout fails with ProviderError holding a TimeoutException your default, with error code General
The response is malformed JSON, such as an object key with an unpaired UTF-16 surrogate fails with ParseError your default, with error code ParseError

To alert on the total tier, look at the resolution's error code:

val audited: ZIO[FeatureFlags, Nothing, Boolean] =
  FeatureFlags
    .resolveOrDefault[Boolean]("new-checkout", default = false)
    .tap { resolution =>
      ZIO.when(resolution.errorCode.isDefined)(
        ZIO.logWarning(s"new-checkout served its default: ${resolution.errorMessage.getOrElse("")}")
      )
    }
    .map(_.value)

With a circuit breaker

CircuitBreakerProvider from Extras wraps the OFREP provider directly:

val resilient: ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
  ZLayer.scoped {
    for
      ofrep   <- OFREPProvider.make("https://flags.example.com")
      breaker <- CircuitBreakerProvider.make(ofrep, CircuitBreakerProviderConfig(failureThreshold = 5))
      env     <- FeatureFlags.fromProviderAsync(breaker).build
    yield env.get[FeatureFlags]
  }

The OFREP provider emits no events and always reports itself READY, so only failed calls trip the breaker: network errors and calls slower than the breaker's evaluationTimeout. An HTTP error comes back as an answer with an error code, not as a failure, so it does not count.

Long flags

FeatureFlags.long reads any JSON integer in the Long range exactly, 5 and 5000000000 alike. Anything else the server sends for a long read, including a whole decimal such as 2.0, a string, or an integer beyond the Long range, is a TypeMismatch and you get your default. Nothing is rounded or widened.

The contrib provider has no resolver of its own for Long; on its own, OpenFeature would answer long reads from its double resolver and reject every integer flag. OFREPFeatureProvider reads them through the contrib object resolver instead, which keeps the number exactly as the server sent it. The deprecated factories return the bare contrib provider and do not have this.