From f88af6de815afe72f60733758bde0d5008f2b647 Mon Sep 17 00:00:00 2001 From: Tyler Arehart Date: Mon, 11 Dec 2023 10:17:40 -0800 Subject: [PATCH] Updating the error handling behavior to emphasize resilience. --- README.md | 14 +++---- __tests__/poller.test.ts | 22 +++++++--- examples/infinitePoller.ts | 11 +++-- package.json | 2 +- src/poller.ts | 85 +++++++++++++++++++++++++++----------- 5 files changed, 93 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6bda598..89b9d91 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Initialize: const poller = new Poller({ dataClient: dataClient, sessionConfig: { + // This configuration will be specific to your app ApplicationIdentifier: 'MyApp', EnvironmentIdentifier: 'Test', ConfigurationProfileIdentifier: 'Config1', @@ -22,11 +23,9 @@ const poller = new Poller({ configParser: (s: string) => JSON.parse(s), }); -try { - await poller.start(); -} catch (e) { - // Handle any errors connecting to AppConfig -} +// We avoid bubbling up exceptions, and keep trying in the background +// even if we were not initially successful. +const { isInitiallySuccessful, error } = await poller.start(); ``` Fetch: @@ -34,7 +33,7 @@ Fetch: ```typescript // Instantly returns the cached configuration object that was // polled in the background. -const configObject = poller.getConfigurationObject().latestValue; +const { latestValue } = poller.getConfigurationObject(); ``` ## Error handling @@ -45,7 +44,8 @@ A few things can go wrong when polling for AppConfig, such as: - 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. +configuration even once, we'll report it in the response from poller.start(), and continue +attempting to connect in the background. 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 diff --git a/__tests__/poller.test.ts b/__tests__/poller.test.ts index 5fbe0ca..18a2803 100644 --- a/__tests__/poller.test.ts +++ b/__tests__/poller.test.ts @@ -49,14 +49,18 @@ describe('Poller', () => { ...standardConfig, }); - await poller.start(); + const { isInitiallySuccessful, error } = await poller.start(); + + expect(isInitiallySuccessful).toBeTruthy(); + expect(error).toBeUndefined(); + const latest = poller.getConfigurationString(); expect(latest.latestValue).toEqual(configValue); expect(latest.errorCausingStaleValue).toBeUndefined(); }); - it('Bubbles up error on startup', async () => { + it('Reports error if startConfigurationSession fails', async () => { appConfigClientMock.on(StartConfigurationSessionCommand).rejects({ message: 'Failed to start session', } as AwsError); @@ -68,10 +72,14 @@ describe('Poller', () => { ...standardConfig, }); - await expect(poller.start()).rejects.toThrow(Error); + const { isInitiallySuccessful, error } = await poller.start(); + + expect(isInitiallySuccessful).toBeFalsy(); + expect(error).toBeDefined(); + expect(error.message).toBe('Failed to start session'); }); - it('Bubbles up error if first getLatest fails', async () => { + it('Reports error if first getLatest fails', async () => { appConfigClientMock.on(StartConfigurationSessionCommand).resolves({ InitialConfigurationToken: 'initialToken', }); @@ -87,7 +95,11 @@ describe('Poller', () => { ...standardConfig, }); - await expect(poller.start()).rejects.toThrow(Error); + const { isInitiallySuccessful, error } = await poller.start(); + + expect(isInitiallySuccessful).toBeFalsy(); + expect(error).toBeDefined(); + expect(error.message).toBe('Failed to get latest'); }); it('Continues polling if first getLatest string cannot be parsed', async () => { diff --git a/examples/infinitePoller.ts b/examples/infinitePoller.ts index d804e49..ab45595 100644 --- a/examples/infinitePoller.ts +++ b/examples/infinitePoller.ts @@ -62,14 +62,19 @@ const poller = new Poller({ pollIntervalSeconds: 60, }); -await poller.start(); +const { isInitiallySuccessful, error } = await poller.start(); -console.log('Starting at:', new Date()); +if (!isInitiallySuccessful) { + poller.stop(); + throw new Error('Startup failed', { cause: error }); +} + +console.log('Connection succeeded at:', new Date()); setInterval(() => { const obj = poller.getConfigurationObject(); console.log('Current config entry', obj); -}, 1000 * 60); +}, 1000 * 5); // This will run forever until you manually terminate it. // Normally you would call poller.stop() if you want the program to exit. diff --git a/package.json b/package.json index 5f86156..697be92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aws-appconfig-poller", - "version": "0.0.2", + "version": "0.0.3", "description": "A wrapper around @aws-sdk/client-appconfigdata to provide background polling and caching.", "repository": { "type": "git", diff --git a/src/poller.ts b/src/poller.ts index e11b954..4f68d83 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -21,13 +21,15 @@ export interface ConfigStore { versionLabel?: string; } +export interface Outcome { + isInitiallySuccessful: boolean; + error?: Error; +} + type PollingPhase = 'ready' | 'starting' | 'active' | 'stopped'; -/** - * Starts polling immediately upon construction. - */ export class Poller { - private readonly DEFAULT_POLL_INTERVAL_SECONDS = 30; + private readonly DEFAULT_POLL_INTERVAL_SECONDS = 60; private readonly config: PollerConfig; @@ -43,17 +45,33 @@ export class Poller { constructor(config: PollerConfig) { this.config = config; + const { + pollIntervalSeconds, + sessionConfig: { RequiredMinimumPollIntervalInSeconds: requiredMin }, + } = config; + + if ( + pollIntervalSeconds && + requiredMin && + pollIntervalSeconds < requiredMin + ) { + throw new Error( + 'Cannot configure a poll interval shorter than RequiredMinimumPollIntervalInSeconds', + ); + } + this.configStringStore = {}; this.configObjectStore = {}; } - public async start(): Promise { + public async start(): Promise { if (this.pollingPhase != 'ready') { throw new Error('Can only call start() once for an instance of Poller!'); } this.pollingPhase = 'starting'; - await this.startPolling(); + const result = await this.startPolling(); this.pollingPhase = 'active'; + return result; } public stop(): void { @@ -94,25 +112,33 @@ export class Poller { return this.configObjectStore; } - private async startPolling(): Promise { + private async startPolling(): Promise { const { dataClient, sessionConfig } = this.config; const startCommand = new StartConfigurationSessionCommand(sessionConfig); - const result = await dataClient.send(startCommand); - if (!result.InitialConfigurationToken) { - throw new Error( - 'Missing configuration token from AppConfig StartConfigurationSession response', - ); - } + try { + const result = await dataClient.send(startCommand); - this.configurationToken = result.InitialConfigurationToken; + if (!result.InitialConfigurationToken) { + throw new Error( + 'Missing configuration token from AppConfig StartConfigurationSession response', + ); + } + + this.configurationToken = result.InitialConfigurationToken; - await this.fetchLatestConfiguration(); + return await this.fetchLatestConfiguration(); + } catch (e) { + return { + isInitiallySuccessful: false, + error: e, + }; + } } - private async fetchLatestConfiguration(): Promise { - const { dataClient, logger } = this.config; + private async fetchLatestConfiguration(): Promise { + const { dataClient, logger, pollIntervalSeconds } = this.config; const getCommand = new GetLatestConfigurationCommand({ ConfigurationToken: this.configurationToken, @@ -129,13 +155,9 @@ export class Poller { this.configStringStore.errorCausingStaleValue = e; this.configObjectStore.errorCausingStaleValue = 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:', + `Failed to get value from AppConfig during ${this.pollingPhase} phase!` + + `Will wait ${pollIntervalSeconds}s and then start a new configuration session in response to error:`, e, ); @@ -144,9 +166,12 @@ export class Poller { 'Starting new configuration session in hopes of recovering...', ); this.startPolling(); - }, this.config.pollIntervalSeconds * 1000); + }, pollIntervalSeconds * 1000); - return; + return { + isInitiallySuccessful: false, + error: e, + }; } const nextIntervalInSeconds = this.getNextIntervalInSeconds( @@ -156,6 +181,10 @@ export class Poller { this.timeoutHandle = setTimeout(() => { this.fetchLatestConfiguration(); }, nextIntervalInSeconds * 1000); + + return { + isInitiallySuccessful: true, + }; } private processGetResponse( @@ -210,6 +239,12 @@ export class Poller { } private getNextIntervalInSeconds(awsSuggestedSeconds?: number): number { + const { pollIntervalSeconds } = this.config; + + if (awsSuggestedSeconds && pollIntervalSeconds) { + return Math.max(awsSuggestedSeconds, pollIntervalSeconds); + } + return ( this.config.pollIntervalSeconds || awsSuggestedSeconds ||