fix: run error hook when provider returns reason error or error code (#926)

## This PR

- runs error hook when provider returns reason error or error code

### Related Issues

Fixes #925

### Notes

Based on a conversation in Slack:
https://cloud-native.slack.com/archives/C06E4DE6S07/p1714581197391509

---------

Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
This commit is contained in:
Michael Beemer 2024-05-08 13:38:04 -04:00 committed by GitHub
parent f0de66770b
commit c6d0b5da9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 120 additions and 22 deletions

View File

@ -175,13 +175,13 @@ export default {
displayName: 'react', displayName: 'react',
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
preset: 'ts-jest', preset: 'ts-jest',
testMatch: ['<rootDir>/packages/react/test/**/*.spec.ts*'], testMatch: ['<rootDir>/packages/react/test/**/*.spec.{ts,tsx}'],
moduleNameMapper: { moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src', '@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/web-sdk': '<rootDir>/packages/client/src', '@openfeature/web-sdk': '<rootDir>/packages/client/src',
}, },
transform: { transform: {
'^.+\\.tsx$': [ '^.+\\.(ts|tsx)$': [
'ts-jest', 'ts-jest',
{ {
tsconfig: '<rootDir>/packages/react/test/tsconfig.json', tsconfig: '<rootDir>/packages/react/test/tsconfig.json',

View File

@ -15,7 +15,8 @@ import {
ResolutionDetails, ResolutionDetails,
SafeLogger, SafeLogger,
StandardResolutionReasons, StandardResolutionReasons,
statusMatchesEvent instantiateErrorByErrorCode,
statusMatchesEvent,
} from '@openfeature/core'; } from '@openfeature/core';
import { FlagEvaluationOptions } from '../evaluation'; import { FlagEvaluationOptions } from '../evaluation';
import { ProviderEvents } from '../events'; import { ProviderEvents } from '../events';
@ -208,7 +209,7 @@ export class OpenFeatureClient implements Client {
try { try {
this.beforeHooks(allHooks, hookContext, options); this.beforeHooks(allHooks, hookContext, options);
// short circuit evaluation entirely if provider is in a bad state // short circuit evaluation entirely if provider is in a bad state
if (this.providerStatus === ProviderStatus.NOT_READY) { if (this.providerStatus === ProviderStatus.NOT_READY) {
throw new ProviderNotReadyError('provider has not yet initialized'); throw new ProviderNotReadyError('provider has not yet initialized');
@ -225,6 +226,10 @@ export class OpenFeatureClient implements Client {
flagKey, flagKey,
}; };
if (evaluationDetails.errorCode) {
throw instantiateErrorByErrorCode(evaluationDetails.errorCode);
}
this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options); this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails; return evaluationDetails;

View File

@ -7,6 +7,8 @@ import {
GeneralError, GeneralError,
OpenFeature, OpenFeature,
Hook, Hook,
StandardResolutionReasons,
ErrorCode,
} from '../src'; } from '../src';
const BOOLEAN_VALUE = true; const BOOLEAN_VALUE = true;
@ -206,6 +208,27 @@ describe('Hooks', () => {
], ],
}); });
}); });
it('"error" must run if resolution details contains an error code', () => {
(MOCK_ERROR_PROVIDER.resolveBooleanEvaluation as jest.Mock).mockReturnValue({
value: BOOLEAN_VALUE,
errorCode: ErrorCode.FLAG_NOT_FOUND,
});
const mockErrorHook = jest.fn();
const details = client.getBooleanDetails(FLAG_KEY, false, {
hooks: [{ error: mockErrorHook }],
});
expect(mockErrorHook).toHaveBeenCalled();
expect(details).toEqual(
expect.objectContaining({
errorCode: ErrorCode.FLAG_NOT_FOUND,
reason: StandardResolutionReasons.ERROR,
}),
);
});
}); });
}); });

View File

@ -16,6 +16,7 @@ import {
ResolutionDetails, ResolutionDetails,
SafeLogger, SafeLogger,
StandardResolutionReasons, StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent, statusMatchesEvent,
} from '@openfeature/core'; } from '@openfeature/core';
import { FlagEvaluationOptions } from '../evaluation'; import { FlagEvaluationOptions } from '../evaluation';
@ -278,6 +279,10 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
flagKey, flagKey,
}; };
if (evaluationDetails.errorCode) {
throw instantiateErrorByErrorCode(evaluationDetails.errorCode);
}
await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options); await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails; return evaluationDetails;

View File

