-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(gofeatureflag): Clear cache if configuration changes + provider …
…refactoring (#947) Signed-off-by: Thomas Poignant <thomas.poignant@gofeatureflag.org>
- Loading branch information
1 parent
52d8445
commit 338123f
Showing
13 changed files
with
501 additions
and
310 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 4 additions & 18 deletions
22
libs/providers/go-feature-flag/src/lib/context-transformer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
60
libs/providers/go-feature-flag/src/lib/controller/cache.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
218
libs/providers/go-feature-flag/src/lib/controller/goff-api.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.