Skip to content

Commit b1991e0

Browse files
authored
Improve uncaught error handling, include configuration errors in logs (#227)
* Improve uncaught error handling * Exit on uncaught error to avoid logging to stderr * Make sure the configuration issues are logged
1 parent 4245010 commit b1991e0

File tree

6 files changed

+111
-65
lines changed

6 files changed

+111
-65
lines changed

packages/airnode-feed/src/index.ts

+20-13
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,38 @@
1-
import { go } from '@api3/promise-utils';
2-
31
import { initiateSignedApiUpdateLoops } from './fetch-beacon-data';
42
import { initiateHeartbeatLoop } from './heartbeat';
53
import { logger } from './logger';
64
import { initializeState } from './state';
75
import { loadConfig } from './validation/config';
86

7+
const setupUncaughtErrorHandler = () => {
8+
// NOTE: From the Node.js docs:
9+
//
10+
// Installing an 'uncaughtExceptionMonitor' listener does not change the behavior once an 'uncaughtException' event is
11+
// emitted. The process will still crash if no 'uncaughtException' listener is installed.
12+
process.on('uncaughtExceptionMonitor', (error, origin) => {
13+
logger.error('Uncaught exception.', error, { origin });
14+
});
15+
16+
// We want to exit the process immediately to avoid Node.js to log the uncaught error to stderr.
17+
process.on('uncaughtException', () => process.exit(1));
18+
process.on('unhandledRejection', () => process.exit(1));
19+
};
20+
921
// Start the Airnode feed. All application errors should be handled by this function (or its callees) and any error from
1022
// this function is considered unexpected.
1123
const startAirnodeFeed = async () => {
12-
const goConfig = await go(loadConfig);
13-
if (!goConfig.success) {
14-
// Note, that the error should not expose any sensitive information.
15-
logger.error('Failed to load the configuration.', goConfig.error);
16-
return;
17-
}
18-
initializeState(goConfig.data);
24+
const config = await loadConfig();
25+
if (!config) return;
26+
initializeState(config);
1927

2028
void initiateSignedApiUpdateLoops();
2129
initiateHeartbeatLoop();
2230
};
2331

2432
const main = async () => {
25-
const goStartAirnodeFeed = await go(startAirnodeFeed);
26-
if (!goStartAirnodeFeed.success) {
27-
logger.error('Could not start Airnode feed. Unexpected error occurred.', goStartAirnodeFeed.error);
28-
}
33+
setupUncaughtErrorHandler();
34+
35+
await startAirnodeFeed();
2936
};
3037

3138
void main();

packages/airnode-feed/src/validation/config.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { cwd } from 'node:process';
55
import { go } from '@api3/promise-utils';
66
import dotenv from 'dotenv';
77

8+
import { logger } from '../logger';
9+
810
import { configSchema } from './schema';
911
import { interpolateSecrets, parseSecrets } from './utils';
1012

@@ -24,6 +26,9 @@ export const loadConfig = async () => {
2426
return configSchema.parseAsync(interpolateSecrets(rawConfig, secrets));
2527
});
2628

27-
if (!goLoadConfig.success) throw new Error(`Unable to load configuration.`, { cause: goLoadConfig.error });
29+
if (!goLoadConfig.success) {
30+
logger.error(`Unable to load configuration.`, goLoadConfig.error);
31+
return null;
32+
}
2833
return goLoadConfig.data;
2934
};

packages/signed-api/src/config/config.test.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import type { AllowedAirnode } from '../schema';
77

88
import * as configModule from './config';
99

