Skip to content

Commit 338123f

Browse files
feat(gofeatureflag): Clear cache if configuration changes + provider refactoring (#947)
Signed-off-by: Thomas Poignant <[email protected]>
1 parent 52d8445 commit 338123f

13 files changed

+501
-310
lines changed

libs/providers/go-feature-flag/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"current-version": "echo $npm_package_version"
77
},
88
"peerDependencies": {
9-
"@openfeature/server-sdk": "^1.13.0"
9+
"@openfeature/server-sdk": "^1.15.0"
1010
}
11-
}
11+
}

libs/providers/go-feature-flag/src/lib/context-transfomer.spec.ts

+8-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { EvaluationContext } from '@openfeature/server-sdk';
2-
import { GoFeatureFlagUser } from './model';
2+
import { GOFFEvaluationContext } from './model';
33
import { transformContext } from './context-transformer';
44

55
describe('contextTransformer', () => {
66
it('should use the targetingKey as user key', () => {
77
const got = transformContext({
88
targetingKey: 'user-key',
99
} as EvaluationContext);
10-
const want: GoFeatureFlagUser = {
10+
const want: GOFFEvaluationContext = {
1111
key: 'user-key',
12-
anonymous: false,
1312
custom: {},
1413
};
1514
expect(got).toEqual(want);
@@ -20,10 +19,9 @@ describe('contextTransformer', () => {
2019
targetingKey: 'user-key',
2120
anonymous: true,
2221
} as EvaluationContext);
23-
const want: GoFeatureFlagUser = {
22+
const want: GOFFEvaluationContext = {
2423
key: 'user-key',
25-
anonymous: true,
26-
custom: {},
24+
custom: { anonymous: true },
2725
};
2826
expect(got).toEqual(want);
2927
});
@@ -36,10 +34,10 @@ describe('contextTransformer', () => {
3634
3735
} as EvaluationContext);
3836

39-
const want: GoFeatureFlagUser = {
37+
const want: GOFFEvaluationContext = {
4038
key: 'dd3027562879ff6857cc6b8b88ced570546d7c0c',
41-
anonymous: true,
4239
custom: {
40+
anonymous: true,
4341
firstname: 'John',
4442
lastname: 'Doe',
4543
@@ -56,13 +54,13 @@ describe('contextTransformer', () => {
5654
5755
} as EvaluationContext);
5856

59-
const want: GoFeatureFlagUser = {
57+
const want: GOFFEvaluationContext = {
6058
key: 'user-key',
61-
anonymous: true,
6259
custom: {
6360
firstname: 'John',
6461
lastname: 'Doe',
6562
63+
anonymous: true,
6664
},
6765
};
6866
expect(got).toEqual(want);
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,17 @@
11
import { EvaluationContext } from '@openfeature/server-sdk';
22
import { sha1 } from 'object-hash';
3-
import { GoFeatureFlagUser } from './model';
3+
import { GOFFEvaluationContext } from './model';
44

55
/**
66
* transformContext takes the raw OpenFeature context returns a GoFeatureFlagUser.
77
* @param context - the context used for flag evaluation.
8-
* @returns {GoFeatureFlagUser} the user against who we will evaluate the flag.
8+
* @returns {GOFFEvaluationContext} the evaluation context against which we will evaluate the flag.
99
*/
10-
export function transformContext(context: EvaluationContext): GoFeatureFlagUser {
10+
export function transformContext(context: EvaluationContext): GOFFEvaluationContext {
1111
const { targetingKey, ...attributes } = context;
12-
13-
// If we don't have a targetingKey we are using a hash of the object to build
14-
// a consistent key. If for some reason it fails we are using a constant string
1512
const key = targetingKey || sha1(context) || 'anonymous';
16-
17-
// Handle the special case of the anonymous field
18-
let anonymous = false;
19-
if (attributes !== undefined && attributes !== null && 'anonymous' in attributes) {
20-
if (typeof attributes['anonymous'] === 'boolean') {
21-
anonymous = attributes['anonymous'];
22-
}
23-
delete attributes['anonymous'];
24-
}
25-
2613
return {
27-
key,
28-
anonymous,
14+
key: key,
2915
custom: attributes,
3016
};
3117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { GoFeatureFlagProviderOptions } from '../model';
2+
import { EvaluationContext, Logger, ResolutionDetails } from '@openfeature/server-sdk';
3+
import { LRUCache } from 'lru-cache';
4+
import hash from 'object-hash';
5+
6+
export class CacheController {
7+
// cacheTTL is the time we keep the evaluation in the cache before we consider it as obsolete.
8+
// If you want to keep the value forever, you can set the FlagCacheTTL field to -1
9+
private readonly cacheTTL?: number;
10+
// logger is the Open Feature logger to use
11+
private logger?: Logger;
12+
// cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation
13+
private readonly cache?: LRUCache<string, ResolutionDetails<any>>;
14+
// options for this provider
15+
private readonly options: GoFeatureFlagProviderOptions;
16+
17+
constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) {
18+
this.options = options;
19+
this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60;
20+
this.logger = logger;
21+
const cacheSize =
22+
options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000;
23+
this.cache = new LRUCache({ maxSize: cacheSize, sizeCalculation: () => 1 });
24+
}
25+
26+
get(flagKey: string, evaluationContext: EvaluationContext): ResolutionDetails<any> | undefined {
27+
if (this.options.disableCache) {
28+
return undefined;
29+
}
30+
const cacheKey = this.buildCacheKey(flagKey, evaluationContext);
31+
return this.cache?.get(cacheKey);
32+
}
33+
34+
set(
35+
flagKey: string,
36+
evaluationContext: EvaluationContext,
37+
evaluationResponse: { resolutionDetails: ResolutionDetails<any>; isCacheable: boolean },
38+
) {
39+
if (this.options.disableCache) {
40+
return;
41+
}
42+
43+
const cacheKey = this.buildCacheKey(flagKey, evaluationContext);
44+
if (this.cache !== undefined && evaluationResponse.isCacheable) {
45+
if (this.cacheTTL === -1) {
46+
this.cache.set(cacheKey, evaluationResponse.resolutionDetails);
47+
} else {
48+
this.cache.set(cacheKey, evaluationResponse.resolutionDetails, { ttl: this.cacheTTL });
49+
}
50+
}
51+
}
52+
53+
clear(): void {
54+
return this.cache?.clear();
55+
}
56+
57+
private buildCacheKey(flagKey: string, evaluationContext: EvaluationContext): string {
58+
return `${flagKey}-${hash(evaluationContext)}`;
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
ConfigurationChange,
3+
DataCollectorRequest,
4+
DataCollectorResponse,
5+
FeatureEvent,
6+
GoFeatureFlagProviderOptions,
7+
GoFeatureFlagProxyRequest,
8+
GoFeatureFlagProxyResponse,
9+
} from '../model';
10+
import {
11+
ErrorCode,
12+
EvaluationContext,
13+
FlagNotFoundError,
14+
Logger,
15+
ResolutionDetails,
16+
StandardResolutionReasons,
17+
TypeMismatchError,
18+
} from '@openfeature/server-sdk';
19+
import { transformContext } from '../context-transformer';
20+
import axios, { isAxiosError } from 'axios';
21+
import { Unauthorized } from '../errors/unauthorized';
22+
import { ProxyNotReady } from '../errors/proxyNotReady';
23+
import { ProxyTimeout } from '../errors/proxyTimeout';
24+
import { UnknownError } from '../errors/unknownError';
25+
import { CollectorError } from '../errors/collector-error';
26+
import { ConfigurationChangeEndpointNotFound } from '../errors/configuration-change-endpoint-not-found';
27+
import { ConfigurationChangeEndpointUnknownErr } from '../errors/configuration-change-endpoint-unknown-err';
28+
import { GoFeatureFlagError } from '../errors/goff-error';
29+
30+
export class GoffApiController {
31+
// endpoint of your go-feature-flag relay proxy instance
32+
private readonly endpoint: string;
33+
34+
// timeout in millisecond before we consider the request as a failure
35+
private readonly timeout: number;
36+
// logger is the Open Feature logger to use
37+
private logger?: Logger;
38+
39+
// etag is the etag of the last configuration change
40+
private etag: string | null = null;
41+
42+
constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) {
43+
this.endpoint = options.endpoint;
44+
this.timeout = options.timeout ?? 0;
45+
this.logger = logger;
46+
// Add API key to the headers
47+
if (options.apiKey) {
48+
axios.defaults.headers.common['Authorization'] = `Bearer ${options.apiKey}`;
49+
}
50+
}
51+
52+
/**
53+
* Call the GO Feature Flag API to evaluate a flag
54+
* @param flagKey
55+
* @param defaultValue
56+
* @param evaluationContext
57+
* @param expectedType
58+
*/
59+
async evaluate<T>(
60+
flagKey: string,
61+
defaultValue: T,
62+
evaluationContext: EvaluationContext,
63+
expectedType: string,
64+
): Promise<{ resolutionDetails: ResolutionDetails<T>; isCacheable: boolean }> {
65+
const goffEvaluationContext = transformContext(evaluationContext);
66+
67+
// build URL to access to the endpoint
68+
const endpointURL = new URL(this.endpoint);
69+
endpointURL.pathname = `v1/feature/${flagKey}/eval`;
70+
71+
const request: GoFeatureFlagProxyRequest<T> = {
72+
evaluationContext: goffEvaluationContext,
73+
defaultValue,
74+
};
75+
let apiResponseData: GoFeatureFlagProxyResponse<T>;
76+
77+
try {
78+
const response = await axios.post<GoFeatureFlagProxyResponse<T>>(endpointURL.toString(), request, {
79+
headers: {
80+
'Content-Type': 'application/json',
81+
Accept: 'application/json',
82+
},
83+
timeout: this.timeout,
84+
});
85+
apiResponseData = response.data;
86+
} catch (error) {
87+
if (axios.isAxiosError(error) && error.response?.status == 401) {
88+
throw new Unauthorized('invalid token used to contact GO Feature Flag relay proxy instance');
89+
}
90+
// Impossible to contact the relay-proxy
91+
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.response?.status === 404)) {
92+
throw new ProxyNotReady(`impossible to call go-feature-flag relay proxy on ${endpointURL}`, error);
93+
}
94+
95+
// Timeout when calling the API
96+
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
97+
throw new ProxyTimeout(`impossible to retrieve the ${flagKey} on time`, error);
98+
}
99+
100+
throw new UnknownError(
101+
`unknown error while retrieving flag ${flagKey} for evaluation context ${evaluationContext.targetingKey}`,
102+
error,
103+
);
104+
}
105+
// Check that we received the expectedType
106+
if (typeof apiResponseData.value !== expectedType) {
107+
throw new TypeMismatchError(
108+
`Flag value ${flagKey} had unexpected type ${typeof apiResponseData.value}, expected ${expectedType}.`,
109+
);
110+
}
111+
// Case of the flag is not found
112+
if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) {
113+
throw new FlagNotFoundError(`Flag ${flagKey} was not found in your configuration`);
114+
}
115+
116+
// Case of the flag is disabled
117+
if (apiResponseData.reason === StandardResolutionReasons.DISABLED) {
118+
// we don't set a variant since we are using the default value, and we are not able to know
119+
// which variant it is.
120+
return {
121+
resolutionDetails: { value: defaultValue, reason: apiResponseData.reason },
122+
isCacheable: true,
123+
};
124+
}
125+
126+
if (apiResponseData.reason === StandardResolutionReasons.ERROR) {
127+
return {
128+
resolutionDetails: {
129+
value: defaultValue,
130+
reason: apiResponseData.reason,
131+
errorCode: this.convertErrorCode(apiResponseData.errorCode),
132+
},
133+
isCacheable: true,
134+
};
135+
}
136+
137+
return {
138+
resolutionDetails: {
139+
value: apiResponseData.value,
140+
variant: apiResponseData.variationType,
141+
reason: apiResponseData.reason?.toString() || 'UNKNOWN',
142+
flagMetadata: apiResponseData.metadata || undefined,
143+
errorCode: this.convertErrorCode(apiResponseData.errorCode),
144+
},
145+
isCacheable: apiResponseData.cacheable,
146+
};
147+
}
148+
149+
async collectData(events: FeatureEvent<any>[], dataCollectorMetadata: Record<string, string>) {
150+
if (events?.length === 0) {
151+
return;
152+
}
153+
154+
const request: DataCollectorRequest<boolean> = { events: events, meta: dataCollectorMetadata };
155+
const endpointURL = new URL(this.endpoint);
156+
endpointURL.pathname = 'v1/data/collector';
157+
158+
try {
159+
await axios.post<DataCollectorResponse>(endpointURL.toString(), request, {
160+
headers: {
161+
'Content-Type': 'application/json',
162+
Accept: 'application/json',
163+
},
164+
timeout: this.timeout,
165+
});
166+
} catch (e) {
167+
throw new CollectorError(`impossible to send the data to the collector: ${e}`);
168+
}
169+
}
170+
171+
public async configurationHasChanged(): Promise<ConfigurationChange> {
172+
const url = `${this.endpoint}v1/flag/change`;
173+
174+
const headers: any = {
175+
'Content-Type': 'application/json',
176+
};
177+
178+
if (this.etag) {
179+
headers['If-None-Match'] = this.etag;
180+
}
181+
try {
182+
const response = await axios.get(url, { headers });
183+
if (response.status === 304) {
184+
return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED;
185+
}
186+
187+
const isInitialConfiguration = this.etag === null;
188+
this.etag = response.headers['etag'];
189+
return isInitialConfiguration
190+
? ConfigurationChange.FLAG_CONFIGURATION_INITIALIZED
191+
: ConfigurationChange.FLAG_CONFIGURATION_UPDATED;
192+
} catch (e) {
193+
if (isAxiosError(e) && e.response?.status === 304) {
194+
return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED;
195+
}
196+
if (isAxiosError(e) && e.response?.status === 404) {
197+
throw new ConfigurationChangeEndpointNotFound('impossible to find the configuration change endpoint');
198+
}
199+
if (e instanceof GoFeatureFlagError) {
200+
throw e;
201+
}
202+
throw new ConfigurationChangeEndpointUnknownErr(
203+
'unknown error while retrieving the configuration change endpoint',
204+
e as Error,
205+
);
206+
}
207+
}
208+
209+
private convertErrorCode(errorCode: ErrorCode | undefined): ErrorCode | undefined {
210+
if (errorCode === undefined) {
211+
return undefined;
212+
}
213+
if (Object.values(ErrorCode).includes(errorCode as ErrorCode)) {
214+
return ErrorCode[errorCode as ErrorCode];
215+
}
216+
return ErrorCode.GENERAL;
217+
}
218+
}

0 commit comments

Comments
 (0)