# ShipSilently — complete documentation Cloudflare-native feature flag service. This file is the full documentation set in one document, intended for LLMs and coding agents. Every code block is imported verbatim from files that are typechecked against the SDK sources in CI. Base URL: https://api.shipsilently.com --- ## Authentication SDK / evaluation endpoints (`/v1/*`) authenticate with a header: X-API-Key: sk_live_… Keys are scoped to exactly one environment, which is how the SDK knows which flags to serve; there is no environment parameter anywhere in the API. **There is currently one kind of key and it is a server key.** It can read every flag configuration in its environment, including targeting rules and segment definitions. There is no public, evaluate-only client key type yet, so it must not be shipped to a browser or mobile bundle. For client-side rendering, evaluate on your server and send down only the resolved values. Keys are rejected if supplied in the query string. Browser `EventSource` cannot set headers, so SSE uses a single-use 60-second ticket instead: ```bash curl -X POST https://api.shipsilently.com/v1/stream/ticket -H "X-API-Key: sk_live_xxx" # → { "ticket": "…", "expiresIn": 60 } # then: new EventSource("https://api.shipsilently.com/v1/stream?ticket=…") ``` The management API (`/api/v2/*`) uses an access token sent raw, with **no** `Bearer` prefix: Authorization: --- ## Core concepts ### Flags A flag has a **key**, a **type** (`boolean` | `string` | `number` | `json`), a **default value**, an **enabled** switch (the kill switch), and an ordered list of **rules**. ### Rules All targeting lives in rules. A rule has conditions (all must match), a serve value, and an optional rollout percentage. **There is no flag-level rollout.** To ramp a flag to 10% of everyone, add a rule with no conditions and a rollout of 10. ### Evaluation order 1. If the flag is disabled → return the flag's default, reason `flag_disabled`. 2. For each rule in order: - conditions don't all match → skip to next rule; - match and no rollout → serve the rule's value, reason `rule_match`; - match and inside the rollout → serve the rule's value, reason `rollout`; - match but **outside** the rollout → **fall through to the next rule**. 3. Nothing decisive → return the flag's default, reason `default`. The fall-through in the last bullet is the most commonly misunderstood part of the model: missing a rule's percentage does not end evaluation. ### Bucketing `bucket = int(sha256("{userId}:{flagId}")[0:8], 16) % 100`, and the user is in the rollout when `bucket < percentage`. - Sticky: same user + same flag → same bucket, always. - Per-flag: the flag id is in the hash, so membership is independent per flag. - Monotonic: raising the percentage only ever adds users. - Identical on the server and in every SDK (guarded by a differential test). - The bucketing key is `userId`, falling back to `email`, then the empty string — which buckets every anonymous user identically. ### Evaluation context A flat bag of attributes. Values may be strings, numbers, or booleans; nested objects and arrays are not part of the attribute model. ### Segments A reusable audience referenced from rules via the `segment_match` operator. Membership: **excluded** keys lose to nothing, **included** keys win over rules, and rule-based membership matches if **any** rule matches (a rule matches when **all** its conditions do). The user key for included/excluded lists resolves as `key` → `userId` → `email` — note this differs from rollout bucketing, which ignores `key`. An unknown segment key matches nobody. A segment rule with no conditions matches nobody (unlike a *flag* rule with no conditions, which matches everyone). ### Evaluation reasons | reason | meaning | | --- | --- | | `rule_match` | A rule's conditions matched; it had no rollout. | | `rollout` | A rule matched and the user fell inside its percentage. | | `flag_disabled` | Kill switch off; the flag's default was returned. | | `default` | No rule was decisive; the flag's default was returned. | | `flag_not_found` | No such flag; *your* default was returned. | `flag_not_found` is produced client-side: the REST API answers an unknown key with HTTP 404 rather than a result body, since the server cannot know your default. ### Result shape ```ts interface EvaluationResult { flagKey: string; value: boolean | string | number | Record; reason: 'default' | 'rule_match' | 'rollout' | 'flag_disabled' | 'flag_not_found'; ruleId?: string; // only on rule_match and rollout } ``` --- ## Targeting operators These are the exact identifiers the API accepts. They are short, not spelled out — `eq`, not `equals`. | operator | matches when | value type | | --- | --- | --- | | `eq` | attribute equals value | literal | | `neq` | attribute does not equal value | literal | | `contains` | substring present | string | | `not_contains` | substring absent | string | | `starts_with` | prefix match | string | | `ends_with` | suffix match | string | | `gt` / `gte` / `lt` / `lte` | numeric comparison | number | | `in` | value appears in list | **array** | | `not_in` | value absent from list | **array** | | `regex` | unanchored pattern match | string | | `segment_match` | user is in any named segment | segment key or list | Semantics that matter: - Comparisons are **string-based**; the context value is stringified first. Only `gt`/`gte`/`lt`/`lte` coerce to numbers. - A **missing attribute never matches**, including for negative operators: `neq` against an absent attribute is `false`, not `true`. - `in` and `not_in` require an array; given a non-array, **both** return false. - `regex` is unanchored (`test()` semantics) and ReDoS-hardened: patterns over 256 chars, inputs over 4096 chars, invalid patterns, and nested-quantifier shapes like `(a+)+` are all rejected as non-matches. - Conditions within a rule are ANDed. For OR, use multiple rules or a segment. - `segment_match` ignores `attribute`; set it to `"segmentKey"` by convention. --- ## JavaScript / TypeScript SDK (`@shipsilently/node`) Async, network-backed client with an in-memory cache. For evaluation with no network on the hot path, use the Edge SDK instead. ### Client setup ```ts // flags.ts — create one client at startup and reuse it for the process lifetime. // The client holds an in-memory flag cache and (optionally) a background // analytics buffer, so constructing one per request throws both away. import { ShipSilentlyClient } from '@shipsilently/node'; export const flags = new ShipSilentlyClient({ apiKey: process.env.SHIPSILENTLY_KEY!, }); ``` Config: `apiKey` (required), `apiUrl` (defaults to https://api.shipsilently.com), `fetch`, `analytics`, `retry`. ### Evaluate one flag ```ts // One flag, one network call. The third argument is both the fallback AND the // type source — `useNewFlow` is `boolean`, `heroVariant` is `string`, inferred // with no generics to write out. import { flags } from './client'; interface User { id: string; plan: string; country: string; } export async function renderCheckout(user: User) { const useNewFlow = await flags.evaluate( 'new-checkout-v2', { userId: user.id, plan: user.plan, country: user.country }, false, ); const heroVariant = await flags.evaluate('hero-variant', { userId: user.id }, 'control'); return { useNewFlow, heroVariant }; } ``` ### Evaluate all flags, then read synchronously ```ts // Fetch every flag once at the request boundary, then read synchronously. // This is the pattern you want on a hot path: one round trip, then `get()` // calls that never touch the network. import { flags } from './client'; export async function handleRequest(userId: string, plan: string) { // One call hydrates the in-memory cache for this context. await flags.evaluateAll({ userId, plan }); // Synchronous reads — no await, served from cache. const showBanner = flags.get('promo-banner', false); const tier = flags.get('pricing-tier', 'standard'); const limits = flags.get('rate-limits', { rpm: 60 }); // Full snapshot, including each flag's `reason` and matched `ruleId`. const snapshot = flags.getAll(); return { showBanner, tier, limits, snapshot }; } ``` `get()` never fetches — it reads the cache hydrated by `evaluateAll()`, `evaluate()`, or `stream()`. Calling it cold returns your default with reason `flag_not_found`. ### Streaming ```ts // Long-running services should stream. The client opens an SSE connection and // keeps the local cache warm, so `get()` always reflects the current config // without polling. import type { StreamConnectionState } from '@shipsilently/node'; import { flags } from './client'; const unsubscribe = flags.stream( { userId: 'system' }, (updated) => { console.log('flags refreshed:', Object.keys(updated)); }, { // Cadence used whenever SSE is unavailable. Default: 30_000. pollingIntervalMs: 30_000, // Observe the transport. `reconnecting` means SSE dropped but polling is // keeping data fresh — it is not an outage, and not worth paging on. onStateChange: (state: StreamConnectionState) => { console.log('[shipsilently] transport:', state); }, }, ); // On shutdown: stop the stream, then flush any buffered analytics. process.on('SIGTERM', async () => { unsubscribe(); await flags.flushAnalytics(); flags.close(); }); ``` Connection states: `streaming`, `reconnecting` (SSE lost, polling covers the gap — not actionable), `polling` (terminal: free plan, no `EventSource`, or `retry.enabled: false`). ### Analytics ```ts // Evaluation analytics are ON by default. Every `get()` and every locally // served fallback is buffered and flushed in the background — that is what // powers stale-flag detection in the dashboard. // // Networked `evaluate()` successes are NOT double-counted: the server already // recorded those, so the client only reports what only it can see. import { ShipSilentlyClient } from '@shipsilently/node'; export const flags = new ShipSilentlyClient({ apiKey: process.env.SHIPSILENTLY_KEY!, analytics: { enabled: true, flushIntervalMs: 30_000, // default 30s, floor 1s maxBatchSize: 100, // force a flush at this many buffered events maxBufferSize: 5_000, // drop events past this (back-pressure guard) samplingRate: 1.0, // 1.0 = every evaluation; 0.1 = 10% }, }); // Opt out entirely — no buffer, no timers, no analytics requests. export const quiet = new ShipSilentlyClient({ apiKey: process.env.SHIPSILENTLY_KEY!, analytics: { enabled: false }, }); // Serverless / short-lived processes should flush before the runtime freezes, // otherwise buffered events die with the isolate. Returns the count sent. export async function beforeExit(): Promise { return flags.flushAnalytics(); } ``` Analytics are on by default. Networked `evaluate()` successes are not double-counted — the server already recorded those, so the client reports only what only it can see. Flush before a serverless isolate is reclaimed. ### Retry and failure behavior ```ts // Reconnection and backoff. Defaults are sensible; tune only if you have a // reason. The stream reconnects with full-jitter exponential backoff and never // gives up, so a flag outage never requires an app restart. import { ShipSilentlyClient } from '@shipsilently/node'; export const flags = new ShipSilentlyClient({ apiKey: process.env.SHIPSILENTLY_KEY!, retry: { enabled: true, // false = one failure downgrades to polling permanently baseDelayMs: 1_000, // first reconnect delay maxDelayMs: 60_000, // backoff ceiling heartbeatTimeoutMs: 90_000, // server beats every 30s; tolerate two misses }, }); // Inject fetch to test failure paths without a network. export const offline = new ShipSilentlyClient({ apiKey: 'sk_live_test', fetch: async () => new Response('boom', { status: 500 }), }); ``` ### Method reference | method | returns | notes | | --- | --- | --- | | `evaluate(key, ctx, default)` | `Promise` | One call, single attempt, no retry | | `evaluateAll(ctx)` | `Promise>` | Hydrates the cache | | `get(key, default)` | `T` | Synchronous cache read | | `getAll()` | `Record` | Cache snapshot | | `stream(ctx, onChange, options?)` | `() => void` | Returns unsubscribe | | `flushAnalytics()` | `Promise` | Resolves with the count sent | | `close()` | `void` | Releases timers and connections | --- ## Edge SDK (`@shipsilently/edge`) Local evaluation. After one async `load()`, every decision is a synchronous, in-memory, sub-millisecond call. Shares one evaluation engine (`@shipsilently/eval-core`) with the server, so results are identical. ### Hosted (zero config) ```ts // Zero-config edge client. After one async `load()`, every evaluation is a // synchronous, in-memory decision against the flag blob — no network, no await, // no per-request latency. This is the right client for Workers and any hot path. import { EdgeClient } from '@shipsilently/edge'; const flags = EdgeClient.init({ apiKey: process.env.SHIPSILENTLY_KEY! }); // Load once at startup (or at the top of a Worker's fetch handler). await flags.load(); // Typed accessors — each returns the default if the flag is missing or is a // different type, so a mistyped flag can never crash the caller. const enabled = flags.getBoolean('new-checkout', { userId: 'u_123' }, false); const variant = flags.getString('hero-variant', { userId: 'u_123' }, 'control'); const limit = flags.getNumber('rate-limit', { userId: 'u_123' }, 60); const config = flags.getObject('checkout-config', { userId: 'u_123' }, { retries: 3 }); // Full result when you need the reason or the matched rule. const result = flags.evaluate('new-checkout', { userId: 'u_123' }, false); console.log(result.reason, result.ruleId); // Refresh in the background when the control plane changes. const unsubscribe = flags.subscribe(() => { console.log('flag blob updated'); }); export { enabled, variant, limit, config, unsubscribe }; ``` Before `load()` resolves, every evaluation returns its default. Use `ready()` to tell "not loaded" from "genuinely off". ### Bring your own KV ```ts // Bring-your-own KV: the flag blob lives in *your* Cloudflare KV namespace, // read in-isolate with zero HTTP and zero auth on the hot path. ShipSilently // stays the control plane; you own the data path. // // Reads are a single `KV.get(key, 'json')`. You keep the key fresh with // `syncToKv()` from a Cron Worker — see sync-worker.ts. import { EdgeClient, kv } from '@shipsilently/edge'; interface Env { MY_KV: { get(key: string, type: 'json'): Promise }; SHIPSILENTLY_ENV_ID: string; } export default { async fetch(request: Request, env: Env): Promise { const flags = EdgeClient.init({ storage: kv(env.MY_KV), // Required for the kv provider — it forms the key `flags:{envId}`. envId: env.SHIPSILENTLY_ENV_ID, }); await flags.load(); const userId = new URL(request.url).searchParams.get('user') ?? 'anon'; const useNewFlow = flags.getBoolean('new-checkout', { userId }, false); return Response.json({ useNewFlow, ready: flags.ready() }); }, }; ``` Keep the key fresh from a Cron Worker: ```ts // Keeps a bring-your-own KV namespace mirroring the ShipSilently control plane. // Run it on a Cron trigger (every minute is plenty). // // `syncToKv` overwrites and never deletes, so readers never observe a // transient missing key. If the fetch fails it returns null and leaves your // existing KV value untouched — serve stale rather than wipe. import { syncToKv } from '@shipsilently/edge'; interface Env { MY_KV: { put(key: string, value: string): Promise }; SHIPSILENTLY_KEY: string; SHIPSILENTLY_ENV_ID: string; } export default { async scheduled(_event: unknown, env: Env): Promise { const blob = await syncToKv(env.MY_KV, { apiKey: env.SHIPSILENTLY_KEY, envId: env.SHIPSILENTLY_ENV_ID, }); if (!blob) { console.warn('[shipsilently] sync failed; KV left at last-known-good'); return; } console.log(`[shipsilently] synced ${blob.flags.length} flags`); }, }; ``` `syncToKv` overwrites and never deletes, so readers never see a transient missing key; a failed sync leaves the previous value in place. ### Static / offline (ideal for tests) ```ts // Fully offline: hand the client a blob you already have. No network, no KV. // Ideal for unit tests, local development, and air-gapped builds where the // blob is baked in at deploy time. import { EdgeClient, staticProvider } from '@shipsilently/edge'; import type { FlagCacheBlob } from '@shipsilently/edge'; const blob: FlagCacheBlob = { orgId: 'org_test', envId: 'env_test', updatedAt: 0, segments: [], flags: [ { uuid: '00000000-0000-4000-8000-000000000001', key: 'new-checkout', type: 'boolean', enabled: true, defaultValue: false, rules: [ { uuid: '00000000-0000-4000-8000-000000000002', sortOrder: 0, name: 'internal staff', conditions: [{ attribute: 'email', operator: 'ends_with', value: '@example.com' }], serveValue: true, rolloutPercentage: null, }, ], }, ], }; const flags = EdgeClient.init({ storage: staticProvider(blob) }); await flags.load(); // true — the rule matches. export const staff = flags.getBoolean('new-checkout', { email: 'dev@example.com' }, false); // false — no rule matches, so the flag's default value is served. export const outsider = flags.getBoolean('new-checkout', { email: 'someone@else.com' }, false); ``` ### Configuration `apiKey` (hosted), `envId` (required for `kv`), `storage`, `apiUrl`, `pollingIntervalMs`, `fetch`, `onError`. **`onError` defaults to a no-op**, so a bad key or unreachable API shows up only as flags that never leave their defaults. Always pass one that logs. ### Method reference `EdgeClient.init(config)`, `load()`, `ready()`, `environmentId()`, `getBoolean/getString/getNumber/getObject(key, ctx, default)`, `evaluate(key, ctx, default)`, `getAll(ctx)`, `subscribe(onChange)`, `close()`. --- ## React SDK (`@shipsilently/react`) ```tsx // Mount the provider once, above anything that reads a flag. It opens a single // stream for the given context and re-renders consumers when flags change. // // The provider compares the context's *contents*, not its identity — you do not // need to memoize the object you pass. import { ShipSilentlyProvider } from '@shipsilently/react'; export function App({ user }: { user: { id: string; plan: string } }) { return ( ); } function Checkout() { return
checkout
; } ``` The provider compares the context's *contents*, not its identity, so callers do not need to memoize it. It creates exactly one client and closes it on unmount; changing `config` after mount does not rebuild it. ```tsx // Three hooks, all of which must be used inside . import { useFlag, useFlags, useShipSilentlyClient } from '@shipsilently/react'; // `useFlag` infers its return type from the default — `dark` is boolean, // `variant` is string. No generic parameters to write. export function Hero() { const dark = useFlag('dark-mode', false); const variant = useFlag('hero-variant', 'control'); return
{variant}
; } // `useFlags` gives the whole map plus a first-load indicator. Use `loading` to // avoid flashing the default value before the first evaluation lands. export function DebugPanel() { const { flags, loading } = useFlags(); if (loading) return

