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.
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.