@ -1,11 +1,19 @@
import { OpenFeature, Provider, ResolutionDetails, Client, FlagValueType, EvaluationContext, Hook } from '../src'; import {
OpenFeature,
Provider,
ResolutionDetails,
Client,
FlagValueType,
EvaluationContext,
Hook,
StandardResolutionReasons,
ErrorCode,
} from '../src';
const BOOLEAN_VALUE = true; const BOOLEAN_VALUE = true;
const BOOLEAN_VARIANT = `${BOOLEAN_VALUE}`; const BOOLEAN_VARIANT = `${BOOLEAN_VALUE}`;
const REASON = 'mocked-value'; const REASON = 'mocked-value';
const ERROR_REASON = 'error';
const ERROR_CODE = 'MOCKED_ERROR';
// a mock provider with some jest spies // a mock provider with some jest spies
const MOCK_PROVIDER: Provider = { const MOCK_PROVIDER: Provider = {
@ -28,8 +36,8 @@ const MOCK_ERROR_PROVIDER: Provider = {
}, },
resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => { resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => {
return Promise.reject({ return Promise.reject({
reason: ERROR_REASON, reason: StandardResolutionReasons.ERROR,
errorCode: ERROR_CODE, errorCode: ErrorCode.GENERAL,
}); });
}), }),
} as unknown as Provider; } as unknown as Provider;
@ -357,6 +365,27 @@ describe('Hooks', () => {
], ],
}); });
}); });
it('"error" must run if resolution details contains an error code', async () => {
(MOCK_ERROR_PROVIDER.resolveBooleanEvaluation as jest.Mock).mockResolvedValueOnce({
value: BOOLEAN_VALUE,
errorCode: ErrorCode.FLAG_NOT_FOUND,
});
const mockErrorHook = jest.fn();
const details = await client.getBooleanDetails(FLAG_KEY, false, undefined, {
hooks: [{ error: mockErrorHook }],
});
expect(mockErrorHook).toHaveBeenCalled();
expect(details).toEqual(
expect.objectContaining({
errorCode: ErrorCode.FLAG_NOT_FOUND,
reason: StandardResolutionReasons.ERROR,
}),
);
});
}); });
}); });
@ -636,8 +665,8 @@ describe('Hooks', () => {
], ],
resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => { resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => {
return Promise.reject({ return Promise.reject({
reason: ERROR_REASON, reason: StandardResolutionReasons.ERROR,
errorCode: ERROR_CODE, errorCode: ErrorCode.INVALID_CONTEXT,
}); });
}), }),
} as unknown as Provider; } as unknown as Provider;
@ -717,8 +746,8 @@ describe('Hooks', () => {
], ],
resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => { resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => {
return Promise.reject({ return Promise.reject({
reason: ERROR_REASON, reason: StandardResolutionReasons.ERROR,
errorCode: ERROR_CODE, errorCode: ErrorCode.PROVIDER_NOT_READY,
}); });
}), }),
} as unknown as Provider; } as unknown as Provider;

View File

@ -1,9 +1,45 @@
export * from './general-error'; import { ErrorCode } from '../evaluation';
export * from './flag-not-found-error';
export * from './parse-error'; import { FlagNotFoundError } from './flag-not-found-error';
export * from './type-mismatch-error'; import { GeneralError } from './general-error';
export * from './targeting-key-missing-error'; import { InvalidContextError } from './invalid-context-error';
export * from './invalid-context-error'; import { OpenFeatureError } from './open-feature-error-abstract';
export * from './open-feature-error-abstract'; import { ParseError } from './parse-error';
export * from './provider-not-ready-error'; import { ProviderFatalError } from './provider-fatal-error';
export * from './provider-fatal-error'; import { ProviderNotReadyError } from './provider-not-ready-error';
import { TargetingKeyMissingError } from './targeting-key-missing-error';
import { TypeMismatchError } from './type-mismatch-error';
const instantiateErrorByErrorCode = (errorCode: ErrorCode, message?: string): OpenFeatureError => {
switch (errorCode) {
case ErrorCode.FLAG_NOT_FOUND:
return new FlagNotFoundError(message);
case ErrorCode.PARSE_ERROR:
return new ParseError(message);
case ErrorCode.TYPE_MISMATCH:
return new TypeMismatchError(message);
case ErrorCode.TARGETING_KEY_MISSING:
return new TargetingKeyMissingError(message);
case ErrorCode.INVALID_CONTEXT:
return new InvalidContextError(message);
case ErrorCode.PROVIDER_NOT_READY:
return new ProviderNotReadyError(message);
case ErrorCode.PROVIDER_FATAL:
return new ProviderFatalError(message);
default:
return new GeneralError(message);
}
};
export {
FlagNotFoundError,
GeneralError,
InvalidContextError,
ParseError,
ProviderFatalError,
ProviderNotReadyError,
TargetingKeyMissingError,
TypeMismatchError,
OpenFeatureError,
instantiateErrorByErrorCode,
};