diff --git a/README.md b/README.md index 44ec022..6bda598 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,51 @@ const poller = new Poller({ ConfigurationProfileIdentifier: 'Config1', }, logger: console.log, + // Turn the config string into an object however you like, + // we'll cache the result (and also the raw string). + configParser: (s: string) => JSON.parse(s), }); -await poller.start(); +try { + await poller.start(); +} catch (e) { + // Handle any errors connecting to AppConfig +} ``` Fetch: ```typescript -const value = poller.getConfigurationString().latestValue; +// Instantly returns the cached configuration object that was +// polled in the background. +const configObject = poller.getConfigurationObject().latestValue; +``` + +## Error handling + +A few things can go wrong when polling for AppConfig, such as: + +- Permissions or networking error connecting to AWS. +- The config document could have been changed to something your configParser can't handle. + +If there's an immediate connection problem during startup, and we're unable to retrieve the +configuration even once, we'll fail fast from the poller.start() function. + +If we startup successfully, but some time later there are problems polling, we'll report +the error via the errorCausingStaleValue response attribute and continue polling in hopes +that the error resolves itself. You may wish to monitor or alarm on this situation because +your config object is potentially outdated. + +```typescript +const { latestValue, errorCausingStaleValue } = poller.getConfigurationObject(); +if (errorCausingStaleValue) { + // Log some metric +} ``` ## License Licensed under the MIT license. See the [LICENSE](https://github.com/tarehart/aws-appconfig-poller/blob/main/LICENSE) file for details. - [gha-badge]: https://github.com/tarehart/aws-appconfig-poller/actions/workflows/nodejs.yml/badge.svg [gha-ci]: https://github.com/tarehart/aws-appconfig-poller/actions/workflows/nodejs.yml diff --git a/__tests__/poller.test.ts b/__tests__/poller.test.ts index 6badd15..5fbe0ca 100644 --- a/__tests__/poller.test.ts +++ b/__tests__/poller.test.ts @@ -1,42 +1,149 @@ -import { Poller } from '../src/poller.js'; +import { Poller, PollerConfig } from '../src/poller.js'; import { AppConfigDataClient, GetLatestConfigurationCommand, StartConfigurationSessionCommand, } from '@aws-sdk/client-appconfigdata'; -import { mockClient } from 'aws-sdk-client-mock'; +import { AwsError, mockClient } from 'aws-sdk-client-mock'; import { Uint8ArrayBlobAdapter } from '@smithy/util-stream'; +const standardConfig: Omit, 'dataClient'> = { + sessionConfig: { + ApplicationIdentifier: 'MyApp', + EnvironmentIdentifier: 'Test', + ConfigurationProfileIdentifier: 'Config1', + }, + configParser: (s: string) => s.substring(1), + pollIntervalSeconds: 1, +}; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe('Poller', () => { const appConfigClientMock = mockClient(AppConfigDataClient); - const configValue = 'Some Configuration'; + let poller: Poller | undefined; - appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ - InitialConfigurationToken: 'initialToken', + afterEach(() => { + poller.stop(); + appConfigClientMock.reset(); }); - appConfigClientMock.on(GetLatestConfigurationCommand).resolves({ - Configuration: Uint8ArrayBlobAdapter.fromString(configValue), + it('Polls', async () => { + const configValue = 'Some Configuration'; + + appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ + InitialConfigurationToken: 'initialToken', + }); + + appConfigClientMock.on(GetLatestConfigurationCommand).resolves({ + Configuration: Uint8ArrayBlobAdapter.fromString(configValue), + }); + + const dataClient = new AppConfigDataClient(); + + poller = new Poller({ + dataClient: dataClient, + ...standardConfig, + }); + + await poller.start(); + const latest = poller.getConfigurationString(); + + expect(latest.latestValue).toEqual(configValue); + expect(latest.errorCausingStaleValue).toBeUndefined(); }); - const dataClient = new AppConfigDataClient(); + it('Bubbles up error on startup', async () => { + appConfigClientMock.on(StartConfigurationSessionCommand).rejects({ + message: 'Failed to start session', + } as AwsError); - let poller: Poller | undefined; + const dataClient = new AppConfigDataClient(); - afterAll(() => { - poller.stop(); + poller = new Poller({ + dataClient: dataClient, + ...standardConfig, + }); + + await expect(poller.start()).rejects.toThrow(Error); }); - it('Polls', async () => { + it('Bubbles up error if first getLatest fails', async () => { + appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ + InitialConfigurationToken: 'initialToken', + }); + + appConfigClientMock.on(GetLatestConfigurationCommand).rejects({ + message: 'Failed to get latest', + }); + + const dataClient = new AppConfigDataClient(); + poller = new Poller({ dataClient: dataClient, - sessionConfig: { - ApplicationIdentifier: 'MyApp', - EnvironmentIdentifier: 'Test', - ConfigurationProfileIdentifier: 'Config1', + ...standardConfig, + }); + + await expect(poller.start()).rejects.toThrow(Error); + }); + + it('Continues polling if first getLatest string cannot be parsed', async () => { + appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ + InitialConfigurationToken: 'initialToken', + }); + + appConfigClientMock.on(GetLatestConfigurationCommand).resolves({ + Configuration: Uint8ArrayBlobAdapter.fromString('abc'), + }); + + const dataClient = new AppConfigDataClient(); + + poller = new Poller({ + dataClient: dataClient, + ...standardConfig, + configParser(s): string { + throw new Error('bad string ' + s); }, - logger: console.log, + }); + + await poller.start(); + + const str = poller.getConfigurationString(); + expect(str.latestValue).toBeDefined(); + + const obj = poller.getConfigurationObject(); + expect(obj.latestValue).toBeUndefined(); + expect(obj.errorCausingStaleValue).toBeDefined(); + }); + + it('Attempts session restart if second fetch fails', async () => { + const configValue = 'worked once'; + const configValue2 = 'worked again'; + + appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ + InitialConfigurationToken: 'initialToken', + }); + + appConfigClientMock + .on(GetLatestConfigurationCommand) + .resolvesOnce({ + Configuration: Uint8ArrayBlobAdapter.fromString(configValue), + }) + .rejectsOnce({ + message: 'Failed to get latest', + }) + .resolves({ + Configuration: Uint8ArrayBlobAdapter.fromString(configValue2), + }); + + const dataClient = new AppConfigDataClient(); + + poller = new Poller({ + dataClient: dataClient, + ...standardConfig, }); await poller.start(); @@ -44,5 +151,21 @@ describe('Poller', () => { expect(latest.latestValue).toEqual(configValue); expect(latest.errorCausingStaleValue).toBeUndefined(); + + await wait(standardConfig.pollIntervalSeconds * 1000 + 100); + + const updated = poller.getConfigurationObject(); + expect(updated.errorCausingStaleValue).toBeDefined(); + expect(updated.latestValue).toEqual( + standardConfig.configParser(configValue), + ); + + await wait(standardConfig.pollIntervalSeconds * 1000 + 100); + + const updatedAgain = poller.getConfigurationObject(); + expect(updatedAgain.errorCausingStaleValue).toBeUndefined(); + expect(updatedAgain.latestValue).toEqual( + standardConfig.configParser(configValue2), + ); }); }); diff --git a/examples/infinitePoller.ts b/examples/infinitePoller.ts index 213ee55..d804e49 100644 --- a/examples/infinitePoller.ts +++ b/examples/infinitePoller.ts @@ -57,7 +57,7 @@ const poller = new Poller({ EnvironmentIdentifier: 'Live', ConfigurationProfileIdentifier: 'YamlTest', }, - configTransformer: (s): SampleFormat => parse(s), + configParser: (s): SampleFormat => parse(s), logger: console.log, pollIntervalSeconds: 60, }); diff --git a/src/poller.ts b/src/poller.ts index f77056a..e11b954 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -6,11 +6,11 @@ import { StartConfigurationSessionCommandInput, } from '@aws-sdk/client-appconfigdata'; -interface PollerConfig { +export interface PollerConfig { dataClient: AppConfigDataClient; sessionConfig: StartConfigurationSessionCommandInput; pollIntervalSeconds?: number; - configTransformer?: (s: string) => T; + configParser?: (s: string) => T; logger?: (s: string, obj?: unknown) => void; } @@ -21,7 +21,7 @@ export interface ConfigStore { versionLabel?: string; } -type PollingPhase = 'ready' | 'active' | 'stopped'; +type PollingPhase = 'ready' | 'starting' | 'active' | 'stopped'; /** * Starts polling immediately upon construction. @@ -51,8 +51,9 @@ export class Poller { if (this.pollingPhase != 'ready') { throw new Error('Can only call start() once for an instance of Poller!'); } - this.pollingPhase = 'active'; + this.pollingPhase = 'starting'; await this.startPolling(); + this.pollingPhase = 'active'; } public stop(): void { @@ -77,7 +78,7 @@ export class Poller { /** * This returns a version of the config that's been parsed according to the - * configTransformer passed into this class. It's possible that this value is + * configParser passed into this class. It's possible that this value is * more stale than getConfigurationString, in the case where a new value * retrieved from AppConfig is malformed and the transformer has been failing. * @@ -125,10 +126,27 @@ export class Poller { this.processGetResponse(getResponse); } catch (e) { - // TODO: should we start a new configuration session in some cases? this.configStringStore.errorCausingStaleValue = e; this.configObjectStore.errorCausingStaleValue = e; - logger?.('Config string and object have gone stale', e); + + if (this.pollingPhase === 'starting') { + // If we're part of the initial startup sequence, fail fast. + throw e; + } + + logger?.( + 'Values have gone stale, will wait and then start a new configuration session in response to error:', + e, + ); + + this.timeoutHandle = setTimeout(() => { + logger?.( + 'Starting new configuration session in hopes of recovering...', + ); + this.startPolling(); + }, this.config.pollIntervalSeconds * 1000); + + return; } const nextIntervalInSeconds = this.getNextIntervalInSeconds( @@ -143,45 +161,51 @@ export class Poller { private processGetResponse( getResponse: GetLatestConfigurationCommandOutput, ): void { - const { logger, configTransformer } = this.config; + const { logger } = this.config; if (getResponse.NextPollConfigurationToken) { this.configurationToken = getResponse.NextPollConfigurationToken; } - const stringValue = getResponse.Configuration?.transformToString(); + try { + const stringValue = getResponse.Configuration?.transformToString(); + + if (stringValue) { + this.cacheNewValue(stringValue, getResponse.VersionLabel); + } else { + // When the configuration in the getResponse is empty, that means the configuration is + // unchanged from the last time we polled. + // https://docs.aws.amazon.com/appconfig/2019-10-09/APIReference/API_appconfigdata_GetLatestConfiguration.html + this.configStringStore.lastFreshTime = new Date(); + this.configObjectStore.lastFreshTime = new Date(); + } + } catch (e) { + this.configStringStore.errorCausingStaleValue = e; + this.configObjectStore.errorCausingStaleValue = e; + logger?.('Config string and object have gone stale:', e); + } + } + + private cacheNewValue(stringValue: string, versionLabel?: string): void { + const { logger, configParser } = this.config; - if (stringValue) { + this.configStringStore.latestValue = stringValue; + this.configStringStore.lastFreshTime = new Date(); + this.configStringStore.versionLabel = versionLabel; + this.configStringStore.errorCausingStaleValue = undefined; + + if (configParser) { try { - this.configStringStore.latestValue = stringValue; - this.configStringStore.lastFreshTime = new Date(); - this.configStringStore.versionLabel = getResponse.VersionLabel; - this.configStringStore.errorCausingStaleValue = undefined; - - if (configTransformer) { - try { - this.configObjectStore.latestValue = configTransformer( - this.configStringStore.latestValue, - ); - this.configObjectStore.lastFreshTime = new Date(); - this.configObjectStore.versionLabel = getResponse.VersionLabel; - this.configObjectStore.errorCausingStaleValue = undefined; - } catch (e) { - this.configObjectStore.errorCausingStaleValue = e; - logger?.('Config object has gone stale', e); - } - } + this.configObjectStore.latestValue = configParser( + this.configStringStore.latestValue, + ); + this.configObjectStore.lastFreshTime = new Date(); + this.configObjectStore.versionLabel = versionLabel; + this.configObjectStore.errorCausingStaleValue = undefined; } catch (e) { - this.configStringStore.errorCausingStaleValue = e; this.configObjectStore.errorCausingStaleValue = e; - logger?.('Config string and object have gone stale', e); + logger?.('Config object could not be parsed:', e); } - } else { - // When the configuration in the getResponse is empty, that means the configuration is - // unchanged from the last time we polled. - // https://docs.aws.amazon.com/appconfig/2019-10-09/APIReference/API_appconfigdata_GetLatestConfiguration.html - this.configStringStore.lastFreshTime = new Date(); - this.configObjectStore.lastFreshTime = new Date(); } }