Skip to content

Commit 274f610

Browse files
authored
Handle errors gracefully (#387)
1 parent 9dda393 commit 274f610

8 files changed

+174
-15
lines changed

lib/plugins/healthcheck/commonHealthcheckPlugin.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const positiveHealthcheckChecker: HealthChecker = () => {
1414
const negativeHealthcheckChecker: HealthChecker = () => {
1515
return Promise.resolve({ error: new Error('Something exploded') })
1616
}
17+
const throwingHealthcheckChecker: HealthChecker = () => {
18+
throw new Error('Connection refused')
19+
}
1720

1821
async function initApp(opts: CommonHealthcheckPluginOptions) {
1922
const app = fastify()
@@ -317,5 +320,34 @@ describe('commonHealthcheckPlugin', () => {
317320
],
318321
})
319322
})
323+
324+
it('handles checker that throws an error as a failed healthcheck', async () => {
325+
app = await initApp({
326+
responsePayload: { version: 1 },
327+
healthChecks: [
328+
{
329+
name: 'check1',
330+
isMandatory: false,
331+
checker: throwingHealthcheckChecker,
332+
},
333+
{
334+
name: 'check2',
335+
isMandatory: true,
336+
checker: positiveHealthcheckChecker,
337+
},
338+
],
339+
})
340+
341+
const response = await app.inject().get(PRIVATE_ENDPOINT).end()
342+
expect(response.statusCode).toBe(200)
343+
expect(response.json()).toEqual({
344+
heartbeat: 'PARTIALLY_HEALTHY',
345+
version: 1,
346+
checks: {
347+
check1: 'FAIL',
348+
check2: 'HEALTHY',
349+
},
350+
})
351+
})
320352
})
321353
})

lib/plugins/healthcheck/commonHealthcheckPlugin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Either } from '@lokalise/node-core'
1+
import { type Either, isError } from '@lokalise/node-core'
22
import type { FastifyPluginCallback } from 'fastify'
33
import fp from 'fastify-plugin'
44
import type { AnyFastifyInstance } from '../pluginsCommon.js'
@@ -97,7 +97,12 @@ function addRoute(
9797
if (opts.healthChecks.length) {
9898
const results = await Promise.all(
9999
opts.healthChecks.map(async (healthcheck) => {
100-
const result = await healthcheck.checker(app)
100+
let result: Either<Error, true>
101+
try {
102+
result = await healthcheck.checker(app)
103+
} catch (err) {
104+
result = { error: isError(err) ? err : new Error(String(err)) }
105+
}
101106
if (result.error) {
102107
app.log.error(result.error, `${healthcheck.name} healthcheck has failed`)
103108
}

lib/plugins/healthcheck/commonSyncHealthcheckPlugin.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const positiveHealthcheckChecker: HealthCheckerSync = () => {
1414
const negativeHealthcheckChecker: HealthCheckerSync = () => {
1515
return new Error('Something exploded')
1616
}
17+
const throwingHealthcheckChecker: HealthCheckerSync = () => {
18+
throw new Error('Connection refused')
19+
}
1720

1821
async function initApp(opts: CommonSyncHealthcheckPluginOptions) {
1922
const app = fastify()
@@ -317,5 +320,34 @@ describe('commonSyncHealthcheckPlugin', () => {
317320
],
318321
})
319322
})
323+
324+
it('handles checker that throws an error as a failed healthcheck', async () => {
325+
app = await initApp({
326+
responsePayload: { version: 1 },
327+
healthChecks: [
328+
{
329+
name: 'check1',
330+
isMandatory: false,
331+
checker: throwingHealthcheckChecker,
332+
},
333+
{
334+
name: 'check2',
335+
isMandatory: true,
336+
checker: positiveHealthcheckChecker,
337+
},
338+
],
339+
})
340+
341+
const response = await app.inject().get(PRIVATE_ENDPOINT).end()
342+
expect(response.statusCode).toBe(200)
343+
expect(response.json()).toEqual({
344+
heartbeat: 'PARTIALLY_HEALTHY',
345+
version: 1,
346+
checks: {
347+
check1: 'FAIL',
348+
check2: 'HEALTHY',
349+
},
350+
})
351+
})
320352
})
321353
})

