Skip to content

fix(parameters): refresh AppConfig session token after 24 hrs #1916

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions packages/parameters/src/appconfig/AppConfigProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
AppConfigGetOptions,
AppConfigGetOutput,
} from '../types/AppConfigProvider';
import { APPCONFIG_TOKEN_EXPIRATION } from '../constants';

/**
* ## Intro
Expand Down Expand Up @@ -182,7 +183,10 @@ import type {
*/
class AppConfigProvider extends BaseProvider {
public client!: AppConfigDataClient;
protected configurationTokenStore = new Map<string, string>();
protected configurationTokenStore = new Map<
string,
{ value: string; expiration: number }
>();
protected valueStore = new Map<string, Uint8Array>();
private application?: string;
private environment: string;
Expand Down Expand Up @@ -270,23 +274,26 @@ 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
*/
protected async _get(
name: string,
options?: AppConfigGetOptions
): Promise<Uint8Array | undefined> {
/**
* 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,
Expand All @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/parameters/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,6 +23,7 @@ const Transform = {
} as const;

export {
APPCONFIG_TOKEN_EXPIRATION,
DEFAULT_MAX_AGE_SECS,
TRANSFORM_METHOD_JSON,
TRANSFORM_METHOD_BINARY,
Expand Down
57 changes: 56 additions & 1 deletion packages/parameters/tests/unit/AppConfigProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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', () => {
Expand Down