diff --git a/packages/parameters/src/appconfig/AppConfigProvider.ts b/packages/parameters/src/appconfig/AppConfigProvider.ts index 0f38d649de..bf61526b66 100644 --- a/packages/parameters/src/appconfig/AppConfigProvider.ts +++ b/packages/parameters/src/appconfig/AppConfigProvider.ts @@ -10,6 +10,7 @@ import type { AppConfigGetOptions, AppConfigGetOutput, } from '../types/AppConfigProvider'; +import { APPCONFIG_TOKEN_EXPIRATION } from '../constants'; /** * ## Intro @@ -182,7 +183,10 @@ import type { */ class AppConfigProvider extends BaseProvider { public client!: AppConfigDataClient; - protected configurationTokenStore = new Map(); + protected configurationTokenStore = new Map< + string, + { value: string; expiration: number } + >(); protected valueStore = new Map(); private application?: string; private environment: string; @@ -270,6 +274,15 @@ class AppConfigProvider extends BaseProvider { /** * Retrieve a configuration from AWS AppConfig. * + * First we start the session and after that we retrieve the configuration from AppSync. + * When starting a session, the service returns a token that can be used to poll for changes + * for up to 24hrs, so we cache it for later use together with the expiration date. + * + * The value of the configuration is also cached internally because AppConfig returns an empty + * value if the configuration has not changed since the last poll. This way even if your code + * polls the configuration multiple times, we return the most recent value by returning the cached + * one if an empty response is returned by AppConfig. + * * @param {string} name - Name of the configuration or its ID * @param {AppConfigGetOptions} options - SDK options to propagate to `StartConfigurationSession` API call */ @@ -277,16 +290,10 @@ class AppConfigProvider extends BaseProvider { name: string, options?: AppConfigGetOptions ): Promise { - /** - * The new AppConfig APIs require two API calls to return the configuration - * First we start the session and after that we retrieve the configuration - * We need to store { name: token } pairs to use in the next execution - * We also need to store { name : value } pairs because AppConfig returns - * an empty value if the session already has the latest configuration - * but, we don't want to return an empty value to our callers. - * {@link https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-retrieving-the-configuration.html} - **/ - if (!this.configurationTokenStore.has(name)) { + if ( + !this.configurationTokenStore.has(name) || + this.configurationTokenStore.get(name)!.expiration <= Date.now() + ) { const sessionOptions: StartConfigurationSessionCommandInput = { ...(options?.sdkOptions || {}), ApplicationIdentifier: this.application, @@ -303,20 +310,23 @@ class AppConfigProvider extends BaseProvider { if (!session.InitialConfigurationToken) throw new Error('Unable to retrieve the configuration token'); - this.configurationTokenStore.set(name, session.InitialConfigurationToken); + this.configurationTokenStore.set(name, { + value: session.InitialConfigurationToken, + expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION, + }); } const getConfigurationCommand = new GetLatestConfigurationCommand({ - ConfigurationToken: this.configurationTokenStore.get(name), + ConfigurationToken: this.configurationTokenStore.get(name)?.value, }); const response = await this.client.send(getConfigurationCommand); if (response.NextPollConfigurationToken) { - this.configurationTokenStore.set( - name, - response.NextPollConfigurationToken - ); + this.configurationTokenStore.set(name, { + value: response.NextPollConfigurationToken, + expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION, + }); } else { this.configurationTokenStore.delete(name); } diff --git a/packages/parameters/src/constants.ts b/packages/parameters/src/constants.ts index dcb53165c7..4855b5bb4e 100644 --- a/packages/parameters/src/constants.ts +++ b/packages/parameters/src/constants.ts @@ -2,6 +2,7 @@ const DEFAULT_MAX_AGE_SECS = 5; const TRANSFORM_METHOD_JSON = 'json'; const TRANSFORM_METHOD_BINARY = 'binary'; const TRANSFORM_METHOD_AUTO = 'auto'; +const APPCONFIG_TOKEN_EXPIRATION = 23 * 60 * 60 * 1000 + 45 * 60 * 1000; // 23 hrs 45 min /** * Transform methods for values retrieved by parameter providers. @@ -22,6 +23,7 @@ const Transform = { } as const; export { + APPCONFIG_TOKEN_EXPIRATION, DEFAULT_MAX_AGE_SECS, TRANSFORM_METHOD_JSON, TRANSFORM_METHOD_BINARY, diff --git a/packages/parameters/tests/unit/AppConfigProvider.test.ts b/packages/parameters/tests/unit/AppConfigProvider.test.ts index cb6b5ad237..84e72889d5 100644 --- a/packages/parameters/tests/unit/AppConfigProvider.test.ts +++ b/packages/parameters/tests/unit/AppConfigProvider.test.ts @@ -15,11 +15,13 @@ import { Uint8ArrayBlobAdapter } from '@smithy/util-stream'; import { mockClient } from 'aws-sdk-client-mock'; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; import 'aws-sdk-client-mock-jest'; +import { APPCONFIG_TOKEN_EXPIRATION } from '../../src/constants'; jest.mock('@aws-lambda-powertools/commons', () => ({ ...jest.requireActual('@aws-lambda-powertools/commons'), addUserAgentMiddleware: jest.fn(), })); +jest.useFakeTimers(); describe('Class: AppConfigProvider', () => { const client = mockClient(AppConfigDataClient); @@ -201,7 +203,10 @@ describe('Class: AppConfigProvider', () => { // Prepare class AppConfigProviderMock extends AppConfigProvider { public _addToStore(key: string, value: string): void { - this.configurationTokenStore.set(key, value); + this.configurationTokenStore.set(key, { + value, + expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION, + }); } public _storeHas(key: string): boolean { @@ -289,6 +294,56 @@ describe('Class: AppConfigProvider', () => { expect(result1).toBe(mockData); expect(result2).toBe(mockData); }); + + test('when the session token has expired, it starts a new session and retrieves the token', async () => { + // Prepare + const options: AppConfigProviderOptions = { + application: 'MyApp', + environment: 'MyAppProdEnv', + }; + const provider = new AppConfigProvider(options); + const name = 'MyAppFeatureFlag'; + + const fakeInitialToken = 'aW5pdGlhbFRva2Vu'; + const fakeSecondToken = 'bZ6pdGlhbFRva3Wk'; + const fakeNextToken1 = 'bmV4dFRva2Vu'; + const mockData = Uint8ArrayBlobAdapter.fromString('foo'); + const mockData2 = Uint8ArrayBlobAdapter.fromString('bar'); + + client + .on(StartConfigurationSessionCommand) + .resolvesOnce({ + InitialConfigurationToken: fakeInitialToken, + }) + .resolvesOnce({ + InitialConfigurationToken: fakeSecondToken, + }) + .on(GetLatestConfigurationCommand, { + ConfigurationToken: fakeInitialToken, + }) + .resolves({ + Configuration: mockData, + NextPollConfigurationToken: fakeNextToken1, + }) + .on(GetLatestConfigurationCommand, { + ConfigurationToken: fakeSecondToken, + }) + .resolves({ + Configuration: mockData2, + NextPollConfigurationToken: fakeNextToken1, + }); + jest.setSystemTime(new Date('2022-03-10')); + + // Act + const result1 = await provider.get(name, { forceFetch: true }); + // Mock time skip of 24hrs + jest.setSystemTime(new Date('2022-03-11')); + const result2 = await provider.get(name, { forceFetch: true }); + + // Assess + expect(result1).toBe(mockData); + expect(result2).toBe(mockData2); + }); }); describe('Method: _getMultiple', () => {