Files
buzz/crates/buzz-feature-flags/src/launchdarkly.rs
Luke Tornquist 8f6b66f9ff Select one feature flag provider at compile time (#7677)
## Why

Buzz server components need one typed feature-flag contract without
forcing the open-source build to depend on LaunchDarkly. A staging or
production relay must not be able to choose the wrong provider through
runtime configuration.

## What

- Adds typed boolean and signed `i64` flags with static, environment,
and compile-gated LaunchDarkly evaluators.
- Defines an executable relay artifact contract that compiles exactly
one provider.
- Rejects zero or multiple relay provider features at compile time.
- Runs the LaunchDarkly feature tests and the provider compile matrix in
the unit CI lane and fallback test script.

## How

`buzz-feature-flags` stays provider-neutral. The future relay
composition root will expose one build path per artifact feature:
`static-feature-flags`, `environment-feature-flags`, or
`launchdarkly-feature-flags`. Runtime configuration carries only
settings for the selected provider. It never carries a provider name,
fallback, or precedence rule.

The crate still has no relay consumer. A locked compile fixture proves
each singleton builds and every zero or multi-provider combination fails
until the relay adopts this contract.

This removes the proposed runtime `ProviderMode` and leaves one
provider, one construction path, and one evaluator per artifact.

## Risk

This PR still has no relay or app consumer, so it changes no
user-visible behavior. Risk is limited to the new feature-flag crate,
its compile contract, and its unit-test lane.

## Testing

An external Rust consumer at `30cf968bfe85e82980fda7e63f5bebe649ca939f`
exercised six environment-evaluator scenarios and 59 assertions. The
compile-time correction adds no live consumer to exercise manually.

## Bigger picture

When `buzz-relay` adopts this crate, its build manifests must map each
distribution to one provider feature. That consumer PR must verify live
Relay Proxy reachability, secret ingestion, lifecycle ownership, and
user-visible behavior.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:31:08 -04:00

954 lines
34 KiB
Rust

use http::{uri::Authority, Uri};
use launchdarkly_server_sdk::{
BuildError, Client, ConfigBuildError, ConfigBuilder, Context, ContextBuilder,
MultiContextBuilder,
};
use std::time::Duration;
use thiserror::Error;
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
const COMMUNITY_CONTEXT_KIND: &str = "community";
const PUBKEY_CONTEXT_KIND: &str = "pubkey";
/// Runtime inputs required to initialize a LaunchDarkly-backed evaluator.
#[derive(Clone)]
pub struct LaunchDarklyRuntimeConfig {
/// SDK key used to authenticate with LaunchDarkly.
pub sdk_key: String,
/// Optional Relay Proxy endpoint used for all LaunchDarkly service traffic.
pub relay_proxy_endpoint: Option<String>,
}
impl std::fmt::Debug for LaunchDarklyRuntimeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchDarklyRuntimeConfig")
.field("sdk_key", &"[REDACTED]")
.field("relay_proxy_endpoint", &self.relay_proxy_endpoint)
.finish()
}
}
impl LaunchDarklyRuntimeConfig {
/// Create a runtime config from explicit caller-provided values.
pub fn new(sdk_key: impl Into<String>) -> Self {
Self {
sdk_key: sdk_key.into(),
relay_proxy_endpoint: None,
}
}
/// Set the optional Relay Proxy endpoint.
pub fn with_relay_proxy_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.relay_proxy_endpoint = Some(endpoint.into());
self
}
}
/// Errors building a LaunchDarkly evaluator from runtime inputs.
#[derive(Debug, Error)]
pub enum LaunchDarklyInitError {
/// Caller provided an empty SDK key.
#[error("launchdarkly sdk key must not be empty")]
EmptySdkKey,
/// Caller provided an empty relay-proxy endpoint.
#[error("launchdarkly relay proxy endpoint must not be empty")]
EmptyRelayProxyEndpoint,
/// Caller provided an invalid relay-proxy endpoint.
#[error("launchdarkly relay proxy endpoint must be an absolute http(s) URI with authority")]
InvalidRelayProxyEndpoint(InvalidRelayProxyEndpointReason),
/// LaunchDarkly SDK config build failed.
#[error("launchdarkly config build failed: {0}")]
ConfigBuild(#[from] ConfigBuildError),
/// LaunchDarkly SDK client build failed.
#[error("launchdarkly client build failed: {0}")]
ClientBuild(#[from] BuildError),
}
/// Why a relay-proxy endpoint failed pre-validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvalidRelayProxyEndpointReason {
/// Endpoint could not be parsed as a URI.
MalformedUri,
/// Endpoint scheme is not `http` or `https`.
InvalidScheme,
/// Endpoint has no authority section (`host[:port]`).
MissingAuthority,
}
/// Errors starting a LaunchDarkly evaluator and waiting for initialization.
#[derive(Debug, Error)]
pub enum LaunchDarklyStartError {
/// LaunchDarkly client did not initialize before timeout.
#[error("launchdarkly initialization timed out after {0:?}")]
InitializationTimeout(Duration),
/// LaunchDarkly client reported failed initialization.
#[error("launchdarkly initialization failed")]
InitializationFailed,
}
/// Feature-flag evaluator backed by the LaunchDarkly Rust server SDK.
pub struct LaunchDarklyEvaluator {
client: Client,
}
impl std::fmt::Debug for LaunchDarklyEvaluator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchDarklyEvaluator").finish()
}
}
impl LaunchDarklyEvaluator {
/// Wrap an already-constructed LaunchDarkly client.
pub(crate) fn new(client: Client) -> Self {
Self { client }
}
/// Build a LaunchDarkly client from explicit runtime configuration.
pub fn from_runtime_config(
config: LaunchDarklyRuntimeConfig,
) -> Result<Self, LaunchDarklyInitError> {
let sdk_key = config.sdk_key.trim();
if sdk_key.is_empty() {
return Err(LaunchDarklyInitError::EmptySdkKey);
}
let mut builder = ConfigBuilder::new(sdk_key);
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
if relay_proxy_endpoint.trim().is_empty() {
return Err(LaunchDarklyInitError::EmptyRelayProxyEndpoint);
}
let relay_proxy_endpoint = validate_relay_proxy_endpoint(&relay_proxy_endpoint)?;
let mut service_endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new();
service_endpoints.relay_proxy(&relay_proxy_endpoint);
builder = builder.service_endpoints(&service_endpoints);
}
let config = builder.build()?;
let client = Client::build(config)?;
Ok(Self::new(client))
}
/// Start background tasks and wait for SDK initialization.
///
/// This method must run inside a Tokio runtime.
pub async fn start_with_default_executor_and_wait(
&self,
timeout: Duration,
) -> Result<(), LaunchDarklyStartError> {
self.client.start_with_default_executor();
match self.client.wait_for_initialization(timeout).await {
Some(true) => Ok(()),
Some(false) => Err(LaunchDarklyStartError::InitializationFailed),
None => Err(LaunchDarklyStartError::InitializationTimeout(timeout)),
}
}
/// Gracefully stop background tasks and flush pending analytics events.
///
/// This call blocks the current thread until pending analytics delivery
/// completes. Async callers should run it in a blocking shutdown path.
pub fn close(&self) {
self.client.close();
}
}
impl FlagEvaluator for LaunchDarklyEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
let context = match launchdarkly_context(context) {
Ok(context) => context,
Err(_) => return flag.default(),
};
self.client
.bool_variation(&context, flag.key(), flag.default())
}
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
let context = match launchdarkly_context(context) {
Ok(context) => context,
Err(_) => return flag.default(),
};
let detail =
self.client
.float_variation_detail(&context, flag.key(), flag.default() as f64);
if detail.variation_index.is_none() {
return flag.default();
}
let Some(value) = detail.value else {
return flag.default();
};
strict_f64_to_i64(value).unwrap_or_else(|| flag.default())
}
}
fn strict_f64_to_i64(value: f64) -> Option<i64> {
if !value.is_finite() || value.fract() != 0.0 {
return None;
}
i64::try_from(value as i128).ok()
}
fn validate_relay_proxy_endpoint(endpoint: &str) -> Result<String, LaunchDarklyInitError> {
if endpoint.trim() != endpoint {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
if endpoint.contains('#') {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
let parsed = endpoint.parse::<Uri>().map_err(|_| {
LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
)
})?;
if !matches!(parsed.scheme_str(), Some("http" | "https")) {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::InvalidScheme,
));
}
if parsed.query().is_some() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
let Some(authority) = parsed.authority() else {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MissingAuthority,
));
};
if authority.host().is_empty() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MissingAuthority,
));
}
if authority_has_explicit_port(authority) && authority.port_u16().is_none() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
Ok(endpoint.to_owned())
}
fn authority_has_explicit_port(authority: &Authority) -> bool {
let authority = authority
.as_str()
.rsplit_once('@')
.map_or_else(|| authority.as_str(), |(_, host_and_port)| host_and_port);
if authority.starts_with('[') {
return authority.contains("]:");
}
authority
.rsplit_once(':')
.map(|(_, port)| !port.is_empty())
.unwrap_or(false)
}
/// Build the LaunchDarkly context used for evaluation.
///
/// The adapter always includes a `community` kind context and optionally adds a
/// global `pubkey` kind context when an actor is present.
fn launchdarkly_context(context: &EvaluationContext) -> Result<Context, String> {
let mut community = ContextBuilder::new(context.community().to_string());
community.kind(COMMUNITY_CONTEXT_KIND);
let community = community.build()?;
let Some(actor_pubkey) = context.actor_pubkey() else {
return Ok(community);
};
let mut actor = ContextBuilder::new(actor_pubkey.to_hex());
actor.kind(PUBKEY_CONTEXT_KIND);
let actor = actor.build()?;
MultiContextBuilder::of(vec![community, actor]).build()
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::{CommunityId, PublicKey};
use launchdarkly_server_sdk::{
Config, Flag, FlagBuilder, FlagValue, Kind, NullEventProcessorBuilder, TestData,
};
fn community(id: &str) -> CommunityId {
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
}
fn actor() -> PublicKey {
PublicKey::from_hex("5581946f95a03e6afb43027ec89b21507f040d35fd6f8594f168f4298f96f9cb")
.expect("valid pubkey")
}
fn kind(name: &'static str) -> Kind {
Kind::try_from(name).expect("valid LaunchDarkly context kind")
}
fn weighted_rollout_flag(key: &str, context_kind: Option<&str>) -> Flag {
let context_kind_json = context_kind.map_or_else(String::new, |context_kind| {
format!(",\"contextKind\":\"{context_kind}\"")
});
serde_json::from_str(&format!(
r#"{{
"key": "{key}",
"version": 1,
"on": true,
"targets": [],
"rules": [],
"prerequisites": [],
"fallthrough": {{
"rollout": {{
"variations": [
{{ "variation": 0, "weight": 1 }},
{{ "variation": 1, "weight": 99999 }}
]
{context_kind_json}
}}
}},
"offVariation": 0,
"variations": [false, true],
"clientSideAvailability": {{
"usingMobileKey": false,
"usingEnvironmentId": false
}},
"salt": "saltyA"
}}"#
))
.expect("valid preconfigured weighted rollout flag")
}
fn test_config(test_data: &TestData) -> Config {
ConfigBuilder::new("sdk-key")
.data_source(test_data)
.event_processor(&NullEventProcessorBuilder::new())
.build()
.expect("launchdarkly test config")
}
fn default_test_config(test_data: &TestData) -> Config {
ConfigBuilder::new("sdk-key")
.data_source(test_data)
.build()
.expect("launchdarkly default test config")
}
async fn started_evaluator(test_data: &TestData) -> LaunchDarklyEvaluator {
let config = test_config(test_data);
let client = Client::build(config).expect("launchdarkly test client");
let evaluator = LaunchDarklyEvaluator::new(client);
evaluator
.start_with_default_executor_and_wait(Duration::from_secs(1))
.await
.expect("launchdarkly initialized");
evaluator
}
#[test]
fn launchdarkly_test_config_uses_null_event_processor() {
let test_data = TestData::new();
let config = test_config(&test_data);
let default_config = default_test_config(&test_data);
let endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new()
.build()
.expect("default service endpoints");
let event_processor = config
.event_processor_builder()
.build(&endpoints, config.sdk_key(), None)
.expect("event processor build");
let default_event_processor = default_config
.event_processor_builder()
.build(&endpoints, default_config.sdk_key(), None)
.expect("default event processor build");
event_processor.close();
default_event_processor.close();
assert!(
event_processor.flush_blocking(Duration::from_millis(10)),
"null event processor should remain a no-op after close"
);
assert!(
!default_event_processor.flush_blocking(Duration::from_millis(10)),
"default event processor should fail flush_blocking after close"
);
}
#[test]
fn runtime_config_debug_redacts_sdk_key() {
let config = LaunchDarklyRuntimeConfig::new("super-secret-sdk-key")
.with_relay_proxy_endpoint("https://relay.internal");
let debug = format!("{config:?}");
assert!(!debug.contains("super-secret-sdk-key"));
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("https://relay.internal"));
}
#[test]
fn malformed_relay_proxy_endpoint_returns_typed_error_without_panic() {
let result = std::panic::catch_unwind(|| {
LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key")
.with_relay_proxy_endpoint("not a valid endpoint"),
)
});
assert!(
result.is_ok(),
"invalid endpoint should return Err, not panic"
);
assert!(matches!(
result.expect("catch_unwind result"),
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
#[test]
fn relay_proxy_endpoint_rejects_non_http_scheme_and_missing_authority() {
let invalid_scheme = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key")
.with_relay_proxy_endpoint("ftp://relay.internal:8030"),
);
assert!(matches!(
invalid_scheme,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::InvalidScheme
))
));
for endpoint in [
"https://",
"http:///missing-authority",
"https:///still-missing-authority",
"https://:8030/path-prefix",
] {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(matches!(
result,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
}
#[test]
fn relay_proxy_endpoint_rejects_query_fragment_and_invalid_ports() {
let invalid_cases = [
"https://relay.internal/path?env=staging",
"https://relay.internal/path#fragment",
"https://relay.internal:notaport/path-prefix",
"https://relay.internal:99999/path-prefix",
];
for endpoint in invalid_cases {
let error = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
)
.expect_err("endpoint should be rejected");
assert!(matches!(
error,
LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)
));
assert!(!format!("{error}").contains(endpoint));
assert!(!format!("{error:?}").contains(endpoint));
}
}
#[test]
fn relay_proxy_endpoint_rejects_leading_or_trailing_whitespace() {
for endpoint in [
" https://relay.internal:8030",
"https://relay.internal:8030 ",
"\thttps://relay.internal:8030",
"https://relay.internal:8030\n",
] {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(matches!(
result,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
}
#[test]
fn relay_proxy_endpoint_accepts_absolute_http_and_https_uris() {
let valid_cases = [
"http://relay.internal:8030",
"https://relay.internal",
"https://relay.internal:8443/path-prefix",
];
for endpoint in valid_cases {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(
result.is_ok(),
"expected valid endpoint {endpoint} to be accepted"
);
}
}
#[test]
fn relay_proxy_endpoint_error_does_not_leak_raw_endpoint_value() {
let endpoint = "https://bad endpoint with spaces";
let error = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
)
.expect_err("invalid endpoint should fail");
assert!(!format!("{error}").contains(endpoint));
assert!(!format!("{error:?}").contains(endpoint));
}
#[tokio::test]
async fn community_only_context_targets_named_community_kind() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-only")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community.to_string(), true),
);
let evaluator = started_evaluator(&test_data).await;
assert!(evaluator.evaluate_bool(
BooleanFlag::new("community-only", false),
&EvaluationContext::for_community(community),
));
evaluator.close();
}
#[tokio::test]
async fn actor_context_targets_community_and_pubkey_kinds() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("by-community")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community.to_string(), true),
);
test_data.update(
FlagBuilder::new("by-pubkey")
.fallthrough_variation(false)
.variation_for_key(kind("pubkey"), actor.to_hex(), true),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(community, actor);
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-community", false), &context));
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-pubkey", false), &context));
evaluator.close();
}
#[tokio::test]
async fn same_actor_can_receive_different_variations_by_community() {
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-rollout")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community_a.to_string(), true),
);
let evaluator = started_evaluator(&test_data).await;
let flag = BooleanFlag::new("community-rollout", false);
assert!(evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_a, actor),));
assert!(!evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_b, actor),));
evaluator.close();
}
#[tokio::test]
async fn community_kind_weighted_rollout_uses_community_key_bucketing() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.use_preconfigured_flag(weighted_rollout_flag(
"community-weighted-rollout",
Some("community"),
));
test_data
.use_preconfigured_flag(weighted_rollout_flag("missing-context-kind-rollout", None));
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_community(community);
assert!(evaluator.evaluate_bool(
BooleanFlag::new("community-weighted-rollout", false),
&context,
));
assert!(!evaluator.evaluate_bool(
BooleanFlag::new("missing-context-kind-rollout", true),
&context,
));
evaluator.close();
}
#[tokio::test]
async fn missing_flag_falls_back_to_declared_default() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.missing", true), &context,));
evaluator.close();
}
#[tokio::test]
async fn wrong_type_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data.update(FlagBuilder::new("relay.feature.bool").variation_for_all(true));
test_data.update(
FlagBuilder::new("relay.feature.string")
.value_for_all(FlagValue::Str("red".to_owned())),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.bool", false), &context,));
assert!(
!evaluator.evaluate_bool(BooleanFlag::new("relay.feature.string", false), &context,)
);
evaluator.close();
}
#[tokio::test]
async fn community_only_context_targets_integer_variation() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-int")
.variations([FlagValue::from(0_i64), FlagValue::from(13_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community.to_string(), 1),
);
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int", -1),
&EvaluationContext::for_community(community),
),
13
);
evaluator.close();
}
#[tokio::test]
async fn actor_context_targets_integer_variation_by_community_and_pubkey() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("by-community-int")
.variations([FlagValue::from(0_i64), FlagValue::from(8_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community.to_string(), 1),
);
test_data.update(
FlagBuilder::new("by-pubkey-int")
.variations([FlagValue::from(0_i64), FlagValue::from(21_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("pubkey"), actor.to_hex(), 1),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(community, actor);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("by-community-int", -1), &context),
8
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("by-pubkey-int", -1), &context),
21
);
evaluator.close();
}
#[tokio::test]
async fn same_actor_can_receive_different_integer_variations_by_community() {
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-int-rollout")
.variations([FlagValue::from(-11_i64), FlagValue::from(99_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community_a.to_string(), 1),
);
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int-rollout", -1),
&EvaluationContext::for_actor(community_a, actor),
),
99
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int-rollout", -1),
&EvaluationContext::for_actor(community_b, actor),
),
-11
);
evaluator.close();
}
#[tokio::test]
async fn missing_integer_flag_falls_back_to_declared_default() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.missing-int", -7), &context),
-7
);
evaluator.close();
}
#[tokio::test]
async fn missing_integer_flag_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.missing-int-precise-default", default),
&context,
),
default,
"missing-flag fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn wrong_type_integer_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data
.update(FlagBuilder::new("relay.feature.int").value_for_all(FlagValue::from(17_i64)));
test_data.update(
FlagBuilder::new("relay.feature.int-bool").value_for_all(FlagValue::from(true)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int", 0), &context),
17
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int-bool", -5), &context),
-5
);
evaluator.close();
}
#[tokio::test]
async fn wrong_type_integer_flag_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-wrong-type-for-precise-default")
.value_for_all(FlagValue::from(true)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-wrong-type-for-precise-default", default),
&context,
),
default,
"wrong-type fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn provider_not_ready_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-provider-not-ready")
.value_for_all(FlagValue::from(123_i64)),
);
let config = test_config(&test_data);
let client = Client::build(config).expect("launchdarkly test client");
let evaluator = LaunchDarklyEvaluator::new(client);
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-provider-not-ready", default),
&context,
),
default,
"provider-not-ready fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn fractional_integer_variation_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-fractional-positive")
.value_for_all(FlagValue::from(17.75_f64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-fractional-negative")
.value_for_all(FlagValue::from(-17.75_f64)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-fractional-positive", 123),
&context,
),
123
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-fractional-negative", -456),
&context,
),
-456
);
evaluator.close();
}
#[tokio::test]
async fn integer_variation_accepts_exact_large_f64_backed_i64_values() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-large-positive")
.value_for_all(FlagValue::from(1_152_921_504_606_846_976_i64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-large-negative")
.value_for_all(FlagValue::from(-1_152_921_504_606_846_976_i64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-min-boundary")
.value_for_all(FlagValue::from(i64::MIN)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-max-rounds-out-of-range")
.value_for_all(FlagValue::from(i64::MAX)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-large-positive", 0),
&context,
),
1_152_921_504_606_846_976_i64
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-large-negative", 0),
&context,
),
-1_152_921_504_606_846_976_i64
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-min-boundary", 11),
&context,
),
i64::MIN
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-max-rounds-out-of-range", -12),
&context,
),
-12
);
evaluator.close();
}
#[tokio::test]
async fn negative_integer_variation_is_supported() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(FlagBuilder::new("negative-int").value_for_all(FlagValue::from(-42_i64)));
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("negative-int", 3),
&EvaluationContext::for_community(community),
),
-42
);
evaluator.close();
}
}