Skip to content

Commit

Permalink
Updating the error handling behavior to emphasize resilience.
Browse files Browse the repository at this point in the history
  • Loading branch information
tarehart committed Dec 11, 2023
1 parent 96905f4 commit f88af6d
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 41 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -22,19 +23,17 @@ 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:

```typescript
// Instantly returns the cached configuration object that was
// polled in the background.
const configObject = poller.getConfigurationObject().latestValue;
const { latestValue } = poller.getConfigurationObject();
```

## Error handling
Expand All @@ -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
Expand Down
22 changes: 17 additions & 5 deletions __tests__/poller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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',
});
Expand All @@ -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 () => {
Expand Down
11 changes: 8 additions & 3 deletions examples/infinitePoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,19 @@ const poller = new Poller<SampleFormat>({
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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
85 changes: 60 additions & 25 deletions src/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ export interface ConfigStore<T> {
versionLabel?: string;
}

export interface Outcome {
isInitiallySuccessful: boolean;
error?: Error;
}

type PollingPhase = 'ready' | 'starting' | 'active' | 'stopped';

/**
* Starts polling immediately upon construction.
*/
export class Poller<T> {
private readonly DEFAULT_POLL_INTERVAL_SECONDS = 30;
private readonly DEFAULT_POLL_INTERVAL_SECONDS = 60;

private readonly config: PollerConfig<T>;

Expand All @@ -43,17 +45,33 @@ export class Poller<T> {
constructor(config: PollerConfig<T>) {
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<void> {
public async start(): Promise<Outcome> {
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 {
Expand Down Expand Up @@ -94,25 +112,33 @@ export class Poller<T> {
return this.configObjectStore;
}

private async startPolling(): Promise<void> {
private async startPolling(): Promise<Outcome> {
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<void> {
const { dataClient, logger } = this.config;
private async fetchLatestConfiguration(): Promise<Outcome> {
const { dataClient, logger, pollIntervalSeconds } = this.config;

const getCommand = new GetLatestConfigurationCommand({
ConfigurationToken: this.configurationToken,
Expand All @@ -129,13 +155,9 @@ export class Poller<T> {
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,
);

Expand All @@ -144,9 +166,12 @@ export class Poller<T> {
'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(
Expand All @@ -156,6 +181,10 @@ export class Poller<T> {
this.timeoutHandle = setTimeout(() => {
this.fetchLatestConfiguration();
}, nextIntervalInSeconds * 1000);

return {
isInitiallySuccessful: true,
};
}

private processGetResponse(
Expand Down Expand Up @@ -210,6 +239,12 @@ export class Poller<T> {
}

private getNextIntervalInSeconds(awsSuggestedSeconds?: number): number {
const { pollIntervalSeconds } = this.config;

if (awsSuggestedSeconds && pollIntervalSeconds) {
return Math.max(awsSuggestedSeconds, pollIntervalSeconds);
}

return (
this.config.pollIntervalSeconds ||
awsSuggestedSeconds ||
Expand Down

0 comments on commit f88af6d

Please sign in to comment.