10-
test('interpolates example config and secrets', () => {
10+
test('interpolates example config and secrets', async () => {
1111
jest
12-
.spyOn(configModule, 'loadRawConfig')
12+
.spyOn(configModule, 'loadRawConfigFromFilesystem')
1313
.mockReturnValue(JSON.parse(readFileSync(join(__dirname, '../../config/signed-api.example.json'), 'utf8')));
1414
jest
15-
.spyOn(configModule, 'loadRawSecrets')
15+
.spyOn(configModule, 'loadRawSecretsFromFilesystem')
1616
.mockReturnValue(dotenv.parse(readFileSync(join(__dirname, '../../config/secrets.example.env'), 'utf8')));
1717

18-
const config = configModule.loadConfigFromFilesystem();
18+
const config = await configModule.loadConfig();
1919

20-
expect(config.endpoints[0]!.authTokens).toStrictEqual(['secret-endpoint-token']);
21-
expect((config.allowedAirnodes[0] as AllowedAirnode).authTokens).toStrictEqual(['secret-airnode-token']);
20+
expect(config!.endpoints[0]!.authTokens).toStrictEqual(['secret-endpoint-token']);
21+
expect((config!.allowedAirnodes[0] as AllowedAirnode).authTokens).toStrictEqual(['secret-airnode-token']);
2222
});

packages/signed-api/src/config/config.ts

+54-32
Original file line numberDiff line numberDiff line change
@@ -20,64 +20,86 @@ export const getConfig = (): Config => {
2020
return config;
2121
};
2222

23-
export const loadAndCacheConfig = async (): Promise<Config> => {
24-
const jsonConfig = await fetchConfig();
25-
config = configSchema.parse(jsonConfig);
26-
return config;
27-
};
28-
2923
// When Signed API is built, the "/dist" file contains "src" folder and "package.json" and the config is expected to be
3024
// located next to the "/dist" folder. When run in development, the config is expected to be located next to the "src"
3125
// folder (one less import level). We resolve the config by CWD as a workaround. Since the Signed API is dockerized,
3226
// this is hidden from the user.
3327
const getConfigPath = () => join(cwd(), './config');
3428

35-
export const loadRawConfig = () => JSON.parse(readFileSync(join(getConfigPath(), 'signed-api.json'), 'utf8'));
29+
export const loadRawConfigFromFilesystem = () =>
30+
JSON.parse(readFileSync(join(getConfigPath(), 'signed-api.json'), 'utf8'));
3631

37-
export const loadRawSecrets = () => dotenv.parse(readFileSync(join(getConfigPath(), 'secrets.env'), 'utf8'));
32+
export const loadRawSecretsFromFilesystem = () =>
33+
dotenv.parse(readFileSync(join(getConfigPath(), 'secrets.env'), 'utf8'));
3834

3935
export const loadConfigFromFilesystem = () => {
4036
const goLoadConfig = goSync(() => {
41-
const rawSecrets = loadRawSecrets();
42-
const rawConfig = loadRawConfig();
37+
const rawSecrets = loadRawSecretsFromFilesystem();
38+
const rawConfig = loadRawConfigFromFilesystem();
4339
const secrets = parseSecrets(rawSecrets);
44-
return configSchema.parse(interpolateSecrets(rawConfig, secrets));
40+
return interpolateSecrets(rawConfig, secrets);
4541
});
4642

47-
if (!goLoadConfig.success) throw new Error(`Unable to load configuration.`, { cause: goLoadConfig.error });
43+
if (!goLoadConfig.success) {
44+
logger.error(`Unable to load configuration.`, goLoadConfig.error);
45+
return null;
46+
}
4847
return goLoadConfig.data;
4948
};
5049

51-
const fetchConfig = async (): Promise<any> => {
50+
export const loadNonValidatedConfig = async () => {
5251
const env = loadEnv();
5352
const source = env.CONFIG_SOURCE;
5453
switch (source) {
5554
case 'local': {
5655
return loadConfigFromFilesystem();
5756
}
5857
case 'aws-s3': {
59-
return fetchConfigFromS3();
58+
return loadConfigFromS3();
6059
}
6160
}
6261
};
6362

64-
const fetchConfigFromS3 = async (): Promise<any> => {
65-
const env = loadEnv();
66-
const region = env.AWS_REGION!; // Validated by environment variables schema.
67-
const s3 = new S3({ region });
68-
69-
const params = {
70-
Bucket: env.AWS_S3_BUCKET_NAME,
71-
Key: env.AWS_S3_BUCKET_PATH,
72-
};
73-
74-
logger.info(`Fetching config from AWS S3 region.`, { region });
75-
const res = await go(async () => s3.getObject(params), { retries: 1 });
76-
if (!res.success) {
77-
logger.error('Error fetching config from AWS S3.', res.error);
78-
throw res.error;
63+
export const loadConfig = async () => {
64+
if (config) return config;
65+
66+
const nonValidatedConfig = await loadNonValidatedConfig();
67+
if (!nonValidatedConfig) return null;
68+
69+
const safeParsedConfig = configSchema.safeParse(nonValidatedConfig);
70+
if (!safeParsedConfig.success) {
71+
logger.error('Config failed validation.', safeParsedConfig.error);
72+
return null;
73+
}
74+
return (config = safeParsedConfig.data);
75+
};
76+
77+
const loadConfigFromS3 = async (): Promise<any> => {
78+
const goFetchConfig = await go(async () => {
79+
const env = loadEnv();
80+
const region = env.AWS_REGION!; // Validated by environment variables schema.
81+
const s3 = new S3({ region });
82+
83+
const params = {
84+
Bucket: env.AWS_S3_BUCKET_NAME,
85+
Key: env.AWS_S3_BUCKET_PATH,
86+
};
87+
88+
logger.info(`Fetching config from AWS S3 region.`, { region });
89+
const res = await go(async () => s3.getObject(params), { retries: 1 });
90+
if (!res.success) {
91+
logger.error('Error fetching config from AWS S3.', res.error);
92+
return null;
93+
}
94+
logger.info('Config fetched successfully from AWS S3.');
95+
const stringifiedConfig = await res.data.Body!.transformToString();
96+
return JSON.parse(stringifiedConfig);
97+
});
98+
99+
// Check whether the config returned a truthy response, because false response assumes an error has been handled.
100+
if (!goFetchConfig.success || !goFetchConfig.data) {
101+
logger.error('Unexpected error during fetching config from S3.', goFetchConfig.error);
102+
return null;
79103
}
80-
logger.info('Config fetched successfully from AWS S3.');
81-
const stringifiedConfig = await res.data.Body!.transformToString();
82-
return JSON.parse(stringifiedConfig);
104+
return goFetchConfig.data;
83105
};

packages/signed-api/src/index.ts

+20-11
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import { go } from '@api3/promise-utils';
22
import z from 'zod';
33

4-
import { loadAndCacheConfig } from './config/config';
4+
import { loadConfig } from './config/config';
55
import { logger } from './logger';
66
import { DEFAULT_PORT, startServer } from './server';
77
import { initializeVerifierPool } from './signed-data-verifier-pool';
88

9+
const setupUncaughtErrorHandler = () => {
10+
// NOTE: From the Node.js docs:
11+
//
12+
// Installing an 'uncaughtExceptionMonitor' listener does not change the behavior once an 'uncaughtException' event is
13+
// emitted. The process will still crash if no 'uncaughtException' listener is installed.
14+
process.on('uncaughtExceptionMonitor', (error, origin) => {
15+
logger.error('Uncaught exception.', error, { origin });
16+
});
17+
18+
// We want to exit the process immediately to avoid Node.js to log the uncaught error to stderr.
19+
process.on('uncaughtException', () => process.exit(1));
20+
process.on('unhandledRejection', () => process.exit(1));
21+
};
22+
923
const portSchema = z.coerce.number().int().positive();
1024

1125
// Start the Signed API. All application errors should be handled by this function (or its callees) and any error from
1226
// this function is considered unexpected.
1327
const startSignedApi = async () => {
14-
const goConfig = await go(loadAndCacheConfig);
15-
if (!goConfig.success) {
16-
logger.error('Failed to load the configuration.', goConfig.error);
17-
return;
18-
}
19-
const config = goConfig.data;
28+
const config = await loadConfig();
29+
if (!config) return;
2030
logger.info('Using configuration.', config);
2131

2232
const goPool = await go(() => initializeVerifierPool());
@@ -45,10 +55,9 @@ const startSignedApi = async () => {
4555
};
4656

4757
const main = async () => {
48-
const goStartSignedApi = await go(startSignedApi);
49-
if (!goStartSignedApi.success) {
50-
logger.error('Could not start Signed API. Unexpected error occurred.', goStartSignedApi.error);
51-
}
58+
setupUncaughtErrorHandler();
59+
60+
await startSignedApi();
5261
};
5362

5463
void main();

packages/signed-api/src/server.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,14 @@ export const startServer = (config: Config, port: number) => {
6565
// NOTE: The error handling middleware only catches synchronous errors. Request handlers logic should be wrapped in
6666
// try-catch and manually passed to next() in case of errors.
6767
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
68-
logger.error('An unexpected error occurred.', { err });
68+
// For unhandled errors it's very beneficial to have the stack trace. It is possible that the value is not an error.
69+
// It would be nice to know the stack trace in such cases as well.
70+
const stack = err.stack ?? new Error('Unexpected non-error value encountered').stack;
71+
logger.error('An unexpected handler error occurred.', { err, stack });
6972

7073
res.status(err.status || 500).json({
7174
error: {
72-
message: err.message || 'An unexpected error occurred.',
75+
message: err.message || 'An unexpected handler error occurred.',
7376
},
7477
});
7578
});

0 commit comments

Comments
 (0)