Files
buzz/crates/buzz-feature-flags/src/launchdarkly.rs
Luke Tornquist a17243d846 feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170)
## Goal

Give operators a way to relieve database pressure in seconds, without
restarting relay pods. Today every concurrency limit is an environment
variable, so changing one means a rollout. During an incident, that
rollout's restart sets off a reconnect storm of its own.

## What changes

**Two runtime-tunable caps.** Each is read from an integer feature flag
in a new process-wide evaluation context, and re-read every 5 s:

| Flag | Caps | Ceiling |
|---|---|---|
| `buzz.relay.concurrency.history-reads` | subscription-history reads
holding a reader connection (the #9132 gate) | the existing `pool −
pool/5` share, which keeps readers free for authorization |
| `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames running
their database checks | none (unbounded today) |

- **Flags can only tighten.** A positive value is clamped to the
ceiling. Zero, a negative value, no value, or a provider that never
initialized leaves the gate at its ceiling, which is today's behavior.
Once LaunchDarkly has delivered values, they stay in force through a
later outage, so a cap tightened during an incident survives a provider
blip.
- **Lowering never cancels admitted work.** A lowered gate admits
nothing new until the work in flight falls under the new limit.
- **Visibility:** `buzz_concurrency_limit{gate}` reports the applied
limit (`0` means no limit), and each change logs a line.

**One resizable gate.** `HistoryReadGate` becomes
`buzz_db::ConcurrencyGate`, a FIFO semaphore whose limit can move
anywhere in `1..=ceiling`:
- Lowering it forgets free permits and records any shortfall as debt.
- A permit released while there is debt is retired instead of returned.
- Raising it repays debt first, then adds permits.

All of this, and admission itself, settles against the debt under one
lock, so a newer limit always supersedes an older one; there is no
background resize task to race. Tokio hands a released permit to a
queued waiter before it runs; if that waiter is then cancelled, tokio
returns the permit to the semaphore directly, so admission retires any
permit it gets while debt is outstanding. The AUTH cap uses the same
type.

**AUTH shedding.** The permit is taken after signature verification, so
only real logins queue for the database checks. The wait is bounded at 2
s. A shed login gets a bare close with no `OK`, exactly like an auth
timeout. I chose this from what each client does on main today:
- **`OK false`:** the desktop app retries on an unjittered 60 s ladder.
Mobile parks the session until the app resumes. The agent harness fails
at startup.
- **Bare close:** the desktop app, mobile, the agent harness and the CLI
all reconnect on their jittered, capped backoff.

On the relay, the shed is visible as access line
`denied/concurrency_limit` and as
`buzz_auth_outcomes_total{outcome="concurrency_limit"}`.

**The feature-flag provider is chosen at startup.**
`BUZZ_FEATURE_FLAGS_PROVIDER` takes `static` (the default),
`environment` or `launchdarkly`, with `BUZZ_LAUNCHDARKLY_SDK_KEY` and an
optional `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL`:
- **Exactly one provider.** The value is parsed into a single provider,
so there is no stacking and no fallback, and the evaluator never changes
for the life of the process. The variable sits outside the
`BUZZ_FEATURE_FLAG_` namespace, so it can never be read as a flag.
- **Startup failures.** Local misconfiguration fails startup: an unknown
provider, a missing key, or a malformed proxy URL. LaunchDarkly being
unreachable, or rejecting the key, does not. The relay starts on
declared defaults, the SDK keeps connecting, and
`buzz_feature_flags_ready{provider}` shows whether the provider has
initialized. A revoked key must not crash-loop every pod over a tuning
knob.
- **Every build carries the LaunchDarkly adapter.** It stays inert
unless selected, so one published image serves every deployment and each
deployment's configuration pins its provider.

## Design-doc amendments (`crates/buzz-feature-flags/README.md`)

- **Provider selection.** The compile-time "exactly one provider"
contract becomes the startup selection above. The only-one guarantee now
holds per process instead of per build.
- **Startup.** Failing closed now covers local configuration only.
- **Guardrails.** "Flags may choose between equivalent implementations
only" gains one exception: a flag may set an operational limit as long
as it can only tighten it, and shedding under that limit stays
retryable.
- **Evaluation context.** `EvaluationContext::for_process()` adds a
process context, which LaunchDarkly sees as a single `relay`-kind
context keyed `relay`.

## What got simpler

- **Removed the compile-time provider contract:** the nested fixture
crate (with its own 2.8k-line `Cargo.lock`), its 229-line contract test,
and the exact-one `compile_error!` guards it required consumers to copy.
One startup parse plus a unit test replaces them.
- **One gate type serves both caps.** The history-read gate became that
generic type instead of gaining a sibling.
- **Net change:** about −2.3k lines.

## Review-Proven Rules applied

- **#1:** shedding is retryable (`PoolTimedOut` → CLOSED for history, a
bare close for AUTH) and never an empty success. Provider failures are
logged and surfaced through a gauge.
- **#2:** the gate settles every limit change and permit release under
one lock. The table test covers lowering while idle, lowering below the
count held, lowering then raising before holders leave, lowering then
raising past the count held, and restoring the ceiling.
- **#3:** the tests drive the production call sites:
`Db::get_channel_window_with_session` under a lowered and raised cap
(Postgres), and `handle_auth` under a full AUTH gate. The controller
test runs the real refresh loop.
- **#4:** values are clamped, waits are bounded, and the refresh runs on
a fixed interval with skipped missed ticks.
- **#9:** AUTH shedding sends clients down their jittered reconnect
backoff rather than an unjittered or terminal path (see above).

