Skip to content

Commit 02d2aec

Browse files
authoredSep 29, 2022
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

+5
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

-26
This file was deleted.
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
})
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

+1
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

+10-47
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
}
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

+1-9
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 &&
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Callout } from '@theguild/components'
2+
3+
# Health check
4+
5+
Yoga is aware of the usefulness of a health check and gives the user maximum possibilities to use the built-in check and create a "readiness" check through a simple plugin.
6+
7+
## Types of health checks
8+
9+
There are two types of health checks: **liveliness** and **readiness**, they both _are_ a health check but convey a different meaning:
10+
11+
- **Liveliness** checks whether the service is alive and running
12+
- **Readiness** checks whether the service is ready to perform work
13+
14+
The difference is that a service can be _live_ but not _ready_ - for example, server has started and is accepting requests (alive), but the read replica it uses is still unavailable (not ready).
15+
16+
A liveliness check is something Yoga can offer out-of-the-box because Yoga is a server and Yoga knows when it's alive. However, a readiness check is something Yoga cannot figure out on its own, it is a check requiring user-land context.
17+
18+
Having said this, Yoga has a `/health` route which is used for **liveliness** check and offers a `useReadinessCheck` plugin allowing you to implement your own **readiness** check.
19+
20+
### Liveliness
21+
22+
By default, you can check whether Yoga is alive by issuing a request to the `/health` endpoint and expecting the response `200 OK`.
23+
24+
Of course, you can change this endpoint through the `healthCheckEndpoint` option.
25+
26+
```ts
27+
import { createYoga } from 'graphql-yoga'
28+
import { createServer } from 'node:http'
29+
import { schema } from './my-service'
30+
31+
const yoga = createYoga({
32+
schema,
33+
healthCheckEndpoint: '/live',
34+
})
35+
36+
const server = createServer(yoga)
37+
server.listen(4000, () => {
38+
console.info('Server is running on http://localhost:4000/graphql')
39+
})
40+
```
41+
42+
A successful response is just `200 OK` without a body.
43+
44+
### Readiness
45+
46+
Additionally, you might want an endpoint which checks whether the services powering Yoga are ready to perform work.
47+
48+
Since this check requires more context that Yoga cannot assume, you're recommended to use the core `useReadinessPlugin` and implement your own check.
49+
50+
```ts
51+
import { createYoga, useReadinessCheck } from 'graphql-yoga'
52+
import { createServer } from 'node:http'
53+
import { schema, checkDbAvailable } from './my-service'
54+
55+
const yoga = createYoga({
56+
schema,
57+
plugins: [
58+
useReadinessCheck({
59+
endpoint: '/ready', // default
60+
check: async () => {
61+
// if resolves, respond with 200 OK
62+
// if throw, respond with 504 Service Unavailable and error message as plaintext in body
63+
await checkDbAvailable()
64+
},
65+
}),
66+
],
67+
})
68+
69+
const server = createServer(yoga)
70+
server.listen(4000, () => {
71+
console.info('Server is running on http://localhost:4000/graphql')
72+
})
73+
```
74+
75+
Throwing an instance of `Error` in the `check` function will have Yoga respond with `504 Service Unavailable` with the error's message in the body.
76+
77+
<Callout>
78+
Please make sure that thrown errors are not leaking sensitive information in
79+
production environments.
80+
</Callout>
81+
82+
Alternatively, you can simply return `false` from the `check` function to have Yoga respond with just `504 Service Unavailable` and no body. This way you can make sure nothing sensitive leaks ever.
83+
84+
```ts
85+
import { createYoga, useReadinessCheck } from 'graphql-yoga'
86+
import { createServer } from 'node:http'
87+
import { schema, checkDbAvailable } from './my-service'
88+
89+
const yoga = createYoga({
90+
schema,
91+
plugins: [
92+
useReadinessCheck({
93+
endpoint: '/ready', // default
94+
check: async () => {
95+
try {
96+
await checkDbAvailable()
97+
// if true, respond with 200 OK
98+
return false
99+
} catch (err) {
100+
// log the error on the server for debugging purposes
101+
console.error(err)
102+
// if false, respond with 504 Service Unavailable and no bdy
103+
return false
104+
}
105+
},
106+
}),
107+
],
108+
})
109+
110+
const server = createServer(yoga)
111+
server.listen(4000, () => {
112+
console.info('Server is running on http://localhost:4000/graphql')
113+
})
114+
```

‎website/src/pages/v3/migration/migration-from-yoga-v2.mdx

+24
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,27 @@ new GraphQLError('My error message', {
156156
},
157157
})
158158
```
159+
160+
**No more `readinessCheckEndpoint`, consider using the `useReadinessCheck` plugin instead**
161+
162+
A readiness health check is not something Yoga can accurately perform as it requires user-land context. Read more about the [new health check](/v3/features/health-check).
163+
164+
```diff
165+
- import { createServer } from '@graphql-yoga/node'
166+
+ import { createYoga, useReadinessCheck } from 'graphql-yoga'
167+
import { schema, checkDbAvailable } from './my-service';
168+
169+
- const yoga = createServer({
170+
+ const yoga = createYoga({
171+
schema,
172+
- readinessCheckEndpoint: '/ready',
173+
+ useReadinessCheck({
174+
+ endpoint: '/ready' // default,
175+
+ check: async () => {
176+
+ // if resolves, respond with 200 OK
177+
+ // if throw, respond with 504 Service Unavailable and error message as plaintext in body
178+
+ await checkDbAvailable()
179+
+ },
180+
+ })
181+
})
182+
```

0 commit comments

Comments
 (0)
Please sign in to comment.