Evaluating flags¶
Every method on FeatureFlags has a twin on its companion object that takes the service from the environment, so
FeatureFlags.boolean(...) is a ZIO[FeatureFlags, FeatureFlagError, Boolean]. The examples use the companion.
Read a value¶
There is a method per flag type, and value[A] for any type with a FlagType:
val enabled: ZIO[FeatureFlags, FeatureFlagError, Boolean] = FeatureFlags.boolean("dark-mode", default = false)
val color: ZIO[FeatureFlags, FeatureFlagError, String] = FeatureFlags.string("button-color", default = "blue")
val limit: ZIO[FeatureFlags, FeatureFlagError, Int] = FeatureFlags.int("max-items", default = 100)
val quota: ZIO[FeatureFlags, FeatureFlagError, Long] = FeatureFlags.long("max-bytes", default = 1_000_000L)
val rate: ZIO[FeatureFlags, FeatureFlagError, Double] = FeatureFlags.double("sample-rate", default = 0.1)
val config: ZIO[FeatureFlags, FeatureFlagError, Map[String, Any]] =
FeatureFlags.obj("checkout-config", default = Map("timeout" -> 30))
// Any type with a FlagType instance, including your own
val ratio: ZIO[FeatureFlags, FeatureFlagError, Float] = FeatureFlags.value[Float]("ratio", 0.5f)
Each also takes an EvaluationContext to target the flag at a user or a request:
val user = EvaluationContext("user-42").withAttribute("plan", "pro")
val forUser: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
FeatureFlags.boolean("dark-mode", default = false, user)
Two tiers¶
The flag may be missing, hold the wrong type, or the provider may be down. Which method you call decides what happens then.
The typed tier (boolean, string, …, value, every *Details) fails with a FeatureFlagError whenever it
cannot produce a real value. That includes a provider that answers with an error code rather than throwing, which is
how most providers report a missing flag. Use it when a default would be wrong, such as a gate that must fail closed:
// Refuse to take payments unless the flag can actually be read and says yes
val paymentsGate: ZIO[FeatureFlags, Misconfigured | FlagsUnavailable, Boolean] =
FeatureFlags.boolean("payments-enabled", default = false).mapError {
case FeatureFlagError.FlagNotFound(key) => Misconfigured(s"$key is not defined")
case other => FlagsUnavailable(other)
}
The total tier (*OrDefault, resolveOrDefault) never fails. It serves your default instead, and
resolveOrDefault returns the full resolution so you can still see why:
val lenient: ZIO[FeatureFlags, Nothing, Boolean] =
FeatureFlags.booleanOrDefault("dark-mode", default = false)
// The full resolution, still never failing: it says why a default was served
val explained: ZIO[FeatureFlags, Nothing, String] =
FeatureFlags.resolveOrDefault[Boolean]("dark-mode", default = false).map { resolution =>
resolution.errorCode match
case Some(code) => s"served the default because of $code: ${resolution.errorMessage.getOrElse("")}"
case None => s"resolved to ${resolution.value}"
}
The total tier absorbs typed errors and defects alike. Only fiber interruption still propagates. Both tiers run the same
evaluation: the same context, hooks, transactions and timeouts. Hooks see a failed evaluation in their error stage
whichever tier you called.
| Typed tier | Total tier |
|---|---|
boolean(key, default[, ctx]): IO[FeatureFlagError, Boolean] |
booleanOrDefault(key, default[, ctx]): UIO[Boolean] |
string, int, long, double, obj |
stringOrDefault, intOrDefault, longOrDefault, doubleOrDefault, objOrDefault |
value[A](key, default[, ctx]) |
valueOrDefault[A](key, default[, ctx]) |
booleanDetails(key, default[, ctx[, options]]), and one per type, and valueDetails[A] |
resolveOrDefault[A](key, default[, ctx[, options]]) |
value, valueOrDefault, valueDetails and resolveOrDefault also take a FlagDef in place of
the key and default.
The resolution¶
The *Details methods and resolveOrDefault return a FlagResolution[A]:
val details: ZIO[FeatureFlags, FeatureFlagError, String] =
FeatureFlags.booleanDetails("dark-mode", default = false).map { resolution =>
s"value=${resolution.value} reason=${resolution.reason} variant=${resolution.variant.getOrElse("-")}"
}
| Field | Meaning |
|---|---|
value |
the flag's value, or your default |
variant |
the variant the provider picked, if it names one |
reason |
why this value: Static, Default, TargetingMatch, Split, Cached, Disabled, Stale, Error, Unknown, or Other(name) for a provider's own reason |
metadata |
the provider's flag metadata, read with getString, getBoolean, getInt, getLong, getDouble |
flagKey |
the key evaluated |
errorCode |
set when a default was served: FlagNotFound, TypeMismatch, ParseError, TargetingKeyMissing, InvalidContext, ProviderNotReady, ProviderFatal, General |
errorMessage |
the provider's or the library's explanation |
isError, isSuccess, isDefault and isCached test the reason and code. On the typed tier errorCode is always
empty, since an error code fails the effect instead.
Errors¶
FeatureFlagError |
When |
|---|---|
FlagNotFound(key) |
the provider does not have the flag |
TypeMismatch(key, expected, actual) |
the flag holds another type, or a custom FlagType rejected the value |
ParseError(key, cause) |
the provider could not parse the flag's definition |
TargetingKeyMissing(key) |
the flag needs a targeting key and the context has none |
InvalidContext(reason) |
the provider rejected the evaluation context |
ProviderNotReady(status) |
the provider has not started yet, or is shutting down |
ProviderFatal |
the provider failed for good, for example it never started within initTimeout |
ProviderInitializationFailed(cause) |
building the layer: the provider did not start |
Unauthorized(reason) |
the provider was refused, typically a wrong SDK key (HTTP 401 or 403) |
Unreachable(cause) |
the provider could not be reached: DNS, connection refused, no route |
ProviderError(cause) |
anything else the provider threw, including an evaluation that timed out |
InvalidConfiguration(reason) |
building a provider (OFREP, Optimizely) with bad settings |
OverrideTypeMismatch(key, expected, actual) |
a transaction override of the wrong type |
NestedTransactionNotAllowed |
a transaction started inside another, under the default nesting policy |
EmptyFlagVariables(key) |
deprecated: nothing in the library raises it; kept for binary compatibility |
Unauthorized, Unreachable and ProviderError come from FeatureFlagError.classify, which sorts what a provider
throws from track or past the SDK during evaluation (a timeout, for instance), so you can alert on a bad key
separately from an outage. An exception that carries an HTTP status (a getStatusCode or statusCode accessor) is
classified by that status: 401 and 403 are Unauthorized, anything else a ProviderError. Every error has a message,
and a cause when there is an underlying exception; Unauthorized from classify keeps the original exception there.
A provider that reports a failure as a GENERAL evaluation error rather than throwing (the OFREP provider
does this for 401 and 403) also yields Unauthorized when the message names an auth failure; its error code stays
General.
FeatureFlagError.isFlagError (a problem with this flag) and isProviderError (a problem with the provider) group
them. isRecoverable is the deprecated old name of isFlagError; it has nothing to do with retrying.
Recover from the cases you expect and let the rest fail:
val recovered: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
FeatureFlags.boolean("dark-mode", default = false).catchSome {
case FeatureFlagError.FlagNotFound(_) => ZIO.succeed(false) // not defined yet: treat as off
case _: FeatureFlagError.Unreachable => ZIO.succeed(false) // network blip: serve the default
case _: FeatureFlagError.Unauthorized => // wrong SDK key: be loud about it
ZIO.logError("flag provider rejected our credentials").as(false)
}
Options for one call¶
EvaluationOptions adds hooks, hook hints or a timeout to a single evaluation. Pass it as the fourth argument, after
the context, to a *Details method, resolveOrDefault, any *OrDefault, valueOrDefault or value (including the
FlagDef forms):
val options: EvaluationOptions =
EvaluationOptions.empty
.withHook(FeatureHook.logging()) // a hook for this call only
.withHint("request-id", "r-123") // read-only data for the hooks
.withTimeout(200.millis) // or .withoutTimeout
val bounded: ZIO[FeatureFlags, FeatureFlagError, FlagResolution[Boolean]] =
FeatureFlags.booleanDetails("dark-mode", default = false, EvaluationContext.empty, options)
| Option | Effect |
|---|---|
withHook(hook), withHooks(hooks) |
run these hooks for this call, as well as the instance's own |
withHint(key, value) |
data the hooks can read from their hints |
withTimeout(duration) |
EvaluationTimeout.After(duration): bound this call |
withoutTimeout |
EvaluationTimeout.Disabled: no timeout, and no timer overhead |
| (neither) | EvaluationTimeout.Default: the instance's timeout |
Each evaluation is bounded by a timeout of 1 second by default. Change it for the whole instance with
FeatureFlagsConfig.withEvaluationTimeout, or turn it off with withoutEvaluationTimeout (see
Wiring a provider). An evaluation that times out fails with ProviderError wrapping a
TimeoutException. The provider's call is abandoned, not cancelled: it keeps running in the background, because the
Java SDK gives no way to stop it.
Each abandoned call keeps a thread of ZIO's blocking pool until the provider returns, and that pool has no upper
bound. Against a provider that hangs, every timeout would park one more thread, so each instance caps how many
abandoned calls may still be running: 256 by default (FeatureFlagsConfig.withMaxAbandonedCalls(n), or
fromAcquireAsync's maxAbandonedCalls; Int.MaxValue turns the cap off). At the cap, a timed evaluation fails
at once with ProviderError wrapping a RejectedExecutionException, without calling the provider, and the first
rejection in each fallbackLogging window logs a warning. The *OrDefault calls serve defaults meanwhile. Calls that
return free their slot, so evaluations resume on their own once the provider recovers. Only abandoned calls count, so
busy but healthy traffic never hits the cap, and an untimed evaluation (withoutTimeout) is never rejected. For a
remote provider, also consider wrapping it in CircuitBreakerProvider, which stops calling it once it
starts failing.
Logging served defaults¶
Each time the total tier serves a default, it logs a warning that names the flag, the reason and the value:
So that a provider outage does not flood the log, the line is rate-limited per flag:
FallbackLogging |
Logs |
|---|---|
Default (Throttled(60.seconds)) |
at most one line per flag per minute; the next one says (suppressed N similar) |
Throttled(window) |
at most one line per flag per window |
Always |
every fallback |
Off |
no line |
A defect absorbed by the total tier is a bug, not an outage, so it is logged with its cause even under Off (throttled
at the default window). Past 1024 distinct flags, further flags share one throttled bucket. The limit applies to this
log line only: hooks and metrics still see every evaluation.
Set the policy on the instance:
val instanceConfig: FeatureFlagsConfig =
FeatureFlagsConfig()
.withEvaluationTimeout(500.millis) // every evaluation; the default is 1 second
.withFallbackLogging(FallbackLogging.Throttled(5.minutes)) // or FallbackLogging.Off / Always
FeatureFlags.fromAcquireAsync takes it as its fallbackLogging parameter instead.
OpenFeature conformance
The spec says evaluation should not write logs. This line is a deliberate exception, since a silently served
default is the signal most worth having. FallbackLogging.Off is the conformant setting; add
FeatureHook.logging if you want a log line per evaluation instead. See
Spec compliance.