Skip to content

Commit 02d2aec

Browse files
authored
Health check for liveliness, useReadinessCheck plugin for readiness (#1808)
1 parent 8863ac0 commit 02d2aec

File tree

10 files changed

+346
-89
lines changed

10 files changed

+346
-89
lines changed

.changeset/rotten-ghosts-design.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphql-yoga': major
3+
---
4+
5+
Drop `readinessCheckEndpoint` and introduce `useReadinessCheck` plugin

packages/graphql-yoga/__integration-tests__/readiness-checks.spec.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { createYoga } from 'graphql-yoga'
22

3-
describe('health checks', () => {
3+
describe('health check', () => {
44
it('return 200 status code for health check endpoint', async () => {
55
const yoga = createYoga({
66
logging: false,
77
})
8-
const result = await yoga.fetch('http://yoga/health', {
9-
method: 'GET',
10-
})
8+
const result = await yoga.fetch('http://yoga/health')
119
expect(result.status).toBe(200)
12-
expect(await result.json()).toEqual({
13-
message: 'alive',
14-
})
1510
})
1611
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { createYoga, createSchema } from 'graphql-yoga'
2+
import { useReadinessCheck } from '../src/plugins/useReadinessCheck'
3+
4+
describe('Readiness Check', () => {
5+
const schema = createSchema({
6+
typeDefs: /* GraphQL */ `
7+
type Query {
8+
hello: String!
9+
}
10+
`,
11+
resolvers: {
12+
Query: {
13+
async hello() {
14+
return 'world'
15+
},
16+
},
17+
},
18+
})
19+
20+
it('should respond with 200 if check returns nothing', async () => {
21+
const yoga = createYoga({
22+
schema,
23+
plugins: [
24+
useReadinessCheck({
25+
check: async () => {
26+
// noop
27+
},
28+
}),
29+
],
30+
})
31+
32+
const response = await yoga.fetch('http://yoga/ready')
33+
expect(response.status).toBe(200)
34+
})
35+
36+
it('should respond with 200 if check returns true', async () => {
37+
const yoga = createYoga({
38+
schema,
39+
plugins: [
40+
useReadinessCheck({
41+
check: async () => {
42+
return true
43+
},
44+
}),
45+
],
46+
})
47+
48+
const response = await yoga.fetch('http://yoga/ready')
49+
expect(response.status).toBe(200)
50+
})
51+
52+
it('should respond with 503 if check returns false', async () => {
53+
const yoga = createYoga({
54+
schema,
55+
plugins: [
56+
useReadinessCheck({
57+
check: async () => {
58+
return false
59+
},
60+
}),
61+
],
62+
})
63+
64+
const response = await yoga.fetch('http://yoga/ready')
65+
expect(response.status).toBe(503)
66+
})
67+
68+
it('should respond with 503 and the error message if check throws an error', async () => {
69+
const message = 'Not good, not bad.'
70+
71+
const yoga = createYoga({
72+
schema,
73+
plugins: [
74+
useReadinessCheck({
75+
check: async () => {
76+
throw new Error(message)
77+
},
78+
}),
79+
],
80+
})
81+
82+
const response = await yoga.fetch('http://yoga/ready')
83+
expect(response.status).toBe(503)
84+
expect(response.headers.get('content-type')).toBe(
85+
'text/plain; charset=utf-8',
86+
)
87+
expect(await response.text()).toBe(message)
88+
})
89+
90+
it('should respond with 503 and empty body if check throws not an error', async () => {
91+
const yoga = createYoga({
92+
schema,
93+
plugins: [
94+
useReadinessCheck({
95+
check: async () => {
96+
throw 1
97+
},
98+
}),
99+
],
100+
})
101+
102+
const response = await yoga.fetch('http://yoga/ready')
103+
expect(response.status).toBe(503)
104+
expect(response.headers.get('content-type')).toBeNull()
105+
expect(await response.text()).toBe('')
106+
})
107+
108+
it('should respond with the response from check', async () => {
109+
const message = 'I am a-ok!'
110+
111+
const yoga = createYoga({
112+
schema,
113+
plugins: [
114+
useReadinessCheck({
115+
check: async ({ fetchAPI }) => {
116+
return new fetchAPI.Response(message, { status: 201 })
117+
},
118+
}),
119+
],
120+
})
121+
122+
const response = await yoga.fetch('http://yoga/ready')
123+
expect(response.status).toBe(201)
124+
expect(await response.text()).toBe(message)
125+
})
126+
})

packages/graphql-yoga/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export type { GraphiQLOptions } from './plugins/useGraphiQL.js'
88
export type { Plugin } from './plugins/types.js'
99
export { shouldRenderGraphiQL, renderGraphiQL } from './plugins/useGraphiQL.js'
1010
export { useSchema } from './plugins/useSchema.js'
11+
export { useReadinessCheck } from './plugins/useReadinessCheck.js'
1112
export * from './schema.js'
1213
export * from './subscription.js'

packages/graphql-yoga/src/plugins/useHealthCheck.ts

Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,29 @@
1-
import { createGraphQLError } from '@graphql-tools/utils'
21
import { YogaLogger } from '../logger.js'
32
import { Plugin } from './types.js'
43

54
export interface HealthCheckPluginOptions {
65
id?: string
76
logger?: YogaLogger
8-
healthCheckEndpoint?: string
9-
readinessCheckEndpoint?: string
7+
endpoint?: string
108
}
119

1210
export function useHealthCheck({
1311
id = Date.now().toString(),
1412
logger = console,
15-
healthCheckEndpoint = '/health',
16-
readinessCheckEndpoint = '/readiness',
13+
endpoint = '/health',
1714
}: HealthCheckPluginOptions = {}): Plugin {
1815
return {
19-
async onRequest({ request, endResponse, fetchAPI, url }) {
16+
async onRequest({ endResponse, fetchAPI, url }) {
2017
const { pathname: requestPath } = url
21-
if (requestPath === healthCheckEndpoint) {
22-
logger.debug(`Responding Health Check`)
23-
const response = new fetchAPI.Response(
24-
JSON.stringify({
25-
message: 'alive',
26-
}),
27-
{
28-
status: 200,
29-
headers: {
30-
'Content-Type': 'application/json',
31-
'x-yoga-id': id,
32-
},
18+
if (requestPath === endpoint) {
19+
logger.debug('Responding Health Check')
20+
const response = new fetchAPI.Response(null, {
21+
status: 200,
22+
headers: {
23+
'x-yoga-id': id,
3324
},
34-
)
25+
})
3526
endResponse(response)
36-
} else if (requestPath === readinessCheckEndpoint) {
37-
logger.debug(`Responding Readiness Check`)
38-
const readinessResponse = await fetchAPI.fetch(
39-
request.url.replace(readinessCheckEndpoint, healthCheckEndpoint),
40-
)
41-
const { message } = await readinessResponse.json()
42-
if (
43-
readinessResponse.status === 200 &&
44-
readinessResponse.headers.get('x-yoga-id') === id &&
45-
message === 'alive'
46-
) {
47-
const response = new fetchAPI.Response(
48-
JSON.stringify({
49-
message: 'ready',
50-
}),
51-
{
52-
status: 200,
53-
headers: {
54-
'Content-Type': 'application/json',
55-
},
56-
},
57-
)
58-
endResponse(response)
59-
} else {
60-
throw createGraphQLError(
61-
`Readiness check failed with status ${readinessResponse.status}`,
62-
)
63-
}
6427
}
6528
},
6629
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Plugin, FetchAPI } from 'graphql-yoga'
2+
3+
export interface ReadinessCheckPluginOptions {
4+
/**
5+
* Under which endpoint do you want the readiness check to be?
6+
*
7+
* @default /ready
8+
*/
9+
endpoint?: string
10+
/**
11+
* The check for whether the service is ready to perform.
12+
*
13+
* You should check here whether the services Yoga depends on
14+
* are ready and working, for example: is the database up and running?
15+
*
16+
* - Returning `true` or nothing will respond with a 200 OK.
17+
* - Returning `false` or throwing an error will respond with a 503 Service Unavailable.
18+
* - Returning a `Response` will have the readiness check respond with it.
19+
*
20+
* Beware that if an instance of `Error` is thrown, its message will be present in the
21+
* response body. Be careful which information you expose.
22+
*/
23+
check: (payload: {
24+
request: Request
25+
fetchAPI: FetchAPI
26+
}) => void | boolean | Response | Promise<void | boolean | Response>
27+
}
28+
29+
/**
30+
* Adds a readiness check for Yoga by simply implementing the `check` option.
31+
*/
32+
export function useReadinessCheck({
33+
endpoint = '/ready',
34+
check,
35+
}: ReadinessCheckPluginOptions): Plugin {
36+
return {
37+
async onRequest({ request, endResponse, fetchAPI, url }) {
38+
const { pathname: requestPath } = url
39+
if (requestPath === endpoint) {
40+
let response: Response
41+
try {
42+
const readyOrResponse = await check({ request, fetchAPI })
43+
if (typeof readyOrResponse === 'object') {
44+
response = readyOrResponse
45+
} else {
46+
response = new fetchAPI.Response(null, {
47+
status: readyOrResponse === false ? 503 : 200,
48+
})
49+
}
50+
} catch (err) {
51+
const isError = err instanceof Error
52+
response = new fetchAPI.Response(isError ? err.message : null, {
53+
status: 503,
54+
headers: isError
55+
? { 'content-type': 'text/plain; charset=utf-8' }
56+
: {},
57+
})
58+
}
59+
endResponse(response)
60+
}
61+
},
62+
}
63+
}

packages/graphql-yoga/src/server.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,6 @@ export type YogaServerOptions<
117117
*/
118118
graphqlEndpoint?: string
119119

120-
/**
121-
* Readiness check endpoint
122-
*
123-
* @default "/readiness"
124-
*/
125-
readinessCheckEndpoint?: string
126-
127120
/**
128121
* Readiness check endpoint
129122
*
@@ -331,8 +324,7 @@ export class YogaServer<
331324
useHealthCheck({
332325
id: this.id,
333326
logger: this.logger,
334-
healthCheckEndpoint: options?.healthCheckEndpoint,
335-
readinessCheckEndpoint: options?.readinessCheckEndpoint,
327+
endpoint: options?.healthCheckEndpoint,
336328
}),
337329
options?.cors !== false && useCORS(options?.cors),
338330
options?.graphiql !== false &&

0 commit comments

Comments
 (0)