## Review rework (`92a975c8`, `3f09cb33`)

- **Keys renamed** to `buzz.relay.concurrency.*`: LaunchDarkly keys
cannot be renamed later, and the flag project may be shared with other
services.
- **Shutdown:** LaunchDarkly's final flush has no deadline of its own,
so `close()` now runs it on a detached thread and waits at most 2 s,
after the audit drain. (The blocking pool would not bound it: the
runtime waits for blocking tasks when it drops.)
- **Refresh loop:** a panicking refresh is caught, logged and counted
(`buzz_concurrency_limit_refresh_panics_total`), and the next tick
retries, so the caps cannot freeze silently.
- **Runbook:** `docs/operator-concurrency-caps.md`, covering how to
tighten a cap and the cases in which a tightened cap returns to its
ceiling (mistyped or deleted flag, pod started during a provider
outage).
- **AUTH test:** asserts the second login passes the gate and returns
its permit; the gate permit is renamed so it no longer shares a name
with the NIP-FI effect permit.

Gated on a Linux workstation at `92a975c8` (`3f09cb33` is rustfmt only;
`cargo fmt --check` passes there): clippy `-D warnings`,
`buzz-feature-flags` (50), `buzz-relay --lib --bins` (1740), the new
modules under parallel `cargo test`, and the Postgres AUTH tests (27).
Each new test fails under its mutant: the refresh without
`catch_unwind`, `close()` with no deadline (hangs until killed), and the
AUTH handler with the gate skipped.

## Testing

Re-run in full at `3c289134` after review rework, on a Linux
workstation:

- `cargo fmt --check`; `cargo clippy -p buzz-feature-flags -p buzz-db -p
buzz-relay --all-targets --all-features -D warnings`.
- `buzz-feature-flags` with and without `launchdarkly` (50 and 24
tests), `buzz-db --lib` (209), and `buzz-relay --lib --bins` (1738),
plus a plain parallel `cargo test` of the new relay modules.
- Postgres: the `buzz-db` routed-read and history-gate tests (55), and
the `buzz-relay` AUTH and connection tests (38).
- `a_cancelled_waiter_cannot_reopen_a_lowered_gate` fails with the debt
check in `admit` removed.
- The new infra-free relay tests are added to the CI unit lane
(`Justfile` and `scripts/run-tests.sh`).
- E2E against a local relay at `06c39d64`: cap gauges, sequential logins
and history under cap 1, a 20-login burst (19 shed with a bare close,
all reconnected), and LaunchDarkly misconfiguration and unreachability.


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:48:06 -05:00

997 lines
35 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";
/// Kind and key of the single context that process-wide settings evaluate.
const RELAY_CONTEXT_KIND: &str = "relay";
const RELAY_CONTEXT_KEY: &str = "relay";
/// 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)),
}
}
/// Whether the SDK has received flag data. Until it has, every evaluation
/// returns the flag's declared default.
pub fn initialized(&self) -> bool {
self.client.initialized()
}
/// 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.
///
/// A community evaluation always includes a `community` kind context and
/// optionally adds a global `pubkey` kind context when an actor is present.
/// The process context is a single `relay` kind context keyed `relay`.
fn launchdarkly_context(context: &EvaluationContext) -> Result<Context, String> {
let Some(community_id) = context.community() else {
let mut relay = ContextBuilder::new(RELAY_CONTEXT_KEY);
relay.kind(RELAY_CONTEXT_KIND);
return relay.build();
};
let mut community = ContextBuilder::new(community_id.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 process_context_targets_relay_kind_and_sees_updates_without_restart() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("process-int")
.variations([FlagValue::from(0_i64), FlagValue::from(5_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("relay"), "relay", 1),
);
let evaluator = started_evaluator(&test_data).await;
let flag = IntegerFlag::new("process-int", -1);
let context = EvaluationContext::for_process();
assert_eq!(evaluator.evaluate_int(flag, &context), 5);
test_data.update(
FlagBuilder::new("process-int")
.variations([FlagValue::from(0_i64), FlagValue::from(9_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("relay"), "relay", 1),
);
assert_eq!(
evaluator.evaluate_int(flag, &context),
9,
"a running evaluator serves the updated value"
);
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();
}
}