Skip to content

Commit

Permalink
feat(gofeatureflag): Clear cache if configuration changes + provider …
Browse files Browse the repository at this point in the history
…refactoring (#947)

Signed-off-by: Thomas Poignant <thomas.poignant@gofeatureflag.org>
  • Loading branch information
thomaspoignant committed Jun 26, 2024
1 parent 52d8445 commit 338123f
Show file tree
Hide file tree
Showing 13 changed files with 501 additions and 310 deletions.
4 changes: 2 additions & 2 deletions libs/providers/go-feature-flag/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.0"
"@openfeature/server-sdk": "^1.15.0"
}
}
}
18 changes: 8 additions & 10 deletions libs/providers/go-feature-flag/src/lib/context-transfomer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { EvaluationContext } from '@openfeature/server-sdk';
import { GoFeatureFlagUser } from './model';
import { GOFFEvaluationContext } from './model';
import { transformContext } from './context-transformer';

describe('contextTransformer', () => {
it('should use the targetingKey as user key', () => {
const got = transformContext({
targetingKey: 'user-key',
} as EvaluationContext);
const want: GoFeatureFlagUser = {
const want: GOFFEvaluationContext = {
key: 'user-key',
anonymous: false,
custom: {},
};
expect(got).toEqual(want);
Expand All @@ -20,10 +19,9 @@ describe('contextTransformer', () => {
targetingKey: 'user-key',
anonymous: true,
} as EvaluationContext);
const want: GoFeatureFlagUser = {
const want: GOFFEvaluationContext = {
key: 'user-key',
anonymous: true,
custom: {},
custom: { anonymous: true },
};
expect(got).toEqual(want);
});
Expand All @@ -36,10 +34,10 @@ describe('contextTransformer', () => {
email: 'john.doe@gofeatureflag.org',
} as EvaluationContext);

const want: GoFeatureFlagUser = {
const want: GOFFEvaluationContext = {
key: 'dd3027562879ff6857cc6b8b88ced570546d7c0c',
anonymous: true,
custom: {
anonymous: true,
firstname: 'John',
lastname: 'Doe',
email: 'john.doe@gofeatureflag.org',
Expand All @@ -56,13 +54,13 @@ describe('contextTransformer', () => {
email: 'john.doe@gofeatureflag.org',
} as EvaluationContext);

const want: GoFeatureFlagUser = {
const want: GOFFEvaluationContext = {
key: 'user-key',
anonymous: true,
custom: {
firstname: 'John',
lastname: 'Doe',
email: 'john.doe@gofeatureflag.org',
anonymous: true,
},
};
expect(got).toEqual(want);
Expand Down
22 changes: 4 additions & 18 deletions libs/providers/go-feature-flag/src/lib/context-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import { EvaluationContext } from '@openfeature/server-sdk';
import { sha1 } from 'object-hash';
import { GoFeatureFlagUser } from './model';
import { GOFFEvaluationContext } from './model';

/**
* transformContext takes the raw OpenFeature context returns a GoFeatureFlagUser.
* @param context - the context used for flag evaluation.
* @returns {GoFeatureFlagUser} the user against who we will evaluate the flag.
* @returns {GOFFEvaluationContext} the evaluation context against which we will evaluate the flag.
*/
export function transformContext(context: EvaluationContext): GoFeatureFlagUser {
export function transformContext(context: EvaluationContext): GOFFEvaluationContext {
const { targetingKey, ...attributes } = context;

// If we don't have a targetingKey we are using a hash of the object to build
// a consistent key. If for some reason it fails we are using a constant string
const key = targetingKey || sha1(context) || 'anonymous';

// Handle the special case of the anonymous field
let anonymous = false;
if (attributes !== undefined && attributes !== null && 'anonymous' in attributes) {
if (typeof attributes['anonymous'] === 'boolean') {
anonymous = attributes['anonymous'];
}
delete attributes['anonymous'];
}

return {
key,
anonymous,
key: key,
custom: attributes,
};
}
60 changes: 60 additions & 0 deletions libs/providers/go-feature-flag/src/lib/controller/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { GoFeatureFlagProviderOptions } from '../model';
import { EvaluationContext, Logger, ResolutionDetails } from '@openfeature/server-sdk';
import { LRUCache } from 'lru-cache';
import hash from 'object-hash';

export class CacheController {
// cacheTTL is the time we keep the evaluation in the cache before we consider it as obsolete.
// If you want to keep the value forever, you can set the FlagCacheTTL field to -1
private readonly cacheTTL?: number;
// logger is the Open Feature logger to use
private logger?: Logger;
// cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation
private readonly cache?: LRUCache<string, ResolutionDetails<any>>;
// options for this provider
private readonly options: GoFeatureFlagProviderOptions;

constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) {
this.options = options;
this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60;
this.logger = logger;
const cacheSize =
options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000;
this.cache = new LRUCache({ maxSize: cacheSize, sizeCalculation: () => 1 });
}

get(flagKey: string, evaluationContext: EvaluationContext): ResolutionDetails<any> | undefined {
if (this.options.disableCache) {
return undefined;
}
const cacheKey = this.buildCacheKey(flagKey, evaluationContext);
return this.cache?.get(cacheKey);
}

set(
flagKey: string,
evaluationContext: EvaluationContext,
evaluationResponse: { resolutionDetails: ResolutionDetails<any>; isCacheable: boolean },
) {
if (this.options.disableCache) {
return;
}

const cacheKey = this.buildCacheKey(flagKey, evaluationContext);
if (this.cache !== undefined && evaluationResponse.isCacheable) {
if (this.cacheTTL === -1) {
this.cache.set(cacheKey, evaluationResponse.resolutionDetails);
} else {
this.cache.set(cacheKey, evaluationResponse.resolutionDetails, { ttl: this.cacheTTL });
}
}
}

clear(): void {
return this.cache?.clear();
}

private buildCacheKey(flagKey: string, evaluationContext: EvaluationContext): string {
return `${flagKey}-${hash(evaluationContext)}`;
}
}
218 changes: 218 additions & 0 deletions libs/providers/go-feature-flag/src/lib/controller/goff-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {
ConfigurationChange,
DataCollectorRequest,
DataCollectorResponse,
FeatureEvent,
GoFeatureFlagProviderOptions,
GoFeatureFlagProxyRequest,
GoFeatureFlagProxyResponse,
} from '../model';
import {
ErrorCode,
EvaluationContext,
FlagNotFoundError,
Logger,
ResolutionDetails,
StandardResolutionReasons,
TypeMismatchError,
} from '@openfeature/server-sdk';
import { transformContext } from '../context-transformer';
import axios, { isAxiosError } from 'axios';
import { Unauthorized } from '../errors/unauthorized';
import { ProxyNotReady } from '../errors/proxyNotReady';
import { ProxyTimeout } from '../errors/proxyTimeout';
import { UnknownError } from '../errors/unknownError';
import { CollectorError } from '../errors/collector-error';
import { ConfigurationChangeEndpointNotFound } from '../errors/configuration-change-endpoint-not-found';
import { ConfigurationChangeEndpointUnknownErr } from '../errors/configuration-change-endpoint-unknown-err';
import { GoFeatureFlagError } from '../errors/goff-error';

export class GoffApiController {
// endpoint of your go-feature-flag relay proxy instance
private readonly endpoint: string;

// timeout in millisecond before we consider the request as a failure
private readonly timeout: number;
// logger is the Open Feature logger to use
private logger?: Logger;

// etag is the etag of the last configuration change
private etag: string | null = null;

constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) {
this.endpoint = options.endpoint;
this.timeout = options.timeout ?? 0;
this.logger = logger;
// Add API key to the headers
if (options.apiKey) {
axios.defaults.headers.common['Authorization'] = `Bearer ${options.apiKey}`;
}
}

/**
* Call the GO Feature Flag API to evaluate a flag
* @param flagKey
* @param defaultValue
* @param evaluationContext
* @param expectedType
*/
async evaluate<T>(
flagKey: string,
defaultValue: T,
evaluationContext: EvaluationContext,
expectedType: string,
): Promise<{ resolutionDetails: ResolutionDetails<T>; isCacheable: boolean }> {
const goffEvaluationContext = transformContext(evaluationContext);

// build URL to access to the endpoint
const endpointURL = new URL(this.endpoint);
endpointURL.pathname = `v1/feature/${flagKey}/eval`;

const request: GoFeatureFlagProxyRequest<T> = {
evaluationContext: goffEvaluationContext,
defaultValue,
};
let apiResponseData: GoFeatureFlagProxyResponse<T>;

try {
const response = await axios.post<GoFeatureFlagProxyResponse<T>>(endpointURL.toString(), request, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: this.timeout,
});
apiResponseData = response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status == 401) {
throw new Unauthorized('invalid token used to contact GO Feature Flag relay proxy instance');
}
// Impossible to contact the relay-proxy
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.response?.status === 404)) {
throw new ProxyNotReady(`impossible to call go-feature-flag relay proxy on ${endpointURL}`, error);
}

// Timeout when calling the API
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
throw new ProxyTimeout(`impossible to retrieve the ${flagKey} on time`, error);
}

throw new UnknownError(
`unknown error while retrieving flag ${flagKey} for evaluation context ${evaluationContext.targetingKey}`,
error,
);
}
// Check that we received the expectedType
if (typeof apiResponseData.value !== expectedType) {
throw new TypeMismatchError(
`Flag value ${flagKey} had unexpected type ${typeof apiResponseData.value}, expected ${expectedType}.`,
);
}
// Case of the flag is not found
if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) {
throw new FlagNotFoundError(`Flag ${flagKey} was not found in your configuration`);
}

// Case of the flag is disabled
if (apiResponseData.reason === StandardResolutionReasons.DISABLED) {
// we don't set a variant since we are using the default value, and we are not able to know
// which variant it is.
return {
resolutionDetails: { value: defaultValue, reason: apiResponseData.reason },
isCacheable: true,
};
}

if (apiResponseData.reason === StandardResolutionReasons.ERROR) {
return {
resolutionDetails: {
value: defaultValue,
reason: apiResponseData.reason,
errorCode: this.convertErrorCode(apiResponseData.errorCode),
},
isCacheable: true,
};
}

return {
resolutionDetails: {
value: apiResponseData.value,
variant: apiResponseData.variationType,
reason: apiResponseData.reason?.toString() || 'UNKNOWN',
flagMetadata: apiResponseData.metadata || undefined,
errorCode: this.convertErrorCode(apiResponseData.errorCode),
},
isCacheable: apiResponseData.cacheable,
};
}

async collectData(events: FeatureEvent<any>[], dataCollectorMetadata: Record<string, string>) {
if (events?.length === 0) {
return;
}

const request: DataCollectorRequest<boolean> = { events: events, meta: dataCollectorMetadata };
const endpointURL = new URL(this.endpoint);
endpointURL.pathname = 'v1/data/collector';

try {
await axios.post<DataCollectorResponse>(endpointURL.toString(), request, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: this.timeout,
});
} catch (e) {
throw new CollectorError(`impossible to send the data to the collector: ${e}`);
}
}

public async configurationHasChanged(): Promise<ConfigurationChange> {
const url = `${this.endpoint}v1/flag/change`;

const headers: any = {
'Content-Type': 'application/json',
};

if (this.etag) {
headers['If-None-Match'] = this.etag;
}
try {
const response = await axios.get(url, { headers });
if (response.status === 304) {
return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED;
}

const isInitialConfiguration = this.etag === null;
this.etag = response.headers['etag'];
return isInitialConfiguration
? ConfigurationChange.FLAG_CONFIGURATION_INITIALIZED
: ConfigurationChange.FLAG_CONFIGURATION_UPDATED;
} catch (e) {
if (isAxiosError(e) && e.response?.status === 304) {
return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED;
}
if (isAxiosError(e) && e.response?.status === 404) {
throw new ConfigurationChangeEndpointNotFound('impossible to find the configuration change endpoint');
}
if (e instanceof GoFeatureFlagError) {
throw e;
}
throw new ConfigurationChangeEndpointUnknownErr(
'unknown error while retrieving the configuration change endpoint',
e as Error,
);
}
}

private convertErrorCode(errorCode: ErrorCode | undefined): ErrorCode | undefined {
if (errorCode === undefined) {
return undefined;
}
if (Object.values(ErrorCode).includes(errorCode as ErrorCode)) {
return ErrorCode[errorCode as ErrorCode];
}
return ErrorCode.GENERAL;
}
}
Loading

0 comments on commit 338123f

Please sign in to comment.