lib/plugins/healthcheck/commonSyncHealthcheckPlugin.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isError } from '@lokalise/node-core'
12
import type { FastifyPluginCallback } from 'fastify'
23
import fp from 'fastify-plugin'
34
import type { AnyFastifyInstance } from '../pluginsCommon.js'
@@ -95,7 +96,12 @@ function addRoute(
9596

9697
if (opts.healthChecks.length) {
9798
const results = opts.healthChecks.map((healthcheck) => {
98-
const healthcheckError = healthcheck.checker(app)
99+
let healthcheckError: Error | null
100+
try {
101+
healthcheckError = healthcheck.checker(app)
102+
} catch (err) {
103+
healthcheckError = isError(err) ? err : new Error(String(err))
104+
}
99105
if (healthcheckError) {
100106
app.log.error(healthcheckError, `${healthcheck.name} healthcheck has failed`)
101107
}

lib/plugins/healthcheck/publicHealthcheckPlugin.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const positiveHealthcheckChecker: HealthChecker = () => {
1111
const negativeHealthcheckChecker: HealthChecker = () => {
1212
return Promise.resolve({ error: new Error('Something exploded') })
1313
}
14+
const throwingHealthcheckChecker: HealthChecker = () => {
15+
throw new Error('Connection refused')
16+
}
1417

1518
async function initApp(opts: PublicHealthcheckPluginOptions) {
1619
const app = fastify()
@@ -180,4 +183,33 @@ describe('publicHealthcheckPlugin', () => {
180183
],
181184
})
182185
})
186+
187+
it('handles checker that throws an error as a failed healthcheck', async () => {
188+
app = await initApp({
189+
responsePayload: { version: 1 },
190+
healthChecks: [
191+
{
192+
name: 'check1',
193+
isMandatory: false,
194+
checker: throwingHealthcheckChecker,
195+
},
196+
{
197+
name: 'check2',
198+
isMandatory: true,
199+
checker: positiveHealthcheckChecker,
200+
},
201+
],
202+
})
203+
204+
const response = await app.inject().get('/health').end()
205+
expect(response.statusCode).toBe(200)
206+
expect(response.json()).toEqual({
207+
heartbeat: 'PARTIALLY_HEALTHY',
208+
version: 1,
209+
checks: {
210+
check1: 'FAIL',
211+
check2: 'HEALTHY',
212+
},
213+
})
214+
})
183215
})

lib/plugins/healthcheck/publicHealthcheckPlugin.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { type Either, isError } from '@lokalise/node-core'
12
import type { FastifyPluginCallback } from 'fastify'
23
import fp from 'fastify-plugin'
34
import type { AnyFastifyInstance } from '../pluginsCommon.js'
@@ -45,17 +46,21 @@ function plugin(
4546

4647
if (opts.healthChecks.length) {
4748
const results = await Promise.all(
48-
opts.healthChecks.map((healthcheck) => {
49-
return healthcheck.checker(app).then((result) => {
50-
if (result.error) {
51-
app.log.error(result.error, `${healthcheck.name} healthcheck has failed`)
52-
}
53-
return {
54-
name: healthcheck.name,
55-
result,
56-
isMandatory: healthcheck.isMandatory,
57-
}
58-
})
49+
opts.healthChecks.map(async (healthcheck) => {
50+
let result: Either<Error, true>
51+
try {
52+
result = await healthcheck.checker(app)
53+
} catch (err) {
54+
result = { error: isError(err) ? err : new Error(String(err)) }
55+
}
56+
if (result.error) {
57+
app.log.error(result.error, `${healthcheck.name} healthcheck has failed`)
58+
}
59+
return {
60+
name: healthcheck.name,
61+
result,
62+
isMandatory: healthcheck.isMandatory,
63+
}
5964
}),
6065
)
6166

lib/plugins/healthcheck/startupHealthcheckPlugin.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const positiveHealthcheckChecker: HealthChecker = () => {
1414
const negativeHealthcheckChecker: HealthChecker = () => {
1515
return Promise.resolve({ error: new Error('Something exploded') })
1616
}
17+
const throwingHealthcheckChecker: HealthChecker = () => {
18+
throw new Error('Connection refused')
19+
}
1720

1821
async function initApp(opts: StartupHealthcheckPluginOptions) {
1922
const app = fastify()
@@ -85,5 +88,36 @@ describe('startupHealthcheckPlugin', () => {
8588
],
8689
})
8790
})
91+
92+
it('handles checker that throws as a failed healthcheck', async () => {
93+
await expect(
94+
initApp({
95+
healthChecks: [
96+
{
97+
name: 'check1',
98+
isMandatory: true,
99+
checker: throwingHealthcheckChecker,
100+
},
101+
],
102+
}),
103+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Healthchecks failed: ["check1"]]`)
104+
})
105+
106+
it('app starts if throwing checker is optional', async () => {
107+
app = await initApp({
108+
healthChecks: [
109+
{
110+
name: 'check1',
111+
isMandatory: false,
112+
checker: throwingHealthcheckChecker,
113+
},
114+
{
115+
name: 'check2',
116+
isMandatory: true,
117+
checker: positiveHealthcheckChecker,
118+
},
119+
],
120+
})
121+
})
88122
})
89123
})

lib/plugins/healthcheck/startupHealthcheckPlugin.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
import { type Either, isError } from '@lokalise/node-core'
12
import type { FastifyPluginCallback } from 'fastify'
23
import fp from 'fastify-plugin'
34
import { stdSerializers } from 'pino'
45
import { type HealthCheck, resolveHealthcheckResults } from './commonHealthcheckPlugin.js'
6+
import type { HealthChecker } from './healthcheckCommons.js'
7+
8+
async function executeHealthCheck(
9+
checker: HealthChecker,
10+
app: Parameters<HealthChecker>[0],
11+
): Promise<Either<Error, true>> {
12+
try {
13+
return await checker(app)
14+
} catch (err) {
15+
return { error: isError(err) ? err : new Error(String(err)) }
16+
}
17+
}
518

619
export interface StartupHealthcheckPluginOptions {
720
resultsLogLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'
@@ -18,7 +31,7 @@ const plugin: FastifyPluginCallback<StartupHealthcheckPluginOptions> = (app, opt
1831
if (opts.healthChecks.length) {
1932
const results = await Promise.all(
2033
opts.healthChecks.map(async (healthcheck) => {
21-
const result = await healthcheck.checker(app)
34+
const result = await executeHealthCheck(healthcheck.checker, app)
2235
if (result.error) {
2336
app.log.error(
2437
{

0 commit comments

Comments
 (0)