Skip to main content

exd Rust SDK (exd-client)

Installation

Add exd-client to your Cargo.toml:

[dependencies]
exd-client = { version = "1.0", features = ["telemetry"] }

Cargo features

FeatureDefaultDescription
async-tokioyesCompiles the async ExdClient surface. Turn off for sync-only consumers (CLIs, batch jobs, the git pre-receive hook) that don't want a Tokio dependency.
http-in-processyesAdds the in-process reqwest-based HTTP transport (HttpBackend::InProcess). Off → only HttpBackend::Curl is available; the default backend reverts to curl. See HTTP transport.
native-tls (planned)noSwitches the in-process transport to the platform's native TLS stack instead of rustls.
telemetry (planned)noEnables the OtlpSink telemetry exporter.

The crate exposes two surfaces:

  • Synchronous Namespace (always available) — thread-based refresh, subprocess loader. Used by the exd CLI, the git pre-receive hook, batch jobs, and tests.
  • Async ExdClient (async-tokio feature, default-on) — Tokio-spawned poll task with cooperative shutdown, async eval API. Wraps the same lint pipeline, typed model, and eval engine as Namespace; sync I/O is delegated to tokio::task::spawn_blocking. The HTTP transport is selectable per call site — see HTTP transport.

Runtime requirement (async surface): ExdClient requires a Tokio runtime (tokio = { version = "1", features = ["full"] }).


Initialization

ExdClient is constructed via a single builder. The builder accepts exactly one flag-namespace source, which determines where the manifest comes from and how it is refreshed. The four adoption rungs documented in Developer Adoption Ladder map onto three flag-namespace-source methods on this builder; the same evaluation API is used regardless of which source is chosen.

Namespace sourceMethodRefreshMaps to ladder rung
Local directory of TOML filesnamespace_dir(&str) / PathBufNone — static for process lifetime.1
Pre-built Namespace valuenamespace_inline(Namespace)None — static for process lifetime.1
Remote URI (https / git+ / ftps)namespace_uri(&str)Polled on poll_interval.2
exd serverserver(&str).tenant(&str).namespace(&str)Polled on poll_interval.3 (and 4 with edge URL)

The builder rejects any combination of multiple flag-namespace sources at build() time with ExdError::ConflictingNamespaceSources.

Rung 1 — Inline flag namespace

use exd_client::ExdClient;

let client = ExdClient::builder()
.namespace_dir("./payments")
.environment("production")
.build()
.await?;

namespace_dir reads the manifest TOML from disk at build() time and runs the lint pipeline. No background task is started; the manifest is static for the lifetime of the client.

For tests or callers that have already constructed a Namespace (e.g. via Namespace::from_files for in-memory TOML), namespace_inline(namespace) accepts it directly:

let namespace = Namespace::from_files("payments", &files)?;
let client = ExdClient::builder()
.namespace_inline(namespace)
.environment("production")
.build()
.await?;

Rung 2 — URI-hosted flag namespace

use exd_client::ExdClient;
use std::time::Duration;

let client = ExdClient::builder()
.namespace_uri("https://flags.example.com/payments/manifest.tar.gz")
.environment("production")
.poll_interval(Duration::from_secs(30))
.manifest_cache_path("/var/cache/exd/payments.json")
.build()
.await?;

Supported URI schemes:

SchemeConditional fetchAuth
https://If-None-Match / If-Modified-SinceOptional bearer token via .uri_auth(UriAuth::Bearer(...)) or basic auth in URI.
git+ssh://, git+https://Remote ref SHA comparison. Fragment selects ref and subdirectory: #ref:subdir.SSH agent / deploy key (ssh) or token (https).
ftps://Server MDTM (modification time) comparison.Username/password in URI or .uri_auth(UriAuth::Basic { .. }).

A background poll task is started; behaviour matches the server-backed flow apart from the fetch transport.

Rung 3 — exd server (and rung 4 — edge deployment)

use exd_client::ExdClient;
use std::time::Duration;

let client = ExdClient::builder()
.server("https://flags.example.com")
.tenant("acme")
.namespace("payments")
.environment("production")
.api_token("exd_read_...")
.poll_interval(Duration::from_secs(30))
.manifest_cache_path("/var/cache/exd/payments.json")
.build()
.await?;

Rung 4 (edge deployment) uses the same builder; only the URL passed to .server(...) differs (it points at an edge-deployed exd server instead of a central one).

Builder options

