feature-flag
Server-side feature flag evaluation for async Rust services. Targeting rules, sticky percentage rollouts, hot reload from a JSON file, zero RNG. Built around Tokio, but the evaluation path is sync — flags don't need to await.
use ;
let flagset = from_json?;
let evaluator = new;
let subject = new
.with_attr
.with_attr;
let result = evaluator.evaluate?;
println!;
Design rules
- No external RNG. Sticky bucketing is
SHA-256(flag_id ‖ "/" ‖ subject_id) mod 100. Deterministic; no entropy source needed; the same user always lands in the same slot. - Tokio is the assumed runtime for I/O paths (hot reload). The evaluation hot path is plain sync — flags don't need an executor.
- Cheap clones.
FlagEvaluatorisArc-shaped, so handing it across tasks is free. - Snapshot for tests. Build a
FlagSetin code, evaluate, assert. No I/O required. - Strict validation up-front. Rollout weights must sum to 100. Rules can't reference variants the flag doesn't declare.
FlagSet::from_jsonruns both checks before returning.
The DSL
A FlagSet is JSON, like this:
Rules are evaluated in declared order. The first matching rule wins; its outcome is either a fixed variant or a sticky rollout.
Predicate operators
kind |
Notes |
|---|---|
always |
Always matches. Useful as a default. |
compare + op: eq / ne |
Strict JSON-value equality. |
compare + op: gt / gte / lt / lte |
Numeric. false on non-numeric types. |
compare + op: in / not_in |
value must be an array. |
compare + op: starts_with / ends_with |
String-only. |
all_of / any_of / not |
Combinators. Recursive. |
enabled: false is the kill switch
When enabled is false the evaluator skips all rules and returns default_variant with reason: "disabled". Set it from a remote config push to cut traffic to a feature without redeploying.
Hot reload
Watch a JSON file and atomically swap the loaded FlagSet whenever the mtime advances. Bad pushes don't kill the watcher — they log and stick with the previously-good set.
use Duration;
use ;
let evaluator = new;
let reloader = spawn.await?;
// ... your service runs ...
reloader.shutdown.await;
Polling cadence is intentional: no platform-specific code, no extra dep tree, and flag reload doesn't need millisecond latency.
Sticky rollout — why SHA-256 instead of a hash like FNV
Two reasons. First, the keyspace is open (user-supplied flag ids + subject ids), so we want a hash that doesn't accidentally collide on adversarial inputs. Second, SHA-256 is already in every dependency tree, the cost (~hundreds of ns) is invisible next to a feature-flag dispatch path. If you ever need to migrate to a different hash, the bucketing function is one private helper — replace and re-run the tests.
Composes with
reliability-toolkit-rs— wrap a rollout-controlled call in a circuit breaker + retry.slo-budget-tracker— flip a flag toenabled: falsefrom a remote config push when your SLO starts burning.policy-as-code-engine— for the policy side of "should this happen" (allow/deny on identity + action), pair with this crate which answers "which behaviour should we run."
Examples
# subject=alice country=US -> "control" (rule="us-ca-rollout-30pct", reason=rule_match)
# subject=bob country=DE -> "control" (rule="-", reason=default)
# subject=carla country=US -> "treatment" (rule="internal-employees", reason=rule_match)
Tests + bench
The CI matrix is stable, beta, and 1.85.0 (MSRV).
License
MIT. See LICENSE.