Skip to content

Commit 559ef2d

Browse files
authored
fix(parameters): refresh AppConfig session token after 24 hrs (#1916)
* fix(parameters): refresh appconfig session after 24 hrs * chore: revert change in test
1 parent 3e17477 commit 559ef2d

File tree

3 files changed

+85
-18
lines changed

3 files changed

+85
-18
lines changed

Diff for: packages/parameters/src/appconfig/AppConfigProvider.ts

+27-17
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
AppConfigGetOptions,
1111
AppConfigGetOutput,
1212
} from '../types/AppConfigProvider';
13+
import { APPCONFIG_TOKEN_EXPIRATION } from '../constants';
1314

1415
/**
1516
* ## Intro
@@ -182,7 +183,10 @@ import type {
182183
*/
183184
class AppConfigProvider extends BaseProvider {
184185
public client!: AppConfigDataClient;
185-
protected configurationTokenStore = new Map<string, string>();
186+
protected configurationTokenStore = new Map<
187+
string,
188+
{ value: string; expiration: number }
189+
>();
186190
protected valueStore = new Map<string, Uint8Array>();
187191
private application?: string;
188192
private environment: string;
@@ -270,23 +274,26 @@ class AppConfigProvider extends BaseProvider {
270274
/**
271275
* Retrieve a configuration from AWS AppConfig.
272276
*
277+
* First we start the session and after that we retrieve the configuration from AppSync.
278+
* When starting a session, the service returns a token that can be used to poll for changes
279+
* for up to 24hrs, so we cache it for later use together with the expiration date.
280+
*
281+
* The value of the configuration is also cached internally because AppConfig returns an empty
282+
* value if the configuration has not changed since the last poll. This way even if your code
283+
* polls the configuration multiple times, we return the most recent value by returning the cached
284+
* one if an empty response is returned by AppConfig.
285+
*
273286
* @param {string} name - Name of the configuration or its ID
274287
* @param {AppConfigGetOptions} options - SDK options to propagate to `StartConfigurationSession` API call
275288
*/
276289
protected async _get(
277290
name: string,
278291
options?: AppConfigGetOptions
279292
): Promise<Uint8Array | undefined> {
280-
/**
281-
* The new AppConfig APIs require two API calls to return the configuration
282-
* First we start the session and after that we retrieve the configuration
283-
* We need to store { name: token } pairs to use in the next execution
284-
* We also need to store { name : value } pairs because AppConfig returns
285-
* an empty value if the session already has the latest configuration
286-
* but, we don't want to return an empty value to our callers.
287-
* {@link https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-retrieving-the-configuration.html}
288-
**/
289-
if (!this.configurationTokenStore.has(name)) {
293+
if (
294+
!this.configurationTokenStore.has(name) ||
295+
this.configurationTokenStore.get(name)!.expiration <= Date.now()
296+
) {
290297
const sessionOptions: StartConfigurationSessionCommandInput = {
291298
...(options?.sdkOptions || {}),
292299
ApplicationIdentifier: this.application,
@@ -303,20 +310,23 @@ class AppConfigProvider extends BaseProvider {
303310
if (!session.InitialConfigurationToken)
304311
throw new Error('Unable to retrieve the configuration token');
305312

306-
this.configurationTokenStore.set(name, session.InitialConfigurationToken);
313+
this.configurationTokenStore.set(name, {
314+
value: session.InitialConfigurationToken,
315+
expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION,
316+
});
307317
}
308318

309319
const getConfigurationCommand = new GetLatestConfigurationCommand({
310-
ConfigurationToken: this.configurationTokenStore.get(name),
320+
ConfigurationToken: this.configurationTokenStore.get(name)?.value,
311321
});
312322

313323
const response = await this.client.send(getConfigurationCommand);
314324

315325
if (response.NextPollConfigurationToken) {
316-
this.configurationTokenStore.set(
317-
name,
318-
response.NextPollConfigurationToken
319-
);
326+
this.configurationTokenStore.set(name, {
327+
value: response.NextPollConfigurationToken,
328+
expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION,
329+
});
320330
} else {
321331
this.configurationTokenStore.delete(name);
322332
}

Diff for: packages/parameters/src/constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const DEFAULT_MAX_AGE_SECS = 5;
22
const TRANSFORM_METHOD_JSON = 'json';
33
const TRANSFORM_METHOD_BINARY = 'binary';
44
const TRANSFORM_METHOD_AUTO = 'auto';
5+
const APPCONFIG_TOKEN_EXPIRATION = 23 * 60 * 60 * 1000 + 45 * 60 * 1000; // 23 hrs 45 min
56

67
/**
78
* Transform methods for values retrieved by parameter providers.
@@ -22,6 +23,7 @@ const Transform = {
2223
} as const;
2324

2425
export {
26+
APPCONFIG_TOKEN_EXPIRATION,
2527
DEFAULT_MAX_AGE_SECS,
2628
TRANSFORM_METHOD_JSON,
2729
TRANSFORM_METHOD_BINARY,

Diff for: packages/parameters/tests/unit/AppConfigProvider.test.ts

+56-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import { Uint8ArrayBlobAdapter } from '@smithy/util-stream';
1515
import { mockClient } from 'aws-sdk-client-mock';
1616
import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons';
1717
import 'aws-sdk-client-mock-jest';
18+
import { APPCONFIG_TOKEN_EXPIRATION } from '../../src/constants';
1819

1920
jest.mock('@aws-lambda-powertools/commons', () => ({
2021
...jest.requireActual('@aws-lambda-powertools/commons'),
2122
addUserAgentMiddleware: jest.fn(),
2223
}));
24+
jest.useFakeTimers();
2325

2426
describe('Class: AppConfigProvider', () => {
2527
const client = mockClient(AppConfigDataClient);
@@ -201,7 +203,10 @@ describe('Class: AppConfigProvider', () => {
201203
// Prepare
202204
class AppConfigProviderMock extends AppConfigProvider {
203205
public _addToStore(key: string, value: string): void {
204-
this.configurationTokenStore.set(key, value);
206+
this.configurationTokenStore.set(key, {
207+
value,
208+
expiration: Date.now() + APPCONFIG_TOKEN_EXPIRATION,
209+
});
205210
}
206211

207212
public _storeHas(key: string): boolean {
@@ -289,6 +294,56 @@ describe('Class: AppConfigProvider', () => {
289294
expect(result1).toBe(mockData);
290295
expect(result2).toBe(mockData);
291296
});
297+
298+
test('when the session token has expired, it starts a new session and retrieves the token', async () => {
299+
// Prepare
300+
const options: AppConfigProviderOptions = {
301+
application: 'MyApp',
302+
environment: 'MyAppProdEnv',
303+
};
304+
const provider = new AppConfigProvider(options);
305+
const name = 'MyAppFeatureFlag';
306+
307+
const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
308+
const fakeSecondToken = 'bZ6pdGlhbFRva3Wk';
309+
const fakeNextToken1 = 'bmV4dFRva2Vu';
310+
const mockData = Uint8ArrayBlobAdapter.fromString('foo');
311+
const mockData2 = Uint8ArrayBlobAdapter.fromString('bar');
312+
313+
client
314+
.on(StartConfigurationSessionCommand)
315+
.resolvesOnce({
316+
InitialConfigurationToken: fakeInitialToken,
317+
})
318+
.resolvesOnce({
319+
InitialConfigurationToken: fakeSecondToken,
320+
})
321+
.on(GetLatestConfigurationCommand, {
322+
ConfigurationToken: fakeInitialToken,
323+
})
324+
.resolves({
325+
Configuration: mockData,
326+
NextPollConfigurationToken: fakeNextToken1,
327+
})
328+
.on(GetLatestConfigurationCommand, {
329+
ConfigurationToken: fakeSecondToken,
330+
})
331+
.resolves({
332+
Configuration: mockData2,
333+
NextPollConfigurationToken: fakeNextToken1,
334+
});
335+
jest.setSystemTime(new Date('2022-03-10'));
336+
337+
// Act
338+
const result1 = await provider.get(name, { forceFetch: true });
339+
// Mock time skip of 24hrs
340+
jest.setSystemTime(new Date('2022-03-11'));
341+
const result2 = await provider.get(name, { forceFetch: true });
342+
343+
// Assess
344+
expect(result1).toBe(mockData);
345+
expect(result2).toBe(mockData2);
346+
});
292347
});
293348

294349
describe('Method: _getMultiple', () => {

0 commit comments

Comments
 (0)