loading flags…

; return (
    {Object.values(flags).map((f) => (
  • {f.flagKey}: {String(f.value)} ({f.reason})
  • ))}
); } // Escape hatch to the underlying client — for `evaluate()` against a one-off // context, or an explicit analytics flush. export function FlushButton() { const client = useShipSilentlyClient(); return ; } ``` | hook | returns | | --- | --- | | `useFlag(key, default)` | `T`, inferred from the default | | `useFlags()` | `{ flags, loading }` | | `useShipSilentlyClient()` | the underlying client | The package ships a `'use client'` banner, so it is safe to import from Next.js Client Components. For Server Components and route handlers, use the JS SDK. --- ## Go SDK (`github.com/shipsilently/shipsilently-go`) ```go package main import ( "context" "fmt" "os" shipsilently "github.com/shipsilently/shipsilently-go" ) func main() { client := shipsilently.New(shipsilently.Config{ APIKey: os.Getenv("SHIPSILENTLY_KEY"), // APIURL defaults to https://api.shipsilently.com }) ctx := context.Background() userCtx := shipsilently.UserContext{ "userId": "u_123", "plan": "pro", "country": "US", } // Evaluate is a generic package-level function, not a method — Go does not // allow type parameters on methods. The default value fixes T, so // useNewFlow is a bool and variant is a string, with no casting. useNewFlow, err := shipsilently.Evaluate(ctx, client, "new-checkout-v2", userCtx, false) if err != nil { // err is non-nil on a failed lookup, but the returned value is still // safe to use: it is the last-known-good cached value, or your default. fmt.Fprintf(os.Stderr, "flag lookup degraded: %v\n", err) } variant, _ := shipsilently.Evaluate(ctx, client, "hero-variant", userCtx, "control") fmt.Println(useNewFlow, variant) } ``` `Evaluate` is a package-level generic function, not a method — Go does not allow type parameters on methods. On a failed request the client returns the last-known-good cached value with a **nil** error. A non-nil error means nothing was cached either, and the value is your compile-time default. `DisableCache: true` restores strict errors. ```go package main import ( "context" "log" "os" "time" shipsilently "github.com/shipsilently/shipsilently-go" ) func main() { client := shipsilently.New(shipsilently.Config{ APIKey: os.Getenv("SHIPSILENTLY_KEY"), // Reconnection backoff. Zero values use the defaults shown here. Retry: shipsilently.RetryConfig{ BaseDelay: 1 * time.Second, MaxDelay: 60 * time.Second, HeartbeatTimeout: 90 * time.Second, }, // Refresh cadence when streaming is plan-gated (HTTP 402). PollingInterval: 30 * time.Second, }) ctx, stop := context.WithCancel(context.Background()) defer stop() // Fetch everything once for the process-level context. all, err := client.EvaluateAll(ctx, shipsilently.UserContext{"userId": "system"}) if err != nil { log.Printf("initial load degraded: %v", err) } log.Printf("loaded %d flags", len(all)) // Then keep it fresh. cancel() closes the stream. cancel, err := client.Stream(ctx, shipsilently.UserContext{"userId": "system"}, func(flags map[string]any) { log.Printf("flags refreshed: %d", len(flags)) }) if err != nil { log.Fatalf("stream: %v", err) } defer cancel() select {} } ``` Config: `APIKey`, `APIURL` (defaults to https://api.shipsilently.com), `HTTP`, `Retry`, `PollingInterval`, `DisableCache`, `Logger`. A `*Client` is safe for concurrent use; create one per process. --- ## OpenFeature ```ts // ShipSilently as an OpenFeature provider. Call sites stay vendor-neutral — // swapping providers is a one-line change and your evaluation code never moves. // // `@openfeature/server-sdk` is an OPTIONAL peer dependency: install it only if // you import this subpath. The core SDK has no OpenFeature dependency. // // npm install @shipsilently/edge @openfeature/server-sdk import { OpenFeature } from '@openfeature/server-sdk'; import { ShipSilentlyProvider } from '@shipsilently/edge/openfeature'; // Accepts the same config as EdgeClient.init — hosted, kv(), or staticProvider(). const provider = new ShipSilentlyProvider({ apiKey: process.env.SHIPSILENTLY_KEY! }); // setProviderAndWait runs initialize(), which loads the flag blob. await OpenFeature.setProviderAndWait(provider); const client = OpenFeature.getClient(); // `targetingKey` maps to the context's `key`; other primitive attributes pass // through untouched for targeting rules. export const enabled = await client.getBooleanValue('new-checkout', false, { targetingKey: 'u_123', plan: 'pro', }); // Evaluation details carry the mapped OpenFeature reason: // rule_match → TARGETING_MATCH rollout → SPLIT // flag_disabled → DISABLED flag_not_found → ERROR (FLAG_NOT_FOUND) export const details = await client.getBooleanDetails('new-checkout', false, { targetingKey: 'u_123', }); ``` `@openfeature/server-sdk` is an optional peer dependency, needed only for this subpath. Context mapping: `targetingKey` → `key`; primitive attributes pass through; nested objects and arrays are dropped. **`targetingKey` is not `userId`.** Rollout bucketing reads `userId` (then `email`) and ignores `key`, so set `userId` explicitly in the context if you use percentage rollouts through OpenFeature. Reason mapping: `rule_match`→`TARGETING_MATCH`, `rollout`→`SPLIT`, `flag_disabled`→`DISABLED`, `default`→`DEFAULT`, `flag_not_found`→`ERROR` (`FLAG_NOT_FOUND`). The deciding rule's UUID is surfaced as `variant`. Use `setProviderAndWait`, not `setProvider` — `initialize()` is what loads the blob. --- ## REST API (evaluation endpoints) | method | path | purpose | | --- | --- | --- | | POST | `/v1/evaluate` | Evaluate one flag | | POST | `/v1/evaluate/batch` | Evaluate every flag for a context | | GET | `/v1/blob` | Whole flag config for local evaluation (ETag-revalidated) | | POST | `/v1/stream/ticket` | Mint a single-use SSE ticket | | GET | `/v1/stream` | SSE stream of flag changes (heartbeat every 30s) | | POST | `/v1/analytics/events` | Submit buffered evaluation analytics | ```bash curl -X POST https://api.shipsilently.com/v1/evaluate \ -H "X-API-Key: $SHIPSILENTLY_KEY" \ -H "Content-Type: application/json" \ -d '{ "flagKey": "new-checkout-v2", "context": { "userId": "u_123", "plan": "pro" } }' ``` Response: ```json { "flagKey": "new-checkout-v2", "value": true, "reason": "rollout", "ruleId": "…" } ``` Batch response is wrapped: `{ "flags": { "": EvaluationResult, … } }`. ### Errors | status | meaning | | --- | --- | | 400 | Malformed body, or an API key in the query string | | 401 | Missing, invalid, or expired key | | 402 | Plan doesn't include the feature (streaming) — fall back to polling | | 403 | Key lacks permission | | 404 | No such flag in this environment | | 429 | Rate limited; honor `Retry-After` | --- ## Resilience **A failed request never downgrades a value the SDK already holds.** The cache is replaced only by a successful response; on failure the last-known-good value is served. This includes 401/403, deliberately — key rotations race caches, and an auth blip should not flip every flag back to its default. | condition | behavior | reported as | | --- | --- | --- | | Network error / timeout | Serve cached, else your default | `error_fallback` | | 5xx | Serve cached, else your default | `error_fallback` | | 401 / 403 | Serve cached, else your default; warning deduped to 1/min | `error_fallback` | | 404 | Definitive; return your default immediately | `flag_not_found` | | 402 on streaming | Fall back to polling for the subscription's life | state `polling` | | 429 | Serve cached; honor `Retry-After` | `error_fallback` | **There is no retry on the evaluation path.** `evaluate()` and `evaluateAll()` make exactly one attempt, because they sit on your request path. Retrying is the stream/poll loop's job. **Cold-start caveat.** Last-known-good only helps once something is known. A process that has never completed a successful evaluation serves your compile-time defaults, so those must be safe, tested code paths. Short-lived isolates start cold every invocation; use the Edge SDK with a KV-backed blob if that matters. **Reconnection.** `stream()` reconnects with full-jitter exponential backoff and never gives up. Backoff runs from `baseDelayMs` (1s) to `maxDelayMs` (60s). The server heartbeats every 30s; silence past `heartbeatTimeoutMs` (90s) triggers a reconnect. A healthy connection resets the ladder. Don't alert on `reconnecting`; do alert on `polling` if your plan includes streaming. --- ## Common mistakes - Using spelled-out operator names (`equals`, `greater_than`). Use `eq`, `gt`. - Expecting a flag-level rollout switch. Rollouts belong to rules. - Assuming a rule that fails its rollout ends evaluation. It falls through. - Shipping an `sk_live_` key to the browser. There is no public key type yet. - Calling `get()` before the cache is warm and reading the result as "off". - Forgetting `await flags.flushAnalytics()` in a serverless handler. - Relying on `targetingKey` alone for rollout bucketing via OpenFeature. - Omitting `onError` on the Edge SDK and never seeing provider failures. - Assuming the kill switch serves your code's default — it serves the *flag's*.