featurefly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ import { evaluateRules } from './targeting';
2
+ import { isInRollout } from './rollout';
3
+ import { assignVariation } from './experiment';
4
+ /**
5
+ * Extracts the stickiness value from a context for a given key name.
6
+ */
7
+ function getStickinessValue(context, key) {
8
+ if (!key)
9
+ return context.userId || context.workspaceId;
10
+ if (key === 'userId')
11
+ return context.userId;
12
+ if (key === 'workspaceId')
13
+ return context.workspaceId;
14
+ const attr = context.attributes?.[key];
15
+ return attr !== undefined ? String(attr) : undefined;
16
+ }
17
+ /**
18
+ * Offline / Local Evaluator Engine.
19
+ * Takes a pre-fetched `FlagDocument` and evaluates flags entirely in-memory
20
+ * without making any HTTP calls. Perfect for edge workers or serverless.
21
+ */
22
+ export class EdgeEvaluator {
23
+ constructor(document, fallbackDefaults = {}, trackingCallback) {
24
+ this.flagIndex = new Map();
25
+ this.document = document;
26
+ this.fallbackDefaults = fallbackDefaults;
27
+ this.trackingCallback = trackingCallback;
28
+ this.rebuildIndex();
29
+ }
30
+ /**
31
+ * Update the internal document with a fresh one.
32
+ */
33
+ updateDocument(document) {
34
+ this.document = document;
35
+ this.rebuildIndex();
36
+ }
37
+ rebuildIndex() {
38
+ this.flagIndex.clear();
39
+ for (const flag of this.document.flags) {
40
+ this.flagIndex.set(flag.slug, flag);
41
+ }
42
+ }
43
+ /**
44
+ * Evaluate a single flag against a context.
45
+ */
46
+ evaluate(slug, context, localOverrides = {}) {
47
+ // 1. Local overrides always win
48
+ if (slug in localOverrides) {
49
+ return { value: localOverrides[slug], reason: 'LOCAL_OVERRIDE' };
50
+ }
51
+ const flag = this.flagIndex.get(slug);
52
+ // If flag doesn't exist in document, return fallback or false
53
+ if (!flag) {
54
+ if (slug in this.fallbackDefaults) {
55
+ return { value: this.fallbackDefaults[slug], reason: 'DEFAULT' };
56
+ }
57
+ return { value: false, reason: 'DEFAULT' };
58
+ }
59
+ // Evaluate the flag based on rules, rollout, and experiments
60
+ return this.evaluateInner(flag, context);
61
+ }
62
+ /**
63
+ * Evaluate all flags in the document at once.
64
+ */
65
+ evaluateAll(context, localOverrides = {}) {
66
+ const results = {};
67
+ for (const flag of this.document.flags) {
68
+ const result = this.evaluateInner(flag, context);
69
+ results[flag.slug] = result.value;
70
+ }
71
+ // Merge in any local overrides and fallbacks
72
+ return { ...this.fallbackDefaults, ...results, ...localOverrides };
73
+ }
74
+ evaluateInner(flag, context) {
75
+ // 2. Targeting rules
76
+ if (flag.targetingRules && flag.targetingRules.length > 0) {
77
+ // Return the value of the first matching rule
78
+ const ruleValue = evaluateRules(flag.targetingRules, context);
79
+ if (ruleValue !== null) {
80
+ // If the rule has a rollout percentage, evaluate it before accepting the value
81
+ const rule = flag.targetingRules.find(r => r.value === ruleValue);
82
+ if (rule && rule.rolloutPercentage !== undefined) {
83
+ const key = context.userId || context.workspaceId;
84
+ // Use rule ID as salt to prevent hash overlap
85
+ if (isInRollout(key, { percentage: rule.rolloutPercentage, salt: rule.id })) {
86
+ return { value: ruleValue, reason: 'TARGETING_MATCH' };
87
+ }
88
+ // If the rollout fails, we don't return the rule value. We fall through.
89
+ }
90
+ else {
91
+ // No rule rollout, just standard targeting match
92
+ return { value: ruleValue, reason: 'TARGETING_MATCH' };
93
+ }
94
+ }
95
+ }
96
+ // 3. Experiment Assignment (A/B testing)
97
+ if (flag.experiment) {
98
+ const assignment = assignVariation(flag.experiment, context);
99
+ if (assignment) {
100
+ // Trigger analytics callback synchronously if provided
101
+ if (this.trackingCallback) {
102
+ try {
103
+ this.trackingCallback(assignment);
104
+ }
105
+ catch (e) {
106
+ // Ignore callback errors during evaluation
107
+ }
108
+ }
109
+ return { value: assignment.value, reason: 'EXPERIMENT_ASSIGNMENT', assignment };
110
+ }
111
+ }
112
+ // 4. Base Gradual Rollout
113
+ if (flag.rollout) {
114
+ const key = getStickinessValue(context, flag.rollout.stickinessKey);
115
+ if (isInRollout(key || '', { ...flag.rollout, salt: flag.rollout.salt || flag.slug })) {
116
+ return { value: flag.defaultValue, reason: 'PERCENTAGE_ROLLOUT' };
117
+ }
118
+ // If flag HAS a rollout config, and user is NOT in it, they get false
119
+ // regardless of defaultValue (which applies to those IN the rollout).
120
+ // If we're returning boolean, return false. Otherwise, we can't safely
121
+ // return a non-boolean default for "off". We return false as unmanaged value.
122
+ return { value: false, reason: 'DEFAULT' };
123
+ }
124
+ // 5. Default Base Value (if no targeting, no experiment, no rollout matched)
125
+ return { value: flag.defaultValue, reason: 'DEFAULT' };
126
+ }
127
+ }
@@ -0,0 +1,29 @@
1
+ import { FeatureFlyEvent, EventHandler, EventPayloadMap } from './types';
2
+ /**
3
+ * Typed event emitter for FeatureFly SDK.
4
+ * Allows consumers to subscribe to internal SDK events like flag changes,
5
+ * cache hits/misses, circuit breaker state changes, etc.
6
+ */
7
+ export declare class EventEmitter {
8
+ private readonly listeners;
9
+ /**
10
+ * Subscribe to an event. Returns an unsubscribe function.
11
+ */
12
+ on<E extends FeatureFlyEvent>(event: E, handler: EventHandler<E>): () => void;
13
+ /**
14
+ * Subscribe to an event once (auto-unsubscribes after first invocation).
15
+ */
16
+ once<E extends FeatureFlyEvent>(event: E, handler: EventHandler<E>): () => void;
17
+ /**
18
+ * Emit an event to all registered listeners.
19
+ */
20
+ emit<E extends FeatureFlyEvent>(event: E, payload: EventPayloadMap[E]): void;
21
+ /**
22
+ * Remove all listeners for a specific event, or all events if no event is specified.
23
+ */
24
+ removeAllListeners(event?: FeatureFlyEvent): void;
25
+ /**
26
+ * Get the count of listeners for a given event.
27
+ */
28
+ listenerCount(event: FeatureFlyEvent): number;
29
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Typed event emitter for FeatureFly SDK.
3
+ * Allows consumers to subscribe to internal SDK events like flag changes,
4
+ * cache hits/misses, circuit breaker state changes, etc.
5
+ */
6
+ export class EventEmitter {
7
+ constructor() {
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ this.listeners = new Map();
10
+ }
11
+ /**
12
+ * Subscribe to an event. Returns an unsubscribe function.
13
+ */
14
+ on(event, handler) {
15
+ if (!this.listeners.has(event)) {
16
+ this.listeners.set(event, new Set());
17
+ }
18
+ this.listeners.get(event).add(handler);
19
+ // Return unsubscribe function
20
+ return () => {
21
+ this.listeners.get(event)?.delete(handler);
22
+ };
23
+ }
24
+ /**
25
+ * Subscribe to an event once (auto-unsubscribes after first invocation).
26
+ */
27
+ once(event, handler) {
28
+ const wrappedHandler = (payload) => {
29
+ unsubscribe();
30
+ handler(payload);
31
+ };
32
+ const unsubscribe = this.on(event, wrappedHandler);
33
+ return unsubscribe;
34
+ }
35
+ /**
36
+ * Emit an event to all registered listeners.
37
+ */
38
+ emit(event, payload) {
39
+ const handlers = this.listeners.get(event);
40
+ if (!handlers || handlers.size === 0)
41
+ return;
42
+ for (const handler of handlers) {
43
+ try {
44
+ handler(payload);
45
+ }
46
+ catch {
47
+ // Swallow listener errors — SDK should never crash from user callbacks
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Remove all listeners for a specific event, or all events if no event is specified.
53
+ */
54
+ removeAllListeners(event) {
55
+ if (event) {
56
+ this.listeners.delete(event);
57
+ }
58
+ else {
59
+ this.listeners.clear();
60
+ }
61
+ }
62
+ /**
63
+ * Get the count of listeners for a given event.
64
+ */
65
+ listenerCount(event) {
66
+ return this.listeners.get(event)?.size ?? 0;
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ import { Experiment, EvaluationContext, ExperimentAssignment } from './types';
2
+ /**
3
+ * Deterministically assigns a user to an experiment variation based on weights.
4
+ *
5
+ * @param experiment The experiment definition with variations and weights
6
+ * @param context The evaluation context (to extract stickiness key)
7
+ * @returns The assigned variation details, or null if assignment fails
8
+ */
9
+ export declare function assignVariation(experiment: Experiment | undefined, context: EvaluationContext | undefined): ExperimentAssignment | null;
@@ -0,0 +1,51 @@
1
+ import { getHashBucket } from './rollout';
2
+ /**
3
+ * Deterministically assigns a user to an experiment variation based on weights.
4
+ *
5
+ * @param experiment The experiment definition with variations and weights
6
+ * @param context The evaluation context (to extract stickiness key)
7
+ * @returns The assigned variation details, or null if assignment fails
8
+ */
9
+ export function assignVariation(experiment, context) {
10
+ if (!experiment || !experiment.variations || experiment.variations.length === 0) {
11
+ return null;
12
+ }
13
+ // Determine stickiness key. Default to userId, then workspaceId.
14
+ let key;
15
+ if (experiment.stickinessKey) {
16
+ if (experiment.stickinessKey === 'userId')
17
+ key = context?.userId;
18
+ else if (experiment.stickinessKey === 'workspaceId')
19
+ key = context?.workspaceId;
20
+ else
21
+ key = context?.attributes?.[experiment.stickinessKey];
22
+ }
23
+ else {
24
+ key = context?.userId || context?.workspaceId;
25
+ }
26
+ // Anonymous users (no key) cannot be deterministically assigned to A/B tests.
27
+ if (!key) {
28
+ return null;
29
+ }
30
+ // Get a bucket from 0 to 9999 (for 0.01% precision in weights)
31
+ const bucket = getHashBucket(key, experiment.salt || experiment.id, 10000);
32
+ // Find which variation range this bucket falls into.
33
+ // Variations have weights from 0 to 100.
34
+ // We scale weights to 0-10000 for matching the bucket.
35
+ let cumulativeWeight = 0;
36
+ for (const variation of experiment.variations) {
37
+ const scaledWeight = variation.weight * 100; // e.g., 50.5% -> 5050
38
+ cumulativeWeight += scaledWeight;
39
+ if (bucket < cumulativeWeight) {
40
+ return {
41
+ experimentId: experiment.id,
42
+ variationId: variation.id,
43
+ value: variation.value,
44
+ context: context || {}
45
+ };
46
+ }
47
+ }
48
+ // Fallback: This only happens if variations sum to < 100% and the bucket
49
+ // falls into the unassigned remaining percentage. If so, they are not in the experiment.
50
+ return null;
51
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types';
2
+ export * from './client';
3
+ export * from './cache';
4
+ export * from './logger';
5
+ export * from './circuit-breaker';
6
+ export * from './event-emitter';
7
+ export * from './retry';
@@ -0,0 +1,7 @@
1
+ export * from './types';
2
+ export * from './client';
3
+ export * from './cache';
4
+ export * from './logger';
5
+ export * from './circuit-breaker';
6
+ export * from './event-emitter';
7
+ export * from './retry';
@@ -0,0 +1,14 @@
1
+ import { ILogger, LogLevel } from './types';
2
+ /**
3
+ * Default console-based logger with level filtering.
4
+ * Users can replace this with any ILogger implementation (pino, winston, etc).
5
+ */
6
+ export declare class ConsoleLogger implements ILogger {
7
+ private readonly level;
8
+ private readonly prefix;
9
+ constructor(level?: LogLevel);
10
+ debug(message: string, ...args: unknown[]): void;
11
+ info(message: string, ...args: unknown[]): void;
12
+ warn(message: string, ...args: unknown[]): void;
13
+ error(message: string, ...args: unknown[]): void;
14
+ }
@@ -0,0 +1,37 @@
1
+ const LOG_LEVELS = {
2
+ debug: 0,
3
+ info: 1,
4
+ warn: 2,
5
+ error: 3,
6
+ silent: 4,
7
+ };
8
+ /**
9
+ * Default console-based logger with level filtering.
10
+ * Users can replace this with any ILogger implementation (pino, winston, etc).
11
+ */
12
+ export class ConsoleLogger {
13
+ constructor(level = 'warn') {
14
+ this.prefix = '[FeatureFly]';
15
+ this.level = LOG_LEVELS[level];
16
+ }
17
+ debug(message, ...args) {
18
+ if (this.level <= LOG_LEVELS.debug) {
19
+ console.debug(`${this.prefix} ${message}`, ...args);
20
+ }
21
+ }
22
+ info(message, ...args) {
23
+ if (this.level <= LOG_LEVELS.info) {
24
+ console.info(`${this.prefix} ${message}`, ...args);
25
+ }
26
+ }
27
+ warn(message, ...args) {
28
+ if (this.level <= LOG_LEVELS.warn) {
29
+ console.warn(`${this.prefix} ${message}`, ...args);
30
+ }
31
+ }
32
+ error(message, ...args) {
33
+ if (this.level <= LOG_LEVELS.error) {
34
+ console.error(`${this.prefix} ${message}`, ...args);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,79 @@
1
+ import { EventEmitter } from './event-emitter';
2
+ /**
3
+ * Per-flag metrics summary.
4
+ */
5
+ export interface FlagMetric {
6
+ evaluations: number;
7
+ cacheHits: number;
8
+ cacheMisses: number;
9
+ changes: number;
10
+ lastEvaluatedAt: number;
11
+ latencies: number[];
12
+ }
13
+ /**
14
+ * Experiment exposure summary.
15
+ */
16
+ export interface ExperimentMetric {
17
+ experimentId: string;
18
+ exposures: number;
19
+ variationCounts: Record<string, number>;
20
+ }
21
+ /**
22
+ * Full metrics snapshot returned by `getSnapshot()`.
23
+ */
24
+ export interface MetricsSnapshot {
25
+ totalEvaluations: number;
26
+ totalCacheHits: number;
27
+ totalCacheMisses: number;
28
+ cacheHitRate: number;
29
+ flags: Record<string, FlagMetric>;
30
+ experiments: Record<string, ExperimentMetric>;
31
+ latency: {
32
+ p50: number;
33
+ p95: number;
34
+ p99: number;
35
+ avg: number;
36
+ };
37
+ collectedSince: number;
38
+ }
39
+ /**
40
+ * Passive impact metrics collector.
41
+ *
42
+ * Subscribes to SDK events and aggregates per-flag evaluation counts,
43
+ * cache hit/miss ratios, latency percentiles, change frequency,
44
+ * and experiment exposure counts. No network calls, no external dependencies.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const metrics = client.getImpactMetrics();
49
+ * console.log(metrics.cacheHitRate); // 0.87
50
+ * console.log(metrics.latency.p95); // 12ms
51
+ * console.log(metrics.flags['my-flag'].evaluations); // 42
52
+ * ```
53
+ */
54
+ export declare class ImpactMetrics {
55
+ private totalEvaluations;
56
+ private totalCacheHits;
57
+ private totalCacheMisses;
58
+ private readonly flags;
59
+ private readonly experiments;
60
+ private readonly latencies;
61
+ private readonly collectedSince;
62
+ private readonly unsubscribers;
63
+ constructor(events: EventEmitter);
64
+ /**
65
+ * Returns a full immutable snapshot of all collected metrics.
66
+ */
67
+ getSnapshot(): MetricsSnapshot;
68
+ /**
69
+ * Reset all collected metrics.
70
+ */
71
+ reset(): void;
72
+ /**
73
+ * Unsubscribe from all events. Called on client dispose.
74
+ */
75
+ destroy(): void;
76
+ private getOrCreateFlag;
77
+ private getOrCreateExperiment;
78
+ private computeLatencyPercentiles;
79
+ }
@@ -0,0 +1,147 @@
1
+ const MAX_LATENCY_SAMPLES = 1000;
2
+ /**
3
+ * Passive impact metrics collector.
4
+ *
5
+ * Subscribes to SDK events and aggregates per-flag evaluation counts,
6
+ * cache hit/miss ratios, latency percentiles, change frequency,
7
+ * and experiment exposure counts. No network calls, no external dependencies.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const metrics = client.getImpactMetrics();
12
+ * console.log(metrics.cacheHitRate); // 0.87
13
+ * console.log(metrics.latency.p95); // 12ms
14
+ * console.log(metrics.flags['my-flag'].evaluations); // 42
15
+ * ```
16
+ */
17
+ export class ImpactMetrics {
18
+ constructor(events) {
19
+ this.totalEvaluations = 0;
20
+ this.totalCacheHits = 0;
21
+ this.totalCacheMisses = 0;
22
+ this.flags = new Map();
23
+ this.experiments = new Map();
24
+ this.latencies = [];
25
+ this.collectedSince = Date.now();
26
+ this.unsubscribers = [];
27
+ this.unsubscribers.push(events.on('flagEvaluated', (payload) => {
28
+ this.totalEvaluations++;
29
+ // Per-flag tracking
30
+ const metric = this.getOrCreateFlag(payload.slug);
31
+ metric.evaluations++;
32
+ metric.lastEvaluatedAt = Date.now();
33
+ // Latency tracking (ring buffer)
34
+ if (payload.durationMs !== undefined) {
35
+ metric.latencies.push(payload.durationMs);
36
+ if (metric.latencies.length > MAX_LATENCY_SAMPLES) {
37
+ metric.latencies.shift();
38
+ }
39
+ this.latencies.push(payload.durationMs);
40
+ if (this.latencies.length > MAX_LATENCY_SAMPLES) {
41
+ this.latencies.shift();
42
+ }
43
+ }
44
+ }));
45
+ this.unsubscribers.push(events.on('cacheHit', () => {
46
+ this.totalCacheHits++;
47
+ }));
48
+ this.unsubscribers.push(events.on('cacheMiss', () => {
49
+ this.totalCacheMisses++;
50
+ }));
51
+ this.unsubscribers.push(events.on('flagChanged', (payload) => {
52
+ const metric = this.getOrCreateFlag(payload.slug);
53
+ metric.changes++;
54
+ }));
55
+ this.unsubscribers.push(events.on('experimentAssigned', (payload) => {
56
+ const exp = this.getOrCreateExperiment(payload.experimentId);
57
+ exp.exposures++;
58
+ exp.variationCounts[payload.variationId] =
59
+ (exp.variationCounts[payload.variationId] || 0) + 1;
60
+ }));
61
+ }
62
+ /**
63
+ * Returns a full immutable snapshot of all collected metrics.
64
+ */
65
+ getSnapshot() {
66
+ const flagsRecord = {};
67
+ for (const [slug, metric] of this.flags) {
68
+ flagsRecord[slug] = { ...metric, latencies: [...metric.latencies] };
69
+ }
70
+ const experimentsRecord = {};
71
+ for (const [id, metric] of this.experiments) {
72
+ experimentsRecord[id] = { ...metric, variationCounts: { ...metric.variationCounts } };
73
+ }
74
+ const totalCacheOps = this.totalCacheHits + this.totalCacheMisses;
75
+ return {
76
+ totalEvaluations: this.totalEvaluations,
77
+ totalCacheHits: this.totalCacheHits,
78
+ totalCacheMisses: this.totalCacheMisses,
79
+ cacheHitRate: totalCacheOps > 0 ? this.totalCacheHits / totalCacheOps : 0,
80
+ flags: flagsRecord,
81
+ experiments: experimentsRecord,
82
+ latency: this.computeLatencyPercentiles(),
83
+ collectedSince: this.collectedSince,
84
+ };
85
+ }
86
+ /**
87
+ * Reset all collected metrics.
88
+ */
89
+ reset() {
90
+ this.totalEvaluations = 0;
91
+ this.totalCacheHits = 0;
92
+ this.totalCacheMisses = 0;
93
+ this.flags.clear();
94
+ this.experiments.clear();
95
+ this.latencies.length = 0;
96
+ }
97
+ /**
98
+ * Unsubscribe from all events. Called on client dispose.
99
+ */
100
+ destroy() {
101
+ for (const unsub of this.unsubscribers) {
102
+ unsub();
103
+ }
104
+ this.unsubscribers.length = 0;
105
+ }
106
+ // ─── Internal ────────────────────────────────────────────────────────────────
107
+ getOrCreateFlag(slug) {
108
+ let metric = this.flags.get(slug);
109
+ if (!metric) {
110
+ metric = {
111
+ evaluations: 0,
112
+ cacheHits: 0,
113
+ cacheMisses: 0,
114
+ changes: 0,
115
+ lastEvaluatedAt: 0,
116
+ latencies: [],
117
+ };
118
+ this.flags.set(slug, metric);
119
+ }
120
+ return metric;
121
+ }
122
+ getOrCreateExperiment(experimentId) {
123
+ let metric = this.experiments.get(experimentId);
124
+ if (!metric) {
125
+ metric = {
126
+ experimentId,
127
+ exposures: 0,
128
+ variationCounts: {},
129
+ };
130
+ this.experiments.set(experimentId, metric);
131
+ }
132
+ return metric;
133
+ }
134
+ computeLatencyPercentiles() {
135
+ if (this.latencies.length === 0) {
136
+ return { p50: 0, p95: 0, p99: 0, avg: 0 };
137
+ }
138
+ const sorted = [...this.latencies].sort((a, b) => a - b);
139
+ const len = sorted.length;
140
+ return {
141
+ p50: sorted[Math.floor(len * 0.5)],
142
+ p95: sorted[Math.floor(len * 0.95)],
143
+ p99: sorted[Math.floor(len * 0.99)],
144
+ avg: Math.round(sorted.reduce((a, b) => a + b, 0) / len * 100) / 100,
145
+ };
146
+ }
147
+ }
@@ -0,0 +1,10 @@
1
+ import { ILogger, RetryConfig } from './types';
2
+ /**
3
+ * Retry a function with exponential backoff and jitter.
4
+ *
5
+ * @param fn - The async function to retry
6
+ * @param config - Retry configuration
7
+ * @param logger - Logger for retry attempts
8
+ * @param onRetry - Optional callback invoked on each retry with (attempt, error)
9
+ */
10
+ export declare function withRetry<T>(fn: () => Promise<T>, config: Partial<RetryConfig> | undefined, logger: ILogger, onRetry?: (attempt: number, error: unknown) => void): Promise<T>;
@@ -0,0 +1,39 @@
1
+ const DEFAULT_RETRY = {
2
+ maxAttempts: 3,
3
+ baseDelayMs: 1000,
4
+ maxDelayMs: 10000,
5
+ };
6
+ /**
7
+ * Retry a function with exponential backoff and jitter.
8
+ *
9
+ * @param fn - The async function to retry
10
+ * @param config - Retry configuration
11
+ * @param logger - Logger for retry attempts
12
+ * @param onRetry - Optional callback invoked on each retry with (attempt, error)
13
+ */
14
+ export async function withRetry(fn, config = {}, logger, onRetry) {
15
+ const { maxAttempts, baseDelayMs, maxDelayMs } = { ...DEFAULT_RETRY, ...config };
16
+ let lastError;
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
+ try {
19
+ return await fn();
20
+ }
21
+ catch (error) {
22
+ lastError = error;
23
+ if (attempt >= maxAttempts)
24
+ break;
25
+ // Exponential backoff with jitter
26
+ const exponentialDelay = baseDelayMs * Math.pow(2, attempt - 1);
27
+ const jitter = Math.random() * baseDelayMs * 0.5;
28
+ const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
29
+ const errorMessage = error instanceof Error ? error.message : String(error);
30
+ logger.warn(`Request failed (attempt ${attempt}/${maxAttempts}): ${errorMessage}. Retrying in ${Math.round(delay)}ms...`);
31
+ onRetry?.(attempt, error);
32
+ await sleep(delay);
33
+ }
34
+ }
35
+ throw lastError;
36
+ }
37
+ function sleep(ms) {
38
+ return new Promise((resolve) => setTimeout(resolve, ms));
39
+ }
@@ -0,0 +1,14 @@
1
+ import { RolloutConfig } from './types';
2
+ /**
3
+ * Deterministically checks if a given key falls within a rollout percentage.
4
+ *
5
+ * @param key The stickiness key (e.g. userId)
6
+ * @param config Rollout configuration including percentage, salt, and bucket max
7
+ * @returns true if the key hashes to a bucket < percentage
8
+ */
9
+ export declare function isInRollout(key: string | undefined, config: RolloutConfig | undefined): boolean;
10
+ /**
11
+ * Returns a deterministic bucket number (default 0-99) for a given key and salt.
12
+ * Uses MurmurHash3 (32-bit).
13
+ */
14
+ export declare function getHashBucket(key: string, salt?: string, buckets?: number): number;