From 825cfe5f8090720e0cb3440be51e94380213ebe0 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 5 Nov 2025 00:17:42 -0500 Subject: [PATCH] add timeout with cancellation to requests --- demo-timeout-cancellation.js | 139 +++++++++ demo-web-cancellation.js | 138 +++++++++ .../client/internal/open-feature-client.ts | 45 ++- packages/server/src/evaluation/evaluation.ts | 23 ++ packages/server/src/open-feature.ts | 49 +++- .../server/test/timeout-cancellation.spec.ts | 264 ++++++++++++++++++ packages/shared/src/evaluation/evaluation.ts | 5 + packages/shared/src/open-feature.ts | 54 ++++ .../client/internal/open-feature-client.ts | 20 ++ packages/web/src/evaluation/evaluation.ts | 23 ++ packages/web/src/open-feature.ts | 91 +++++- .../web/test/timeout-cancellation.spec.ts | 246 ++++++++++++++++ 12 files changed, 1082 insertions(+), 15 deletions(-) create mode 100644 demo-timeout-cancellation.js create mode 100644 demo-web-cancellation.js create mode 100644 packages/server/test/timeout-cancellation.spec.ts create mode 100644 packages/web/test/timeout-cancellation.spec.ts diff --git a/demo-timeout-cancellation.js b/demo-timeout-cancellation.js new file mode 100644 index 000000000..06abd4e32 --- /dev/null +++ b/demo-timeout-cancellation.js @@ -0,0 +1,139 @@ +// Demonstration script for timeout and cancellation functionality +// This script shows the new timeout and AbortSignal features working + +const { OpenFeature, ErrorCode, ProviderStatus } = require('./packages/server/dist/cjs'); + +// Mock provider that simulates slow operations +class SlowProvider { + constructor(delay = 100) { + this.delay = delay; + this.metadata = { name: 'slow-demo-provider' }; + this.runsOn = 'server'; + } + + async initialize() { + console.log(`🔄 Provider initializing (${this.delay}ms delay)...`); + await new Promise(resolve => setTimeout(resolve, this.delay)); + console.log('✅ Provider initialized successfully'); + } + + async resolveBooleanEvaluation(flagKey, defaultValue) { + console.log(`🔄 Evaluating flag "${flagKey}" (${this.delay}ms delay)...`); + await new Promise(resolve => setTimeout(resolve, this.delay)); + console.log(`✅ Flag "${flagKey}" evaluated to true`); + return { value: true, reason: 'STATIC' }; + } + + resolveStringEvaluation() { throw new Error('Not implemented'); } + resolveNumberEvaluation() { throw new Error('Not implemented'); } + resolveObjectEvaluation() { throw new Error('Not implemented'); } +} + +async function demonstrateTimeoutAndCancellation() { + console.log('🚀 OpenFeature Timeout and Cancellation Demo\n'); + + try { + // Demo 1: Provider initialization timeout (should succeed) + console.log('📝 Demo 1: Provider initialization with sufficient timeout'); + console.log('Setting provider with 200ms timeout, provider takes 100ms...'); + const fastProvider = new SlowProvider(100); + await OpenFeature.setProviderAndWait(fastProvider, { timeout: 200 }); + console.log('✅ Provider initialization succeeded within timeout\n'); + + // Demo 2: Flag evaluation timeout (should succeed) + console.log('📝 Demo 2: Flag evaluation with sufficient timeout'); + console.log('Evaluating flag with 200ms timeout, evaluation takes 100ms...'); + const client = OpenFeature.getClient(); + const result1 = await client.getBooleanDetails('demo-flag', false, {}, { timeout: 200 }); + console.log(`✅ Flag evaluation succeeded: value=${result1.value}, errorCode=${result1.errorCode || 'none'}\n`); + + // Demo 3: Provider initialization timeout (should fail) + console.log('📝 Demo 3: Provider initialization timeout'); + console.log('Setting provider with 50ms timeout, provider takes 200ms...'); + try { + const slowProvider = new SlowProvider(200); + await OpenFeature.setProviderAndWait(slowProvider, { timeout: 50 }); + console.log('❌ Unexpected success - should have timed out'); + } catch (error) { + if (error.code === ErrorCode.TIMEOUT) { + console.log(`✅ Provider initialization timed out as expected: ${error.message}\n`); + } else { + console.log(`❌ Unexpected error: ${error.message}\n`); + } + } + + // Demo 4: Flag evaluation timeout (should fail) + console.log('📝 Demo 4: Flag evaluation timeout'); + console.log('Evaluating flag with 50ms timeout, evaluation takes 200ms...'); + // Set a new slow provider for this test + const anotherSlowProvider = new SlowProvider(200); + await OpenFeature.setProviderAndWait(anotherSlowProvider, { timeout: 300 }); // Give it time to initialize + + const client2 = OpenFeature.getClient(); + const result2 = await client2.getBooleanDetails('timeout-flag', false, {}, { timeout: 50 }); + + if (result2.errorCode === ErrorCode.TIMEOUT) { + console.log(`✅ Flag evaluation timed out as expected: ${result2.errorMessage}\n`); + } else { + console.log(`❌ Expected timeout, got: errorCode=${result2.errorCode}, value=${result2.value}\n`); + } + + // Demo 5: AbortSignal cancellation + console.log('📝 Demo 5: Provider initialization cancellation with AbortSignal'); + console.log('Starting provider initialization, will abort after 50ms...'); + try { + const controller = new AbortController(); + const cancelProvider = new SlowProvider(200); + + // Abort after 50ms + setTimeout(() => { + console.log('🛑 Aborting provider initialization...'); + controller.abort(); + }, 50); + + await OpenFeature.setProviderAndWait(cancelProvider, { signal: controller.signal }); + console.log('❌ Unexpected success - should have been cancelled'); + } catch (error) { + if (error.message.includes('cancelled')) { + console.log(`✅ Provider initialization cancelled as expected: ${error.message}\n`); + } else { + console.log(`❌ Unexpected error: ${error.message}\n`); + } + } + + // Demo 6: Flag evaluation cancellation + console.log('📝 Demo 6: Flag evaluation cancellation with AbortSignal'); + console.log('Starting flag evaluation, will abort after 50ms...'); + + // Set up a provider for this test + const evalTestProvider = new SlowProvider(200); + await OpenFeature.setProviderAndWait(evalTestProvider, { timeout: 300 }); + + const controller2 = new AbortController(); + const client3 = OpenFeature.getClient(); + + // Abort after 50ms + setTimeout(() => { + console.log('🛑 Aborting flag evaluation...'); + controller2.abort(); + }, 50); + + const result3 = await client3.getBooleanDetails('cancel-flag', false, {}, { + signal: controller2.signal + }); + + if (result3.errorCode === ErrorCode.GENERAL && result3.errorMessage.includes('cancelled')) { + console.log(`✅ Flag evaluation cancelled as expected: ${result3.errorMessage}\n`); + } else { + console.log(`❌ Expected cancellation, got: errorCode=${result3.errorCode}, value=${result3.value}\n`); + } + + console.log('🎉 Demo completed! All timeout and cancellation features are working.'); + + } catch (error) { + console.error('❌ Demo failed with error:', error); + } +} + +// Run the demonstration +demonstrateTimeoutAndCancellation().catch(console.error); \ No newline at end of file diff --git a/demo-web-cancellation.js b/demo-web-cancellation.js new file mode 100644 index 000000000..c9ecf0571 --- /dev/null +++ b/demo-web-cancellation.js @@ -0,0 +1,138 @@ +// Demonstration script for web client cancellation functionality +// Note: Web clients have synchronous evaluations but support cancellation checks + +const { OpenFeature, ErrorCode, ProviderStatus } = require('./packages/web/dist/cjs'); + +// Mock web provider +class WebDemoProvider { + constructor(delay = 100) { + this.delay = delay; + this.metadata = { name: 'web-demo-provider' }; + this.runsOn = 'client'; + } + + async initialize() { + console.log(`🔄 Web provider initializing (${this.delay}ms delay)...`); + await new Promise(resolve => setTimeout(resolve, this.delay)); + console.log('✅ Web provider initialized successfully'); + } + + // Web providers have synchronous evaluation methods + resolveBooleanEvaluation(flagKey, defaultValue) { + console.log(`✅ Web flag "${flagKey}" evaluated synchronously to true`); + return { value: true, reason: 'STATIC' }; + } + + resolveStringEvaluation() { throw new Error('Not implemented'); } + resolveNumberEvaluation() { throw new Error('Not implemented'); } + resolveObjectEvaluation() { throw new Error('Not implemented'); } +} + +// Mock console to capture warnings +const originalConsole = console; +const warnings = []; +console.warn = (...args) => { + warnings.push(args.join(' ')); + originalConsole.warn(...args); +}; + +async function demonstrateWebCancellation() { + console.log('🌐 OpenFeature Web Client Cancellation Demo\n'); + + try { + // Demo 1: Provider initialization with timeout (should succeed) + console.log('📝 Demo 1: Web provider initialization with timeout'); + console.log('Setting provider with 200ms timeout, provider takes 100ms...'); + const provider = new WebDemoProvider(100); + await OpenFeature.setProviderAndWait(provider, { timeout: 200 }); + console.log('✅ Web provider initialization succeeded within timeout\n'); + + // Demo 2: Synchronous flag evaluation (no timeout support) + console.log('📝 Demo 2: Synchronous flag evaluation (timeouts not supported)'); + const client = OpenFeature.getClient(); + + // This should work but log a warning about timeout not being supported + const result1 = client.getBooleanDetails('web-flag', false, { + timeout: 1000 // This will trigger a warning + }); + + console.log(`✅ Flag evaluation completed: value=${result1.value}, errorCode=${result1.errorCode || 'none'}`); + + // Check if warning was logged + const timeoutWarning = warnings.find(w => w.includes('Timeout option is not supported')); + if (timeoutWarning) { + console.log('✅ Timeout warning logged as expected for synchronous evaluation\n'); + } else { + console.log('❌ Expected timeout warning was not logged\n'); + } + + // Demo 3: AbortSignal cancellation (should work) + console.log('📝 Demo 3: Flag evaluation cancellation with pre-aborted signal'); + const controller = new AbortController(); + controller.abort(); // Pre-abort the signal + + const result2 = client.getBooleanDetails('cancelled-flag', false, { + signal: controller.signal + }); + + if (result2.errorCode === ErrorCode.GENERAL && result2.errorMessage.includes('cancelled')) { + console.log(`✅ Flag evaluation cancelled as expected: ${result2.errorMessage}`); + console.log(`✅ Default value returned: ${result2.value}\n`); + } else { + console.log(`❌ Expected cancellation, got: errorCode=${result2.errorCode}, value=${result2.value}\n`); + } + + // Demo 4: Provider initialization cancellation + console.log('📝 Demo 4: Web provider initialization cancellation'); + console.log('Starting provider initialization, will abort after 50ms...'); + + try { + const controller2 = new AbortController(); + const slowProvider = new WebDemoProvider(200); + + // Abort after 50ms + setTimeout(() => { + console.log('🛑 Aborting web provider initialization...'); + controller2.abort(); + }, 50); + + await OpenFeature.setProviderAndWait(slowProvider, { signal: controller2.signal }); + console.log('❌ Unexpected success - should have been cancelled'); + } catch (error) { + if (error.message.includes('cancelled')) { + console.log(`✅ Web provider initialization cancelled as expected: ${error.message}\n`); + } else { + console.log(`❌ Unexpected error: ${error.message}\n`); + } + } + + // Demo 5: Combined timeout warning and cancellation + console.log('📝 Demo 5: Combined timeout warning and cancellation'); + const controller3 = new AbortController(); + controller3.abort(); + + const result3 = client.getBooleanDetails('combined-test', false, { + timeout: 5000, // Should trigger warning + signal: controller3.signal // Should cause cancellation + }); + + if (result3.errorCode === ErrorCode.GENERAL && result3.errorMessage.includes('cancelled')) { + console.log('✅ Cancellation took precedence over timeout'); + + // Check if both the old warning and potential new warnings exist + const hasTimeoutWarnings = warnings.some(w => w.includes('Timeout option is not supported')); + if (hasTimeoutWarnings) { + console.log('✅ Timeout warnings were logged for synchronous operations\n'); + } + } + + console.log('🎉 Web client demo completed! Cancellation features are working correctly.'); + console.log('📝 Note: Web clients use synchronous evaluations, so timeouts apply only to provider initialization.'); + + } catch (error) { + console.error('❌ Web demo failed with error:', error); + } +} + +// Run the demonstration +demonstrateWebCancellation().catch(console.error); \ No newline at end of file diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts index 01120f693..8da61e832 100644 --- a/packages/server/src/client/internal/open-feature-client.ts +++ b/packages/server/src/client/internal/open-feature-client.ts @@ -301,7 +301,10 @@ export class OpenFeatureClient implements Client { this.shortCircuitIfNotReady(); // run the referenced resolver, binding the provider. - const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); + const resolution = await this.withTimeoutAndCancellation( + () => resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger), + options + ); const resolutionDetails = { ...resolution, @@ -457,4 +460,44 @@ export class OpenFeatureClient implements Client { flagKey, }; } + + private async withTimeoutAndCancellation( + evaluationFn: () => Promise, + options: FlagEvaluationOptions + ): Promise { + const promises: Promise[] = [evaluationFn()]; + + // Add timeout promise if timeout is specified + if (options.timeout && options.timeout > 0) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + const timeoutError = new Error(`Evaluation timed out after ${options.timeout}ms`); + (timeoutError as OpenFeatureError).code = ErrorCode.TIMEOUT; + reject(timeoutError); + }, options.timeout); + }); + promises.push(timeoutPromise); + } + + // Add cancellation promise if AbortSignal is specified + if (options.signal && !options.signal.aborted) { + const cancellationPromise = new Promise((_, reject) => { + const onAbort = () => { + const cancelError = new Error('Evaluation was cancelled'); + (cancelError as OpenFeatureError).code = ErrorCode.GENERAL; + reject(cancelError); + }; + + options.signal!.addEventListener('abort', onAbort, { once: true }); + }); + promises.push(cancellationPromise); + } else if (options.signal?.aborted) { + // Signal is already aborted, reject immediately + const cancelError = new Error('Evaluation was cancelled'); + (cancelError as OpenFeatureError).code = ErrorCode.GENERAL; + throw cancelError; + } + + return Promise.race(promises); + } } diff --git a/packages/server/src/evaluation/evaluation.ts b/packages/server/src/evaluation/evaluation.ts index b43c4f126..6747c5228 100644 --- a/packages/server/src/evaluation/evaluation.ts +++ b/packages/server/src/evaluation/evaluation.ts @@ -4,6 +4,29 @@ import type { Hook } from '../hooks'; export interface FlagEvaluationOptions { hooks?: Hook[]; hookHints?: HookHints; + /** + * Timeout in milliseconds for the flag evaluation. If the evaluation takes longer than this timeout, + * it will be rejected with a TIMEOUT error. + */ + timeout?: number; + /** + * AbortSignal for cancelling the flag evaluation. When the signal is aborted, the evaluation + * will be rejected with a GENERAL error. + */ + signal?: AbortSignal; +} + +export interface ProviderWaitOptions { + /** + * Timeout in milliseconds for provider initialization. If the provider takes longer than this timeout + * to initialize, the promise will be rejected with a TIMEOUT error. + */ + timeout?: number; + /** + * AbortSignal for cancelling the provider initialization. When the signal is aborted, + * the promise will be rejected with a GENERAL error. + */ + signal?: AbortSignal; } export interface Features { diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts index 01decf154..3abb3aad6 100644 --- a/packages/server/src/open-feature.ts +++ b/packages/server/src/open-feature.ts @@ -10,6 +10,7 @@ import { } from '@openfeature/core'; import type { Client } from './client'; import { OpenFeatureClient } from './client/internal/open-feature-client'; +import type { ProviderWaitOptions } from './evaluation'; import { OpenFeatureEventEmitter } from './events'; import type { Hook } from './hooks'; import type { Provider} from './provider'; @@ -85,6 +86,16 @@ export class OpenFeatureAPI * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(provider: Provider): Promise; + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(provider: Provider, options: ProviderWaitOptions): Promise; /** * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. * A promise is returned that resolves when the provider is ready. @@ -95,13 +106,41 @@ export class OpenFeatureAPI * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(domain: string, provider: Provider): Promise; - async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(domain: string, provider: Provider, options: ProviderWaitOptions): Promise; + async setProviderAndWait( + domainOrProvider?: string | Provider, + providerOrOptions?: Provider | ProviderWaitOptions, + optionsOrUndefined?: ProviderWaitOptions + ): Promise { const domain = stringOrUndefined(domainOrProvider); - const provider = domain - ? objectOrUndefined(providerOrUndefined) - : objectOrUndefined(domainOrProvider); + let provider: Provider | undefined; + let options: ProviderWaitOptions | undefined; + + if (domain) { + // Domain is specified: setProviderAndWait(domain, provider, options?) + provider = objectOrUndefined(providerOrOptions); + options = objectOrUndefined(optionsOrUndefined); + } else { + // No domain: setProviderAndWait(provider, options?) + provider = objectOrUndefined(domainOrProvider); + + // Check if providerOrOptions is actually options + if (providerOrOptions && !('metadata' in providerOrOptions)) { + options = providerOrOptions as ProviderWaitOptions; + } + } - await this.setAwaitableProvider(domain, provider); + await this.setAwaitableProviderWithOptions(domain, provider, options); } /** diff --git a/packages/server/test/timeout-cancellation.spec.ts b/packages/server/test/timeout-cancellation.spec.ts new file mode 100644 index 000000000..54ba3d0ab --- /dev/null +++ b/packages/server/test/timeout-cancellation.spec.ts @@ -0,0 +1,264 @@ +import type { + Provider, + ResolutionDetails, +} from '../src'; +import { + ErrorCode, + OpenFeature, + ProviderStatus, +} from '../src'; + +// Mock provider that simulates slow operations for timeout testing +class SlowMockProvider implements Provider { + metadata = { + name: 'slow-mock', + }; + + readonly runsOn = 'server'; + + constructor(private delay: number = 100) {} + + async initialize(): Promise { + // Simulate slow initialization + await new Promise(resolve => setTimeout(resolve, this.delay)); + return Promise.resolve(); + } + + async resolveBooleanEvaluation(): Promise> { + // Simulate slow evaluation + await new Promise(resolve => setTimeout(resolve, this.delay)); + return { + value: true, + reason: 'STATIC', + }; + } + + resolveStringEvaluation(): Promise> { + throw new Error('Method not implemented.'); + } + + resolveNumberEvaluation(): Promise> { + throw new Error('Method not implemented.'); + } + + resolveObjectEvaluation(): Promise> { + throw new Error('Method not implemented.'); + } +} + +describe('Timeout and Cancellation Functionality', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + }); + + describe('Flag Evaluation Timeouts', () => { + it('should timeout flag evaluation when timeout is exceeded', async () => { + // Set up a slow provider + const slowProvider = new SlowMockProvider(200); // 200ms delay + await OpenFeature.setProviderAndWait(slowProvider); + + const client = OpenFeature.getClient(); + + // Try to evaluate with a 50ms timeout (should fail) + const result = await client.getBooleanDetails('test-flag', false, {}, { + timeout: 50 + }); + + expect(result.errorCode).toBe(ErrorCode.TIMEOUT); + expect(result.errorMessage).toContain('timed out after 50ms'); + expect(result.value).toBe(false); // Should return default value + }); + + it('should complete flag evaluation when timeout is not exceeded', async () => { + // Set up a fast provider + const fastProvider = new SlowMockProvider(50); // 50ms delay + await OpenFeature.setProviderAndWait(fastProvider); + + const client = OpenFeature.getClient(); + + // Try to evaluate with a 200ms timeout (should succeed) + const result = await client.getBooleanDetails('test-flag', false, {}, { + timeout: 200 + }); + + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); // Should return actual value + }); + + it('should cancel flag evaluation when AbortSignal is aborted', async () => { + // Set up a slow provider + const slowProvider = new SlowMockProvider(200); // 200ms delay + await OpenFeature.setProviderAndWait(slowProvider); + + const client = OpenFeature.getClient(); + const controller = new AbortController(); + + // Start evaluation with abort signal + const evaluationPromise = client.getBooleanDetails('test-flag', false, {}, { + signal: controller.signal + }); + + // Abort after 50ms + setTimeout(() => controller.abort(), 50); + + const result = await evaluationPromise; + + expect(result.errorCode).toBe(ErrorCode.GENERAL); + expect(result.errorMessage).toContain('cancelled'); + expect(result.value).toBe(false); // Should return default value + }); + + it('should fail immediately if AbortSignal is already aborted', async () => { + const fastProvider = new SlowMockProvider(10); // Very fast provider + await OpenFeature.setProviderAndWait(fastProvider); + + const client = OpenFeature.getClient(); + const controller = new AbortController(); + controller.abort(); // Pre-abort the signal + + const result = await client.getBooleanDetails('test-flag', false, {}, { + signal: controller.signal + }); + + expect(result.errorCode).toBe(ErrorCode.GENERAL); + expect(result.errorMessage).toContain('cancelled'); + expect(result.value).toBe(false); + }); + }); + + describe('Provider Initialization Timeouts', () => { + it('should timeout provider initialization when timeout is exceeded', async () => { + const slowProvider = new SlowMockProvider(200); // 200ms initialization delay + + try { + // Try to initialize with 50ms timeout (should fail) + await OpenFeature.setProviderAndWait(slowProvider, { timeout: 50 }); + fail('Expected timeout error'); + } catch (error: any) { + expect(error.code).toBe(ErrorCode.TIMEOUT); + expect(error.message).toContain('timed out after 50ms'); + } + }); + + it('should complete provider initialization when timeout is not exceeded', async () => { + const fastProvider = new SlowMockProvider(50); // 50ms initialization delay + + // Should succeed with 200ms timeout + await expect( + OpenFeature.setProviderAndWait(fastProvider, { timeout: 200 }) + ).resolves.toBeUndefined(); + + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + + it('should cancel provider initialization when AbortSignal is aborted', async () => { + const slowProvider = new SlowMockProvider(200); // 200ms initialization delay + const controller = new AbortController(); + + // Start initialization with abort signal + const initPromise = OpenFeature.setProviderAndWait(slowProvider, { + signal: controller.signal + }); + + // Abort after 50ms + setTimeout(() => controller.abort(), 50); + + try { + await initPromise; + fail('Expected cancellation error'); + } catch (error: any) { + expect(error.message).toContain('cancelled'); + } + }); + + it('should fail immediately if AbortSignal is already aborted for provider initialization', async () => { + const provider = new SlowMockProvider(10); // Very fast provider + const controller = new AbortController(); + controller.abort(); // Pre-abort the signal + + try { + await OpenFeature.setProviderAndWait(provider, { signal: controller.signal }); + fail('Expected cancellation error'); + } catch (error: any) { + expect(error.message).toContain('cancelled'); + } + }); + }); + + describe('Combined Timeout and Cancellation', () => { + it('should respect whichever comes first - timeout vs cancellation', async () => { + const slowProvider = new SlowMockProvider(200); + const controller = new AbortController(); + + // Start initialization with both timeout (100ms) and abort signal + const initPromise = OpenFeature.setProviderAndWait(slowProvider, { + timeout: 100, + signal: controller.signal + }); + + // Abort after 50ms (should win over 100ms timeout) + setTimeout(() => controller.abort(), 50); + + try { + await initPromise; + fail('Expected error'); + } catch (error: any) { + // Should be cancelled, not timed out + expect(error.message).toContain('cancelled'); + } + }); + + it('should timeout when timeout comes before cancellation', async () => { + const slowProvider = new SlowMockProvider(200); + const controller = new AbortController(); + + // Start initialization with timeout (50ms) and later abort signal + const initPromise = OpenFeature.setProviderAndWait(slowProvider, { + timeout: 50, + signal: controller.signal + }); + + // Abort after 100ms (timeout should win) + setTimeout(() => controller.abort(), 100); + + try { + await initPromise; + fail('Expected timeout error'); + } catch (error: any) { + expect(error.code).toBe(ErrorCode.TIMEOUT); + expect(error.message).toContain('timed out after 50ms'); + } + }); + }); + + describe('Edge Cases', () => { + it('should work normally when timeout is 0 or negative', async () => { + const provider = new SlowMockProvider(10); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + + // Timeout of 0 should be ignored + const result = await client.getBooleanDetails('test-flag', false, {}, { + timeout: 0 + }); + + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); + }); + + it('should work normally when no timeout or signal is provided', async () => { + const provider = new SlowMockProvider(50); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + + // No timeout/cancellation options + const result = await client.getBooleanDetails('test-flag', false, {}, {}); + + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/packages/shared/src/evaluation/evaluation.ts b/packages/shared/src/evaluation/evaluation.ts index 78e7b65cb..02704098c 100644 --- a/packages/shared/src/evaluation/evaluation.ts +++ b/packages/shared/src/evaluation/evaluation.ts @@ -115,6 +115,11 @@ export enum ErrorCode { */ INVALID_CONTEXT = 'INVALID_CONTEXT', + /** + * The evaluation request timed out. + */ + TIMEOUT = 'TIMEOUT', + /** * An error with an unspecified code. */ diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index fc82bc908..81073bf01 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -217,6 +217,22 @@ export abstract class OpenFeatureCommonAPI< contextOrUndefined?: EvaluationContext, ): this; + protected setAwaitableProviderWithOptions( + domainOrProvider?: string | P, + providerOrUndefined?: P, + options?: { timeout?: number; signal?: AbortSignal } + ): Promise | void { + const initializationPromise = this.setAwaitableProvider(domainOrProvider, providerOrUndefined); + + // If no promise is returned, no timeout/cancellation is needed + if (!initializationPromise || !options) { + return initializationPromise; + } + + // Apply timeout and cancellation wrapping + return this.withProviderTimeoutAndCancellation(initializationPromise, options); + } + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { const domain = stringOrUndefined(domainOrProvider); const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); @@ -440,4 +456,42 @@ export abstract class OpenFeatureCommonAPI< this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`); this._logger.error((err as Error)?.stack); } + + private async withProviderTimeoutAndCancellation( + initializationPromise: Promise, + options: { timeout?: number; signal?: AbortSignal } + ): Promise { + const promises: Promise[] = [initializationPromise]; + + // Add timeout promise if timeout is specified + if (options.timeout && options.timeout > 0) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + const timeoutError = new GeneralError(`Provider initialization timed out after ${options.timeout}ms`); + timeoutError.code = ErrorCode.TIMEOUT; + reject(timeoutError); + }, options.timeout); + }); + promises.push(timeoutPromise); + } + + // Add cancellation promise if AbortSignal is specified + if (options.signal && !options.signal.aborted) { + const cancellationPromise = new Promise((_, reject) => { + const onAbort = () => { + const cancelError = new GeneralError('Provider initialization was cancelled'); + reject(cancelError); + }; + + options.signal!.addEventListener('abort', onAbort, { once: true }); + }); + promises.push(cancellationPromise); + } else if (options.signal?.aborted) { + // Signal is already aborted, reject immediately + const cancelError = new GeneralError('Provider initialization was cancelled'); + throw cancelError; + } + + return Promise.race(promises); + } } diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts index 3f3855ee9..ef89079f1 100644 --- a/packages/web/src/client/internal/open-feature-client.ts +++ b/packages/web/src/client/internal/open-feature-client.ts @@ -255,6 +255,9 @@ export class OpenFeatureClient implements Client { this.shortCircuitIfNotReady(); + // Check for cancellation before evaluation + this.checkCancellation(options); + // run the referenced resolver, binding the provider. const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger); @@ -375,4 +378,21 @@ export class OpenFeatureClient implements Client { flagKey, }; } + + private checkCancellation(options: FlagEvaluationOptions): void { + // Check for cancellation (timeouts are less relevant for synchronous operations) + if (options.signal?.aborted) { + const cancelError = new Error('Evaluation was cancelled'); + (cancelError as OpenFeatureError).code = ErrorCode.GENERAL; + throw cancelError; + } + + // Log warning if timeout is specified for synchronous operations + if (options.timeout !== undefined) { + this._logger.warn( + 'Timeout option is not supported for synchronous web client evaluations. ' + + 'Consider using server-side SDK for timeout support with async operations.' + ); + } + } } diff --git a/packages/web/src/evaluation/evaluation.ts b/packages/web/src/evaluation/evaluation.ts index b839ea823..a7873539f 100644 --- a/packages/web/src/evaluation/evaluation.ts +++ b/packages/web/src/evaluation/evaluation.ts @@ -3,6 +3,29 @@ import type { EvaluationDetails, BaseHook, HookHints, JsonValue } from '@openfea export interface FlagEvaluationOptions { hooks?: BaseHook[]; hookHints?: HookHints; + /** + * Timeout in milliseconds for the flag evaluation. If the evaluation takes longer than this timeout, + * it will be rejected with a TIMEOUT error. + */ + timeout?: number; + /** + * AbortSignal for cancelling the flag evaluation. When the signal is aborted, the evaluation + * will be rejected with a GENERAL error. + */ + signal?: AbortSignal; +} + +export interface ProviderWaitOptions { + /** + * Timeout in milliseconds for provider initialization. If the provider takes longer than this timeout + * to initialize, the promise will be rejected with a TIMEOUT error. + */ + timeout?: number; + /** + * AbortSignal for cancelling the provider initialization. When the signal is aborted, + * the promise will be rejected with a GENERAL error. + */ + signal?: AbortSignal; } export interface Features { diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index be6c7f845..9ad7cdf57 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -11,6 +11,7 @@ import { } from '@openfeature/core'; import type { Client } from './client'; import { OpenFeatureClient } from './client/internal/open-feature-client'; +import type { ProviderWaitOptions } from './evaluation'; import { OpenFeatureEventEmitter, ProviderEvents } from './events'; import type { Hook } from './hooks'; import type { Provider} from './provider'; @@ -90,6 +91,27 @@ export class OpenFeatureAPI * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(provider: Provider, context: EvaluationContext): Promise; + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(provider: Provider, options: ProviderWaitOptions): Promise; + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(provider: Provider, context: EvaluationContext, options: ProviderWaitOptions): Promise; /** * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. * A promise is returned that resolves when the provider is ready. @@ -111,18 +133,69 @@ export class OpenFeatureAPI * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(domain: string, provider: Provider, options: ProviderWaitOptions): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderWaitOptions} options Options for provider initialization including timeout and cancellation. + * @returns {Promise} + * @throws {Error} If the provider throws an exception during initialization or times out. + */ + setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext, options: ProviderWaitOptions): Promise; async setProviderAndWait( clientOrProvider?: string | Provider, - providerContextOrUndefined?: Provider | EvaluationContext, - contextOrUndefined?: EvaluationContext, + providerContextOrOptions?: Provider | EvaluationContext | ProviderWaitOptions, + contextOrOptions?: EvaluationContext | ProviderWaitOptions, + optionsOrUndefined?: ProviderWaitOptions, ): Promise { const domain = stringOrUndefined(clientOrProvider); - const provider = domain - ? objectOrUndefined(providerContextOrUndefined) - : objectOrUndefined(clientOrProvider); - const context = domain - ? objectOrUndefined(contextOrUndefined) - : objectOrUndefined(providerContextOrUndefined); + let provider: Provider | undefined; + let context: EvaluationContext | undefined; + let options: ProviderWaitOptions | undefined; + + if (domain) { + // Domain is specified: setProviderAndWait(domain, provider, context?, options?) + provider = objectOrUndefined(providerContextOrOptions); + + // Check if contextOrOptions is options or context + if (contextOrOptions) { + if ('timeout' in contextOrOptions || 'signal' in contextOrOptions) { + options = contextOrOptions as ProviderWaitOptions; + } else { + context = contextOrOptions as EvaluationContext; + options = objectOrUndefined(optionsOrUndefined); + } + } + } else { + // No domain: setProviderAndWait(provider, context?, options?) or setProviderAndWait(provider, options?) + provider = objectOrUndefined(clientOrProvider); + + if (providerContextOrOptions) { + if ('timeout' in providerContextOrOptions || 'signal' in providerContextOrOptions) { + options = providerContextOrOptions as ProviderWaitOptions; + } else { + context = providerContextOrOptions as EvaluationContext; + // Check if contextOrOptions is options + if (contextOrOptions && ('timeout' in contextOrOptions || 'signal' in contextOrOptions)) { + options = contextOrOptions as ProviderWaitOptions; + } + } + } + } if (context) { // synonymously setting context prior to provider initialization. @@ -134,7 +207,7 @@ export class OpenFeatureAPI } } - await this.setAwaitableProvider(domain, provider); + await this.setAwaitableProviderWithOptions(domain, provider, options); } /** diff --git a/packages/web/test/timeout-cancellation.spec.ts b/packages/web/test/timeout-cancellation.spec.ts new file mode 100644 index 000000000..4afe78f46 --- /dev/null +++ b/packages/web/test/timeout-cancellation.spec.ts @@ -0,0 +1,246 @@ +import type { + Provider, + ResolutionDetails, +} from '../src'; +import { + ErrorCode, + OpenFeature, + ProviderStatus, +} from '../src'; + +// Mock provider that simulates slow operations for testing +class SlowWebProvider implements Provider { + metadata = { + name: 'slow-web-mock', + }; + + readonly runsOn = 'client'; + + constructor(private delay: number = 100) {} + + async initialize(): Promise { + // Simulate slow initialization (still async for web providers) + await new Promise(resolve => setTimeout(resolve, this.delay)); + return Promise.resolve(); + } + + // Web providers have synchronous evaluation methods + resolveBooleanEvaluation(): ResolutionDetails { + // For the web client, we can't actually make this slow since it's synchronous + // But we can test the cancellation check that happens before evaluation + return { + value: true, + reason: 'STATIC', + }; + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Method not implemented.'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Method not implemented.'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Method not implemented.'); + } +} + +describe('Web Client Timeout and Cancellation Functionality', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + }); + + describe('Flag Evaluation Cancellation', () => { + it('should fail immediately if AbortSignal is already aborted', async () => { + const provider = new SlowWebProvider(); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + const controller = new AbortController(); + controller.abort(); // Pre-abort the signal + + const result = client.getBooleanDetails('test-flag', false, { + signal: controller.signal + }); + + expect(result.errorCode).toBe(ErrorCode.GENERAL); + expect(result.errorMessage).toContain('cancelled'); + expect(result.value).toBe(false); // Should return default value + }); + + it('should complete normally when AbortSignal is not aborted', async () => { + const provider = new SlowWebProvider(); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + const controller = new AbortController(); + + const result = client.getBooleanDetails('test-flag', false, { + signal: controller.signal + }); + + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); // Should return actual value + }); + + it('should log warning when timeout is specified for synchronous operations', async () => { + const provider = new SlowWebProvider(); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + + // Mock the logger to capture warnings + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + client.setLogger(mockLogger); + + const result = client.getBooleanDetails('test-flag', false, { + timeout: 1000 // This should trigger a warning + }); + + // Should complete successfully but log a warning + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Timeout option is not supported for synchronous web client evaluations') + ); + }); + }); + + describe('Provider Initialization with Timeout and Cancellation', () => { + it('should timeout provider initialization when timeout is exceeded', async () => { + const slowProvider = new SlowWebProvider(200); // 200ms initialization delay + + try { + // Try to initialize with 50ms timeout (should fail) + await OpenFeature.setProviderAndWait(slowProvider, { timeout: 50 }); + fail('Expected timeout error'); + } catch (error: any) { + expect(error.code).toBe(ErrorCode.TIMEOUT); + expect(error.message).toContain('timed out after 50ms'); + } + }); + + it('should complete provider initialization when timeout is not exceeded', async () => { + const fastProvider = new SlowWebProvider(50); // 50ms initialization delay + + // Should succeed with 200ms timeout + await expect( + OpenFeature.setProviderAndWait(fastProvider, { timeout: 200 }) + ).resolves.toBeUndefined(); + + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + + it('should cancel provider initialization when AbortSignal is aborted', async () => { + const slowProvider = new SlowWebProvider(200); // 200ms initialization delay + const controller = new AbortController(); + + // Start initialization with abort signal + const initPromise = OpenFeature.setProviderAndWait(slowProvider, { + signal: controller.signal + }); + + // Abort after 50ms + setTimeout(() => controller.abort(), 50); + + try { + await initPromise; + fail('Expected cancellation error'); + } catch (error: any) { + expect(error.message).toContain('cancelled'); + } + }); + + it('should handle complex overload with context and options', async () => { + const provider = new SlowWebProvider(50); + const controller = new AbortController(); + const context = { userId: 'test-user' }; + + // Test the complex overload: setProviderAndWait(provider, context, options) + await expect( + OpenFeature.setProviderAndWait(provider, context, { timeout: 200 }) + ).resolves.toBeUndefined(); + + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + + it('should distinguish between context and options in overloads', async () => { + const provider = new SlowWebProvider(10); // Very fast + const controller = new AbortController(); + + // Test overload with just options (no context) + await expect( + OpenFeature.setProviderAndWait(provider, { timeout: 100 }) + ).resolves.toBeUndefined(); + }); + + it('should handle domain-scoped providers with timeout', async () => { + const provider = new SlowWebProvider(50); + + // Test domain-scoped provider with timeout + await expect( + OpenFeature.setProviderAndWait('test-domain', provider, { timeout: 200 }) + ).resolves.toBeUndefined(); + + const client = OpenFeature.getClient('test-domain'); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + }); + + describe('Edge Cases', () => { + it('should work normally when no timeout or signal is provided', async () => { + const provider = new SlowWebProvider(10); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + + // No timeout/cancellation options + const result = client.getBooleanDetails('test-flag', false, {}); + + expect(result.errorCode).toBeUndefined(); + expect(result.value).toBe(true); + }); + + it('should work with both timeout warning and cancellation check', async () => { + const provider = new SlowWebProvider(); + await OpenFeature.setProviderAndWait(provider); + + const client = OpenFeature.getClient(); + const controller = new AbortController(); + controller.abort(); + + // Mock the logger to capture warnings + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + client.setLogger(mockLogger); + + const result = client.getBooleanDetails('test-flag', false, { + timeout: 1000, // Should trigger warning + signal: controller.signal // Should cause cancellation + }); + + // Should be cancelled (takes precedence) + expect(result.errorCode).toBe(ErrorCode.GENERAL); + expect(result.errorMessage).toContain('cancelled'); + expect(result.value).toBe(false); + + // Should still log the timeout warning + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Timeout option is not supported for synchronous web client evaluations') + ); + }); + }); +}); \ No newline at end of file