MethodTypeDefaultDescription
namespace_inlineNamespaceNamespace source: pre-built in-process value. Mutually exclusive with the other source methods.
namespace_dir&str / PathBufNamespace source: local directory of TOML files (sugar for Namespace::from_dir + namespace_inline). Mutually exclusive with the other source methods.
namespace_uri&strNamespace source: remote URI (https://, git+ssh://, git+https://, file://). Mutually exclusive with the other source methods.
server&strFlag-namespace source: base URL of an exd server. Pair with tenant(&str), namespace(&str), api_token(&str). Mutually exclusive with the other source methods.
tenant&strTenant slug on the server. Required when using server.
namespace&strNamespace identifier on the server. Required when using server.
environment&strRequired. Environment name (e.g. "production").
api_token&strRead-scoped API token (exd_read_...). Required when using server.
uri_authUriAuthnoneAuth credentials when using namespace_uri. UriAuth::Bearer(token) adds an Authorization: Bearer … header; UriAuth::Basic { user, pass } uses HTTP Basic. Honored for https:// and http://; other schemes silently ignore it.
poll_intervalDuration30sHow often namespace_uri / server sources re-fetch the manifest. Ignored for namespace_inline.
manifest_cache_path&str / PathBufnonePath to a local tar.gz file used to persist the manifest between process restarts. On warm starts within the freshness window (5 min default), the cache is loaded and the network is bypassed. After every successful fetch or refresh, the archive is rewritten atomically (tempfile + rename). Honored for https:// URI sources and server sources. Silently ignored for namespace_inline, file://, and git+* (they have nothing to cache, or have their own caching). Configuring this path also enables conditional polling — see Conditional refresh.
working_tree&str / PathBufper-process temp dirOn-disk mirror of the closure used by event_stream(true) to apply inline closure deltas. Only honoured alongside event_stream(true); ignored otherwise. See Working tree.
event_streamboolfalseSubscribe to GET /api/v1/events and apply closure deltas as they arrive. Requires server(…). See Event-stream refresh.
event_stream_flags&[impl Into<String>]*Restrict the SSE subscription to a fixed set of flag keys. Translates to ?ns=<slug>:<flag1>,<flag2> in the wire request. Useful when the consuming service only reads a handful of flags from a large flag namespace.
safety_net_intervalDuration30 minHow often the safety-net polling refresh runs alongside the SSE stream. Lowered values give faster recovery from a misbehaving network path; higher values reduce load.
http_backendHttpBackendHttpBackend::from_env_or_default()HTTP transport for every network surface (initial fetch, conditional refresh, SSE consumer, snapshot-fetch fallback). HttpBackend::InProcess uses reqwest (default; requires the default-on http-in-process Cargo feature); HttpBackend::Curl shells out to curl. The env-resolved default reads `EXD_HTTP_BACKEND=curl
dry_runboolfalseWhen true, all evaluations return the caller-supplied default. See Dry Run Mode.
telemetry_sink (planned)impl TelemetrySinknonePluggable sink for OpenTelemetry spans. See Telemetry Sink. Deferred — sampling design unsettled.

Phase status. Today's ExdClient ships Phases 1–3 minus telemetry, plus SSE refresh and ExdClientGroup: all three namespace sources, polling, dry-run, async eval, close(), from_manifest_file, the ManifestClient write path, uri_auth, manifest_cache_path, event_stream, and the multi-namespace ExdClientGroup. Telemetry (telemetry_sink / OTLP) and ftps:// URI support are deferred. The v2 closure-delta protocol described in Event-stream refresh is the steady-state protocol; the wire format is defined in server-api/events and the test contracts in testplan/14-sse-event-stream.md and testplan/14a-closure-deltas.md. Errors from the builder surface as ExdError::ConflictingNamespaceSources, ExdError::MissingField(&'static str), ExdError::Load(LoadError), or ExdError::TaskJoin(String).

build() returns Result<ExdClient, ExdError>. For namespace_uri and server sources it performs an initial manifest fetch and starts the background poll task before resolving. For namespace_inline it validates the supplied Namespace and returns immediately with no background task.


Evaluation API

Typed helpers

Typed helpers never panic and never return Err. If evaluation fails for any reason — network unavailability, missing flag key, rule engine error — the provided default is returned silently. The failure reason is logged at WARN level via the tracing crate and is reflected in the RuleMatched::SdkDefault variant when using the structured API.

// Typed helpers — never panic, always return default on any error
let enabled: bool = client.bool_flag("checkout-redesign", &ctx, false).await;
let variant: String = client.string_flag("checkout-flow", &ctx, "control".into()).await;
let tier: i64 = client.int_flag("price-tier", &ctx, 0).await;
let rate: f64 = client.float_flag("sample-rate", &ctx, 0.0).await;

Structured result

When you need the full evaluation metadata alongside the value, use eval:

// Structured result with metadata
let result = client.eval("checkout-redesign", &ctx).await;
// result.value: FlagValue
// result.variant_key: &str
// result.rule_matched: RuleMatched (enum: Rule(usize), Default, SdkDefault, AttrTypeMismatch{...})
// result.flag_version: u64

EvalResult fields:

FieldTypeDescription
valueFlagValueThe resolved flag value.
variant_key&strKey of the matched variant (e.g. "on", "control", "treatment-a").
rule_matchedRuleMatchedHow the result was determined. See variants below.
flag_versionu64Manifest version at the time of evaluation. Useful for debugging.

RuleMatched variants:

VariantMeaning
Rule(usize)A targeting rule at the given zero-based index matched.
DefaultNo rule matched; the resolved env's variant was used (falling through to the catch-all _'s variant if the env did not declare its own).
SdkDefaultEvaluation failed (network, parse error, etc.); the caller-supplied default was used.
AttrTypeMismatch { attribute, expected, actual }A context attribute's runtime type disagrees with the type the manifest infers for it (see E034). The SDK fallback value is returned.

Typed evaluation entry points

Today's Namespace ships a strict alternative to the loose eval path. Each typed entry point requires the flag's declared flag.type to match and rejects context attributes whose runtime type disagrees with the manifest's inferred type:

use exd_client::{EvalError, Namespace};

let namespace = Namespace::from_dir("./manifest")?;

let enabled: bool = namespace.eval_bool("checkout-redesign", "production", &ctx)?;
let theme: String = namespace.eval_string("theme", "production", &ctx)?;
let tier: i64 = namespace.eval_i64("price-tier", "production", &ctx)?;
let rate: f64 = namespace.eval_f64("sample-rate", "production", &ctx)?;
let cfg = namespace.eval_json("checkout-config", "production", &ctx)?;

EvalError variants:

VariantWhen it fires
UnknownFlag { flag }The flag does not exist in the flag namespace.
UnknownEnvironment { flag, environment }The environment is not declared, or the flag has no per-env block for it.
AttrTypeMismatch { attribute, expected, actual }A context attribute's runtime type disagrees with the manifest-inferred type.
FlagTypeMismatch { flag, requested, actual, variant_key }The flag's declared type does not match the typed entry point being called.

For callers that want the structured EvalResult but still want to inspect FlagValue directly, the public enum has typed accessors: as_bool(), as_str(), as_i64(), as_f64(), as_json(). Each returns Option<T> based on the inner variant.

Evaluate all flags

// Evaluate all flags at once (returns HashMap)
let all: HashMap<String, EvalResult> = client.eval_all(&ctx).await;

Per-call options — EvalOptions

Every eval entry point has an _with_options variant that accepts an EvalOptions value. Today the only option is include_testing — the per-call opt-in for env blocks declared with testing = true. The plain entry points (eval, eval_bool, …) always pass EvalOptions::default() (include_testing = false), so a flag in a testing-gated env resolves through the env's variant for them.

use exd_client::{EvalOptions, Namespace};

let namespace = Namespace::from_dir("./manifest")?;
let admin_ctx = ctx.clone();

// Admin tool: pass the opt-in so the testing rules apply.
let canary = namespace.eval_string_with_options(
"checkout-redesign",
"production",
&admin_ctx,
&EvalOptions::new().with_include_testing(true),
)?;

// Regular user code: no opt-in → testing rules invisible → env's `variant`.
let stable = namespace.eval_string("checkout-redesign", "production", &user_ctx)?;

See resolution § The rollout workflow for the disabled → testing → enabled state machine and the resolution-algorithm impact.


Evaluation Context

The evaluation context carries the identity and attributes of the entity being evaluated against (typically a user, device, or agent).

use exd_client::EvalContext;

let ctx = EvalContext::builder()
.str("user.country", "US")
.bool("user.is_premium", true)
.int("user.age", 28)
.float("user.score", 4.2)
.str_list("user.roles", ["admin", "beta"])
.build();

Attribute builder methods:

MethodRust type storedUse with
.str(key, value)AttrValue::Stringeq/neq, in/not_in, contains/etc, starts_with/ends_with, semver_*
.bool(key, value)AttrValue::Booleq/neq, is_set/is_not_set
.int(key, value)AttrValue::Intnumeric ops, eq/neq, in/not_in
.float(key, value)AttrValue::Floatnumeric ops, eq/neq
.str_list(key, values)AttrValue::StrListcontains/not_contains (list-includes-scalar), in/not_in (set-overlap), is_set/is_not_set
.attr(key, AttrValue)(any)the generic form, useful when decoding from JSON / wire formats

List attributes (str_list) are intentionally loose-typed: the lint pass infers String from a contains "x" predicate (it can't tell from a single use whether the attribute is a single string or a list of strings), and at runtime both AttrValue::String and AttrValue::StrList are accepted. Operators that don't apply to lists (numeric, semver, prefix/suffix, equality) silently evaluate false against a list attribute.

entity_id is commonly used by percentage-bucket segments as the consistent hash input. It should be a stable, unique identifier for the entity (e.g. user UUID, device ID, or agent run ID).


Conditional refresh

server sources, and https:// URI sources that have opted into the cache via manifest_cache_path, use a conditional poll loop:

  1. The initial fetch is unconditional. The response carries ETag: "v<N>" (a strong validator over the manifest version), and the SDK records it in process-local state.
  2. On every subsequent poll tick, the SDK sends If-None-Match: "v<N>".
  3. If the server-side version is unchanged, the response is 304 Not Modified with no body. The typed model is left untouched, the lint pipeline is not re-run, and the cache file (if any) is not rewritten.
  4. If the version has advanced, the server returns 200 OK with the new archive bytes and an updated ETag. The SDK lints the new archive, atomically swaps the typed model, optionally rewrites the cache file, and records the new ETag for the next tick.

For a 30-second poll interval against a stable manifest, this collapses every steady-state poll to a single 304 round trip — the body bandwidth is the response headers and nothing else. The first poll after a process restart is unconditional (the ETag lives in process memory only); after that, every tick is conditional.

file:// and git+* URI sources do not participate in conditional refresh — they re-stage the source on every poll using the loader's existing transports (no HTTP layer to gate on). Wrapped tarball URLs (an https:// source whose archive contains a single top-level directory rather than namespace.toml at the root) also fall through to unconditional refresh; configuring manifest_cache_path is the SDK's signal that the URL serves a server-style archive (namespace.toml at the root) and is therefore eligible for the conditional path.


Event-stream refresh (SSE)

server sources can opt into Server-Sent Events for instant manifest-version notifications, replacing the periodic poll's wall-clock timer with a long-lived stream that pushes a closure delta the moment a new manifest is committed:

let client = ExdClient::builder()
.server("https://flags.example.com")
.namespace("payments")
.environment("production")
.api_token("exd_read_...")
.event_stream(true)
.working_tree(std::env::temp_dir().join("exd/payments"))
.build()
.await?;

The wire protocol is v2 closure deltas — the server pushes a path-keyed delta over the smallest sub-tree of the manifest needed to evaluate the subscribed flags (the closure), not a stream of per-flag TOML edits. Full protocol contract: server-api/events.

What changes when event_stream(true) is enabled

The SDK opens a long-lived GET /api/v1/events?ns=<slug>:<flag-list> connection. The first event the server emits is always delivery: snapshot; the SDK fetches snapshot_url, untars it into the working_tree directory, runs lint::lint_dir(working_tree), and atomic-swaps the resulting typed model into the Namespace. Subsequent events are delivery: inline whenever they fit in the server's 64 KiB payload budget — the SDK applies the path-keyed files to a copy of the working tree, recomputes the closure hash, and swaps only on success.

A low-frequency safety-net periodic refresh runs alongside the stream (safety_net_interval, default 30 minutes; the protocol's hash-verified end-to-end checks make a higher-frequency safety net unnecessary). Reconnect uses exponential backoff (1 s → 30 s cap) and resumes from the last successful event's version via the Last-Event-ID SSE header.

event_stream(true) is only valid alongside server(…); combining it with namespace_inline or namespace_uri returns ExdError::MissingField("server (event_stream requires a server source)") from build().

Working tree

The closure-delta protocol requires the SDK to keep a writable on-disk copy of the closure between events — the apply loop overlays each delta onto the previous tree, lints, and swaps. Configure where that lives via working_tree(impl Into<PathBuf>). The directory is created if missing; if it already exists with a previously-applied closure for the same (server, namespace, environment, subscription), the SDK reuses it as a warm cache and the first event after restart can apply against the cached state (the server still emits a delivery: snapshot first event; the SDK compares its tracked closure hash to the event's closure_hash and skips re-fetching if they match).

If working_tree is not configured, the SDK creates a private subdirectory under the system temp directory keyed by the namespace slug. This is convenient for ephemeral processes; long-running services should pin the path explicitly so warm-restart is deterministic.

working_tree is independent of manifest_cache_path. The cache is the tar.gz archive used to bypass the network on cold start; the working tree is the live, file-keyed mirror used to apply inline deltas. Configuring both is recommended for production.

State machine

Internally the client moves between three states. Eval continues to serve from the last successfully-applied closure across every transition; nothing in the apply path mutates the live Namespace until lint succeeds.

StateWhat's happening
ConnectingTCP connect / TLS handshake / SSE subscribe in flight.
Snapshot-fetchingA delivery: snapshot URL or event: resync URL is being downloaded and applied.
SteadyThe SSE stream is open and the SDK is consuming inline events.

Every failure mode degrades to retry-with-backoff or snapshot fallback; nothing is fatal:

FailureResponseEval impact
prev_closure_hash mismatch on inline eventSnapshot fallback for the current versionNone — stale serves
Per-file sha256 mismatchSnapshot fallbackNone
Post-apply closure_hash mismatchSnapshot fallbackNone
lint::lint_dir fails on the post-apply copySnapshot fallback; last_refresh_error() carries the diagnosticNone
Snapshot download network errorExponential backoff (1 s, 2 s, 4 s, 8 s, 16 s, then 60 s steady)None
Snapshot un-tar / hash verification failureBackoff + retry; persistent failure surfaces on last_refresh_error()None
Snapshot URL signature rejected (401 closure_token_invalid)Reconnect SSE to obtain a freshly-signed URLNone
SSE connection dropsReconnect with Last-Event-ID; the next first event is a snapshotNone
event: resync receivedFetch the resync snapshot_url, apply, resumeNone
Subscribed flag removed from the manifestNamespace reflects the new closure (no entry for that flag); eval returns supplied defaultDefault returned (correct outcome, not a failure)

The strict invariant: bool_flag / string_flag / int_flag / float_flag / eval never propagate refresh-path errors to the caller. They always return either a valid evaluation against the current Namespace or the caller-supplied default. Refresh-path failures are observable via last_refresh_error() for monitoring and via flag_version() (which advances only on a successful apply).

Multi-namespace fan-in: ExdClientGroup

A process holding flags from many flag namespaces (an aggregator service, a multi-tenant agent) doesn't have to open one SSE connection per flag namespace. ExdClientGroup opens a single connection subscribing to every member at once and dispatches each version / resync event to the matching per-namespace inner client:

use exd_client::{EvalContext, ExdClientGroup, FlagFilter};

let group = ExdClientGroup::builder()
.server("https://flags.example.com")
.api_token("exd_read_…") // must hold manifest.read on every member
.member("billing", "production") // all flags
.member_with_flags("growth", "production", ["onboarding"]) // allowlist
.member("experiments", "production")
.working_tree_root("/var/cache/exd") // each member gets a subdir under this
.build()
.await?;

let ctx = EvalContext::new();
let billing = group.client("billing").unwrap();
let on = billing.bool_flag("checkout-redesign", &ctx, false).await;

Each group.client(slug) returns a &ExdClient with the full eval surface; refresh is driven by the group's shared SSE consumer so there's no per-member poll task. The server's connection cap (16 per token) and the per-token rate limits apply once per group, not once per member, so a fleet aggregating 30 flag namespaces stays well under the budget. Per-member apply mutexes ensure one slow lint on flag namespace A does not delay an event for flag namespace B; per-member state isolation guarantees a malformed delta on A leaves B's Namespace untouched.

Builder constraints:

  • server and api_token are required; at least one member is required (else ExdError::MissingField).
  • Duplicate member slugs return ExdError::ConflictingNamespaceSources from build().
  • The token must hold manifest.read on every listed flag namespace; the server fails the entire connection with 403 otherwise.
  • working_tree_root is optional. When set, each member's working tree lives at <root>/<slug>/; when unset, members default to per-process temp directories the same way ExdClient does.

group.close().await aborts the SSE consumer and the per-member safety-net refresh tasks. After close(), evaluation continues to work against the last successfully loaded manifest for each member; only the live refresh stops.


HTTP transport

Every network surface in the SDK — the initial fetch, the conditional refresh poll, the SSE consumer, the v2 closure-delta snapshot fallback, and the ManifestClient write path — routes through a single, caller-selectable HttpBackend value:

VariantNotes
HttpBackend::InProcessIn-process reqwest. Default. Built on hyper + rustls; no system OpenSSL or curl/tar dependency at runtime (apart from tar, which is still needed to extract the manifest archive).
HttpBackend::CurlSubprocess curl. Useful when you want existing ~/.curlrc proxy / TLS configuration to apply without reconfiguring reqwest, or when the target environment cannot ship in-process TLS.

Selection precedence (highest first):

  1. The explicit .http_backend(HttpBackend::Curl) builder method on ExdClientBuilder, ExdClientGroupBuilder, NamespaceBuilder, or ManifestClient::with_http_backend(...).
  2. The EXD_HTTP_BACKEND environment variable. Accepted values: curl, in-process (or inprocess/reqwest). Anything else falls through to the compiled default.
  3. The compiled-in default — InProcess when the default-on http-in-process Cargo feature is enabled, otherwise Curl.
use exd_client::{ExdClient, HttpBackend};

let client = ExdClient::builder()
.server("https://flags.example.com")
.namespace("payments")
.api_token("exd_read_…")
.environment("production")
.http_backend(HttpBackend::Curl) // override the default
.build()
.await?;

For the synchronous Namespace surface, use Namespace::from_uri_with_options(uri, &LoadOptions::new().with_http_backend(HttpBackend::Curl).with_auth(UriAuth::Bearer(token))). For the server-mode constructor, Namespace::builder().http_backend(...).

The chosen backend is recorded on the underlying Namespace so every subsequent refresh() (manual or background) uses the same transport the initial load used.

tar and git are always invoked as subprocesses regardless of backend — there is no in-process replacement for either.


Offline / From-File Mode

For agents, batch jobs, or CI environments that need to load a previously downloaded manifest archive from disk, use the from_manifest_file convenience constructor. No background polling occurs.

// No server connection — useful for agents and batch jobs
let client = ExdClient::from_manifest_file(
"/path/to/payments-v6.tar.gz",
"production",
)?;

The manifest archive is the same .tar.gz format produced by ManifestClient::pull. The environment argument selects which environment's rules are active.

This is equivalent to constructing a Namespace via Namespace::from_archive(path) and passing it to .namespace_inline(...); the convenience is that from_manifest_file skips the explicit Namespace construction step. Like all namespace_inline clients, the result is static for the process lifetime — there is no polling. For runtime refresh from a local-only source, use .namespace_uri("file:///path/to/manifest.tar.gz") instead.


Dry Run Mode

Dry run mode is useful for testing or for situations where you want a client instance to have zero side effects.

let client = ExdClient::builder()
.server("https://flags.example.com")
.namespace("payments")
.environment("production")
.api_token("exd_read_...")
.dry_run(true) // returns defaults for all flags, emits no telemetry
.build()
.await?;

Dry run is independent of the namespace source: it works the same way for namespace_inline, namespace_uri, and server.

Behaviour in dry run mode:

  • All typed helpers return the caller-supplied default.
  • eval returns an EvalResult with rule_matched: RuleMatched::SdkDefault.
  • No telemetry spans are created or exported.
  • The background poll task still starts so that manifest_cache_path (if set) is populated, but evaluation is short-circuited before consulting the manifest.

Telemetry Sink

When the telemetry Cargo feature is enabled, you can attach an OTLP sink to export flag evaluation spans to any OpenTelemetry-compatible collector.

use exd_client::telemetry::OtlpSink;

let client = ExdClient::builder()
// ... other config ...
.telemetry_sink(
OtlpSink::builder()
.endpoint("http://otel-collector:4318/v1/traces")
.batch_size(100)
.flush_interval(Duration::from_secs(10))
.build()
)
.build()
.await?;

OtlpSink options:

OptionTypeDefaultDescription
endpoint&strRequired. OTLP HTTP endpoint for traces.
batch_sizeusize100Maximum number of spans per export batch.
flush_intervalDuration10sHow often the batch is flushed even if batch_size is not reached.

Span attributes emitted per evaluation:

  • exd.namespace
  • exd.environment
  • exd.flag_key
  • exd.variant_key
  • exd.rule_matched
  • exd.flag_version
  • exd.entity_id

Agent Manifest Operations

ManifestClient is a write-path client for managing manifest files. It requires a write-scoped API token (exd_write_...). The same code path backs the exd manifest push|pull|versions CLI subcommands — they reuse ManifestClient over a current-thread Tokio runtime so the SDK and the CLI never drift out of sync on transport behavior.

use exd_client::{ManifestClient, VersionRef, IfVersion};

let mgr = ManifestClient::new("https://flags.example.com", "exd_write_...");
// Optionally pin the HTTP transport (default: HttpBackend::from_env_or_default()):
// let mgr = mgr.with_http_backend(exd_client::HttpBackend::Curl);

// Pull current manifest to a local directory
let meta = mgr.pull("payments", VersionRef::Latest, "./payments").await?;
println!("downloaded version {}", meta.version);

// Lint local manifest tree
let report = exd_client::lint_dir("./payments")?;
if report.has_errors() {
for diag in report.errors() {
eprintln!("[{}] {}:{} — {}", diag.code, diag.file, diag.line, diag.message);
}
return Err(anyhow::anyhow!("lint failed"));
}

// Upload with optimistic concurrency
mgr.push("payments", "./payments", Some(IfVersion(meta.version))).await?;

// Diff two versions
let diff = mgr.diff("payments", VersionRef::Version(5), VersionRef::Latest).await?;
println!("{}", diff.to_string());

// List version history
let versions = mgr.versions("payments").await?;

VersionRef

VariantDescription
VersionRef::LatestThe most recently pushed version.
VersionRef::Version(u64)A specific numbered version.

IfVersion (optimistic concurrency)

Passing Some(IfVersion(n)) to push causes the server to reject the upload with 409 Conflict if the current server version is not n. This prevents accidentally overwriting a version that was pushed by another process between your pull and push. Pass None to skip the check (force push).

lint_dir

lint_dir validates a local manifest directory against the exd TOML schema. It returns a LintReport with zero or more LintDiagnostic entries. Each diagnostic has:

FieldTypeDescription
code&strMachine-readable error code (e.g. "E001").
filePathBufPath to the file containing the issue.
lineusize1-based line number.
messageStringHuman-readable description.
severitySeverityError or Warning.

Background Refresh (synchronous SDK)

The synchronous Namespace (used by Namespace::from_dir and Namespace::from_uri) records its source at load time and ships two refresh primitives. Both re-run the lint pipeline against the original source and atomically swap the typed model into place on success; in-flight eval calls hold their own Arc snapshot and are unaffected by an in-progress refresh.

use exd_client::Namespace;
use std::time::Duration;

let namespace = Namespace::from_uri(
"git+https://github.com/acme/feature-flags.git#main:payments",
)?;

// One-time blocking refresh — drive this from a webhook, an admin endpoint,
// or a scheduled task.
namespace.refresh()?;

// Periodic background refresh — the returned handle keeps the thread alive;
// dropping it signals the thread to stop and joins it.
let handle = namespace.start_periodic_refresh(Duration::from_secs(30));
assert!(handle.is_running());
MethodBehavior
Namespace::refresh()Synchronous re-fetch + lint. Returns Ok(()) on success, Err(LoadError) (Io, Fetch, Lint, or SlugMismatch) on failure. Previous typed state is preserved on any error.
Namespace::start_periodic_refresh(interval)Spawns a background thread that calls refresh() every interval. Returns a RefreshHandle. Failures inside the loop are recorded via Namespace::last_refresh_error() instead of crashing the thread.
Namespace::flag_version()Monotonic counter; starts at 1, increments by one on every successful refresh. Surfaced through EvalResult::flag_version.
Namespace::last_refresh_error()Some(message) after the most recent failed background refresh, None after a subsequent success or when no refresh has run.
Namespace::environment_meta(env) / public_evaluate(env)Returns the per-environment metadata declared in [namespace.environments.<env>] (today: just public_evaluate: bool, defaulting to false). None when the environment is not declared. The exd-server public-evaluation gate uses this to decide whether to admit namespace-client traffic.
RefreshHandle::stop() / DropSignals the background thread to stop and waits for it to exit. stop returns thread::Result<()> so callers can observe a panic; Drop swallows it.

LoadError::SlugMismatch is returned when the new manifest's [namespace].slug differs from the slug originally loaded — a slug change is a different namespace, not a refresh, and the SDK refuses to silently change identity under the caller's feet.

The Namespace is Clone + Send + Sync; clones share one underlying Arc<NamespaceState>, so any task evaluating against the flag namespace observes refreshes immediately. There is no need to wrap the value in ArcSwap or Mutex yourself.

Loading from an exd server (Namespace::builder())

The synchronous Namespace ships a builder for the manifest-distribution deployment mode (see server-api § Deployment modes). The builder fetches <server>/api/v1/tenants/<tenant>/namespaces/<slug>/manifest with a bearer-token Authorization header, treats the response body as a tar.gz, and routes it through the existing loader → lint → typed-model pipeline. No new URI scheme is introduced — the auth seam is the builder, not the URI.

use exd_client::Namespace;
use std::time::Duration;

let namespace = Namespace::builder()
.server("https://flags.example.com")
.namespace("payments")
.api_token(std::env::var("EXD_TOKEN")?) // exd_read_... or exd_write_...
.build()?;

// Refresh and periodic-refresh work identically to from_uri-loaded namespaces;
// the recorded source is the (server, namespace, token) tuple, so background
// refresh re-hits the same /manifest endpoint with the same credential.
namespace.refresh()?;
let _handle = namespace.start_periodic_refresh(Duration::from_secs(30));
MethodRequiredDescription
.server(impl Into<String>)yesBase URL of the exd server (no trailing /api/v1). Trailing slashes are tolerated.
.namespace(impl Into<String>)yesNamespace slug. The builder fetches <server>/api/v1/tenants/<tenant>/namespaces/<slug>/manifest.
.api_token(impl Into<String>)yesBearer credential. v0 of the server accepts namespace-read, namespace-write, tenant-admin, and superadmin tokens on the manifest-download endpoint. The caller is responsible for reading the secret out of the environment (e.g. std::env::var("EXD_TOKEN")?); the builder does not consult env vars itself.
.build()Performs the initial fetch + lint and returns Result<Namespace, LoadError>. Missing required fields surface as LoadError::Builder(String).

The builder is the only auth surface for the server case. Namespace::from_uri("https://…") continues to work for unauthenticated tarball URLs (e.g., a CDN-hosted manifest), but the SDK does not extend that path with a bearer header — keep credentials out of URI strings. If a future deployment needs a different auth scheme (mTLS, OIDC client credentials), it is added as another builder method, not as a new URI scheme.

Both surfaces ship today and use the same wire contract — <server>/api/v1/tenants/<tenant>/namespaces/<slug>/manifest with a bearer-token Authorization header. The synchronous Namespace::builder() calls into the subprocess loader directly; the asynchronous ExdClient::builder().server(…).namespace(…).api_token(…) does the same thing inside tokio::task::spawn_blocking, plus a Tokio-spawned poll task. Use the sync builder for short-lived processes (CLIs, batch jobs, the git pre-receive hook) and the async builder for long-running services that already host a Tokio runtime.

Thread Safety

ExdClient implements Clone + Send + Sync. The internal manifest cache is stored behind a tokio::sync::RwLock; concurrent reads never block each other. A single client instance can be safely shared across all Tokio tasks either by wrapping it in an Arc<ExdClient> or by cloning it — both are cheap because the clone shares the underlying state.

use std::sync::Arc;

let client = Arc::new(ExdClient::builder()/* ... */.build().await?);

for _ in 0..10 {
let c = Arc::clone(&client);
tokio::spawn(async move {
let enabled = c.bool_flag("checkout-redesign", &ctx, false).await;
// ...
});
}

Error Handling Philosophy

Evaluation methods (bool_flag, string_flag, int_flag, float_flag) never return Err and never panic. They always return a concrete value — the caller-supplied default if anything goes wrong. This is intentional: flag evaluation must never crash the application or disrupt the hot path.

Errors are:

  1. Logged at WARN level via the tracing crate, so they surface in your existing structured log pipeline.
  2. Reflected in the rule_matched field as RuleMatched::SdkDefault when using the structured eval API, giving you a programmatic signal that the SDK fell back to the default without throwing.

For operations where failure is expected to be handled explicitly, methods return a typed Result. ExdClient::builder().build() and ExdClient::close() return Result<_, ExdError>. The ManifestClient write-path methods (pull, push, versions, diff) return Result<_, ManifestError> — the variants distinguish transport failures, server errors with structured codes, optimistic-concurrency conflicts (VersionConflict { current_version }), lint failures (LintFailed { message }), local I/O, and malformed responses.