├── .borp.yaml
├── .gitattributes
├── .github
├── dependabot.yml
├── stale.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── eslint.config.js
├── index.js
├── package.json
├── test
├── exports.test.js
├── forkRequest.js
├── index.test.js
├── issues
│ └── 216.test.js
├── pressurehandler.test.js
├── request.js
└── statusRoute.test.js
└── types
├── index.d.ts
└── index.test-d.ts
/.borp.yaml:
--------------------------------------------------------------------------------
1 | files:
2 | - 'test/**/*.test.js'
3 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Set default behavior to automatically convert line endings
2 | * text=auto eol=lf
3 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "monthly"
7 | open-pull-requests-limit: 10
8 |
9 | - package-ecosystem: "npm"
10 | directory: "/"
11 | schedule:
12 | interval: "monthly"
13 | open-pull-requests-limit: 10
14 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 15
3 | # Number of days of inactivity before a stale issue is closed
4 | daysUntilClose: 7
5 | # Issues with these labels will never be considered stale
6 | exemptLabels:
7 | - "discussion"
8 | - "feature request"
9 | - "bug"
10 | - "help wanted"
11 | - "plugin suggestion"
12 | - "good first issue"
13 | # Label to use when marking an issue as stale
14 | staleLabel: stale
15 | # Comment to post when marking an issue as stale. Set to `false` to disable
16 | markComment: >
17 | This issue has been automatically marked as stale because it has not had
18 | recent activity. It will be closed if no further activity occurs. Thank you
19 | for your contributions.
20 | # Comment to post when closing a stale issue. Set to `false` to disable
21 | closeComment: false
22 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - next
8 | - 'v*'
9 | paths-ignore:
10 | - 'docs/**'
11 | - '*.md'
12 | pull_request:
13 | paths-ignore:
14 | - 'docs/**'
15 | - '*.md'
16 |
17 | permissions:
18 | contents: read
19 |
20 | jobs:
21 | test:
22 | permissions:
23 | contents: write
24 | pull-requests: write
25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5
26 | with:
27 | license-check: true
28 | lint: true
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | # Vim swap files
133 | *.swp
134 |
135 | # macOS files
136 | .DS_Store
137 |
138 | # Clinic
139 | .clinic
140 |
141 | # lock files
142 | bun.lockb
143 | package-lock.json
144 | pnpm-lock.yaml
145 | yarn.lock
146 |
147 | # editor files
148 | .vscode
149 | .idea
150 |
151 | #tap files
152 | .tap/
153 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Fastify
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @fastify/under-pressure
2 |
3 | [](https://github.com/fastify/under-pressure/actions/workflows/ci.yml)
4 | [](https://www.npmjs.com/package/@fastify/under-pressure)
5 | [](https://github.com/neostandard/neostandard)
6 |
7 | Process load measuring plugin for Fastify, with automatic handling of *"Service Unavailable"*.
8 | It can check `maxEventLoopDelay`, `maxHeapUsedBytes`, `maxRssBytes`, and `maxEventLoopUtilization` values.
9 | You can also specify a custom health check, to verify the status of
10 | external resources.
11 |
12 |
13 | ## Install
14 | ```
15 | npm i @fastify/under-pressure
16 | ```
17 |
18 | ### Compatibility
19 | | Plugin version | Fastify version |
20 | | ---------------|-----------------|
21 | | `>=9.x` | `^5.x` |
22 | | `>=6.x <9.x` | `^4.x` |
23 | | `^5.x` | `^3.x` |
24 | | `>=2.x <5.x` | `^2.x` |
25 | | `^1.x` | `^1.x` |
26 |
27 |
28 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin
29 | in the table above.
30 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details.
31 |
32 |
33 | ## Usage
34 | Require the plugin and register it into the Fastify instance.
35 |
36 | ```js
37 | const fastify = require('fastify')()
38 |
39 | fastify.register(require('@fastify/under-pressure'), {
40 | maxEventLoopDelay: 1000,
41 | maxHeapUsedBytes: 100000000,
42 | maxRssBytes: 100000000,
43 | maxEventLoopUtilization:0.98
44 | })
45 |
46 | fastify.get('/', (request, reply) => {
47 | if (fastify.isUnderPressure()) {
48 | // skip complex computation
49 | }
50 | reply.send({ hello: 'world'})
51 | })
52 |
53 | fastify.listen({ port: 3000 }, err => {
54 | if (err) throw err
55 | console.log(`server listening on ${fastify.server.address().port}`)
56 | })
57 | ```
58 | `@fastify/under-pressure` will automatically handle for you the `Service Unavailable` error once one of the thresholds has been reached.
59 | You can configure the error message and the `Retry-After` header.
60 | ```js
61 | fastify.register(require('@fastify/under-pressure'), {
62 | maxEventLoopDelay: 1000,
63 | message: 'Under pressure!',
64 | retryAfter: 50
65 | })
66 | ```
67 |
68 | You can also configure custom Error instance `@fastify/under-pressure` will throw.
69 | ```js
70 | class CustomError extends Error {
71 | constructor () {
72 | super('Custom error message')
73 | Error.captureStackTrace(this, CustomError)
74 | }
75 | }
76 |
77 | fastify.register(require('@fastify/under-pressure'), {
78 | maxEventLoopDelay: 1000,
79 | customError: CustomError
80 | })
81 | ```
82 |
83 | The default value for `maxEventLoopDelay`, `maxHeapUsedBytes`, `maxRssBytes`, and `maxEventLoopUtilization` is `0`.
84 | If the value is `0` the check will not be performed.
85 |
86 | Thanks to the encapsulation model of Fastify, you can selectively use this plugin in a subset of routes or even with different thresholds in different plugins.
87 |
88 | #### `memoryUsage`
89 | This plugin also exposes a function that will tell you the current values of `heapUsed`, `rssBytes`, `eventLoopDelay`, and `eventLoopUtilized`.
90 | ```js
91 | console.log(fastify.memoryUsage())
92 | ```
93 |
94 | #### Pressure Handler
95 |
96 | You can provide a pressure handler in the options to handle the pressure errors. The advantage is that you know why the error occurred. Moreover, the request can be handled as if nothing happened.
97 |
98 | ```js
99 | const fastify = require('fastify')()
100 | const underPressure = require('@fastify/under-pressure')()
101 |
102 | fastify.register(underPressure, {
103 | maxHeapUsedBytes: 100000000,
104 | maxRssBytes: 100000000,
105 | pressureHandler: (request, reply, type, value) => {
106 | if (type === underPressure.TYPE_HEAP_USED_BYTES) {
107 | fastify.log.warn(`too many heap bytes used: ${value}`)
108 | } else if (type === underPressure.TYPE_RSS_BYTES) {
109 | fastify.log.warn(`too many rss bytes used: ${value}`)
110 | }
111 |
112 | reply.send('out of memory') // if you omit this line, the request will be handled normally
113 | }
114 | })
115 | ```
116 |
117 | It is possible as well to return a Promise that will call `reply.send` (or something else).
118 |
119 | ```js
120 | fastify.register(underPressure, {
121 | maxHeapUsedBytes: 100000000,
122 | pressureHandler: (request, reply, type, value) => {
123 | return getPromise().then(() => reply.send({ hello: 'world' }))
124 | }
125 | })
126 | ```
127 |
128 | Any other return value than a promise or nullish will be sent to the client with `reply.send`.
129 |
130 | It's also possible to specify the `pressureHandler` on the route:
131 |
132 | ```js
133 | const fastify = require('fastify')()
134 | const underPressure = require('@fastify/under-pressure')()
135 |
136 | fastify.register(underPressure, {
137 | maxHeapUsedBytes: 100000000,
138 | maxRssBytes: 100000000,
139 | })
140 |
141 | fastify.register(async function (fastify) {
142 | fastify.get('/', {
143 | config: {
144 | pressureHandler: (request, reply, type, value) => {
145 | if (type === underPressure.TYPE_HEAP_USED_BYTES) {
146 | fastify.log.warn(`too many heap bytes used: ${value}`)
147 | } else if (type === underPressure.TYPE_RSS_BYTES) {
148 | fastify.log.warn(`too many rss bytes used: ${value}`)
149 | }
150 |
151 | reply.send('out of memory') // if you omit this line, the request will be handled normally
152 | }
153 | }
154 | }, () => 'A')
155 | })
156 | ```
157 |
158 | #### Status route
159 | If needed you can pass `{ exposeStatusRoute: true }` and `@fastify/under-pressure` will expose a `/status` route for you that sends back a `{ status: 'ok' }` object. This can be useful if you need to attach the server to an ELB on AWS for example.
160 |
161 | If you need the change the exposed route path, you can pass `{ exposeStatusRoute: '/alive' }` options.
162 |
163 | To configure the endpoint more specifically you can pass an object. This consists of
164 |
165 | - *routeOpts* - Any Fastify [route options](https://fastify.dev/docs/latest/Reference/Routes/#routes-options) except `schema`
166 | - *routeSchemaOpts* - As per the Fastify route options, an object containing the schema for request
167 | - *routeResponseSchemaOpts* - An object containing the schema for additional response items to be merged with the default response schema, see below
168 | - *url* - The URL to expose the status route on
169 |
170 | ```js
171 | fastify.register(require('@fastify/under-pressure'), {
172 | maxEventLoopDelay: 1000,
173 | exposeStatusRoute: {
174 | routeOpts: {
175 | logLevel: 'debug',
176 | config: {
177 | someAttr: 'value'
178 | }
179 | },
180 | routeSchemaOpts: { // If you also want to set a custom route schema
181 | hide: true
182 | },
183 | url: '/alive' // If you also want to set a custom route path and pass options
184 | }
185 | })
186 | ```
187 | The above example will set the `logLevel` value for the `/alive` route to `debug`.
188 |
189 | If you need to return other information in the response, you can return an object from the `healthCheck` function (see next paragraph) and use the `routeResponseSchemaOpts` property to describe your custom response schema (**note**: `status` will always be present in the response)
190 |
191 | ```js
192 | fastify.register(underPressure, {
193 | ...
194 | exposeStatusRoute: {
195 | routeResponseSchemaOpts: {
196 | extraValue: { type: 'string' },
197 | metrics: {
198 | type: 'object',
199 | properties: {
200 | eventLoopDelay: { type: 'number' },
201 | rssBytes: { type: 'number' },
202 | heapUsed: { type: 'number' },
203 | eventLoopUtilized: { type: 'number' },
204 | },
205 | },
206 | // ...
207 | }
208 | },
209 | healthCheck: async (fastifyInstance) => {
210 | return {
211 | extraValue: await getExtraValue(),
212 | metrics: fastifyInstance.memoryUsage(),
213 | // ...
214 | }
215 | },
216 | }
217 | ```
218 |
219 | #### Custom health checks
220 | If needed you can pass a custom `healthCheck` property, which is an async function, and `@fastify/under-pressure` will allow you to check the status of other components of your service.
221 |
222 | This function should return a promise that resolves to a boolean value or an object. The `healthCheck` function can be called either:
223 |
224 | * every X milliseconds, the time can be
225 | configured with the `healthCheckInterval` option.
226 | * every time the status route is called, if `exposeStatusRoute` is set
227 | to `true`.
228 |
229 | By default when this function is supplied your service health is considered unhealthy, until it has started to return true.
230 |
231 | ```js
232 | const fastify = require('fastify')()
233 |
234 | fastify.register(require('@fastify/under-pressure'), {
235 | healthCheck: async function (fastifyInstance) {
236 | // Do some magic to check if your db connection is healthy
237 | return true
238 | },
239 | healthCheckInterval: 500
240 | })
241 | ```
242 |
243 | #### Sample interval
244 |
245 | You can set a custom value for sampling the metrics returned by `memoryUsage` using the `sampleInterval` option, which accepts a number that represents the interval in milliseconds.
246 |
247 | The default value is different depending on which Node version is used. In version 8 and 10 it is `5`, while on version 11.10.0 and up it is `1000`. This difference is because from version 11.10.0 the event loop delay can be sampled with [`monitorEventLoopDelay`](https://nodejs.org/docs/latest-v12.x/api/perf_hooks.html#perf_hooks_perf_hooks_monitoreventloopdelay_options) and this allows an increase in the interval value.
248 |
249 | ```js
250 | const fastify = require('fastify')()
251 |
252 | fastify.register(require('@fastify/under-pressure'), {
253 | sampleInterval:
254 | })
255 | ```
256 |
257 |
258 | ## Additional information
259 |
260 |
261 | #### `setTimeout` vs `setInterval`
262 |
263 | Under the hood, `@fastify/under-pressure` uses the `setTimeout` method to perform its polling checks. The choice is based on the fact that we do not want to add additional pressure to the system.
264 |
265 | In fact, it is known that `setInterval` will call repeatedly at the scheduled time regardless of whether the previous call ended or not, and if the server is already under load, this will likely increase the problem, because those `setInterval` calls will start piling up. `setTimeout`, on the other hand, is called only once and does not cause the mentioned problem.
266 |
267 | One note to consider is that because the two methods are not identical, the timer function is not guaranteed to run at the same rate when the system is under pressure or running a long-running process.
268 |
269 |
270 |
271 | ## Acknowledgments
272 |
273 | This project is kindly sponsored by [LetzDoIt](https://www.letzdoitapp.com/).
274 |
275 |
276 | ## License
277 |
278 | Licensed under [MIT](./LICENSE).
279 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = require('neostandard')({
4 | ignores: require('neostandard').resolveIgnoresFromGitignore(),
5 | ts: true
6 | })
7 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fe = require('@fastify/error')
4 | const fp = require('fastify-plugin')
5 | const assert = require('node:assert')
6 | const { monitorEventLoopDelay } = require('node:perf_hooks')
7 | const { eventLoopUtilization } = require('node:perf_hooks').performance
8 |
9 | const SERVICE_UNAVAILABLE = 503
10 | const createError = (msg = 'Service Unavailable') => fe('FST_UNDER_PRESSURE', msg, SERVICE_UNAVAILABLE)
11 |
12 | const TYPE_EVENT_LOOP_DELAY = 'eventLoopDelay'
13 | const TYPE_HEAP_USED_BYTES = 'heapUsedBytes'
14 | const TYPE_RSS_BYTES = 'rssBytes'
15 | const TYPE_HEALTH_CHECK = 'healthCheck'
16 | const TYPE_EVENT_LOOP_UTILIZATION = 'eventLoopUtilization'
17 |
18 | function getSampleInterval (value, eventLoopResolution) {
19 | const sampleInterval = value || 1000
20 |
21 | return Math.max(eventLoopResolution, sampleInterval)
22 | }
23 |
24 | async function fastifyUnderPressure (fastify, opts = {}) {
25 | const resolution = 10
26 | const sampleInterval = getSampleInterval(opts.sampleInterval, resolution)
27 | const maxEventLoopDelay = opts.maxEventLoopDelay || 0
28 | const maxHeapUsedBytes = opts.maxHeapUsedBytes || 0
29 | const maxRssBytes = opts.maxRssBytes || 0
30 | const healthCheck = opts.healthCheck || false
31 | const healthCheckInterval = opts.healthCheckInterval || -1
32 | const UnderPressureError = opts.customError || createError(opts.message)
33 | const maxEventLoopUtilization = opts.maxEventLoopUtilization || 0
34 | const pressureHandler = opts.pressureHandler
35 |
36 | const checkMaxEventLoopDelay = maxEventLoopDelay > 0
37 | const checkMaxHeapUsedBytes = maxHeapUsedBytes > 0
38 | const checkMaxRssBytes = maxRssBytes > 0
39 | const checkMaxEventLoopUtilization = maxEventLoopUtilization > 0
40 |
41 | let heapUsed = 0
42 | let rssBytes = 0
43 | let eventLoopDelay = 0
44 | let elu
45 | let eventLoopUtilized = 0
46 |
47 | const histogram = monitorEventLoopDelay({ resolution })
48 | histogram.enable()
49 |
50 | if (eventLoopUtilization) {
51 | elu = eventLoopUtilization()
52 | }
53 |
54 | fastify.decorate('memoryUsage', memoryUsage)
55 | fastify.decorate('isUnderPressure', isUnderPressure)
56 |
57 | const timer = setTimeout(beginMemoryUsageUpdate, sampleInterval)
58 | timer.unref()
59 |
60 | let externalsHealthy = false
61 | let externalHealthCheckTimer
62 | if (healthCheck) {
63 | assert(typeof healthCheck === 'function', 'opts.healthCheck should be a function that returns a promise that resolves to true or false')
64 | assert(healthCheckInterval > 0 || opts.exposeStatusRoute, 'opts.healthCheck requires opts.healthCheckInterval or opts.exposeStatusRoute')
65 |
66 | const doCheck = async () => {
67 | try {
68 | externalsHealthy = await healthCheck(fastify)
69 | } catch (error) {
70 | externalsHealthy = false
71 | fastify.log.error({ error }, 'external healthCheck function supplied to `under-pressure` threw an error. setting the service status to unhealthy.')
72 | }
73 | }
74 |
75 | await doCheck()
76 |
77 | if (healthCheckInterval > 0) {
78 | const beginCheck = async () => {
79 | await doCheck()
80 | externalHealthCheckTimer.refresh()
81 | }
82 |
83 | externalHealthCheckTimer = setTimeout(beginCheck, healthCheckInterval)
84 | externalHealthCheckTimer.unref()
85 | }
86 | } else {
87 | externalsHealthy = true
88 | }
89 |
90 | fastify.addHook('onClose', onClose)
91 |
92 | opts.exposeStatusRoute = mapExposeStatusRoute(opts.exposeStatusRoute)
93 |
94 | if (opts.exposeStatusRoute) {
95 | fastify.route({
96 | ...opts.exposeStatusRoute.routeOpts,
97 | url: opts.exposeStatusRoute.url,
98 | method: 'GET',
99 | schema: Object.assign({}, opts.exposeStatusRoute.routeSchemaOpts, {
100 | response: {
101 | 200: {
102 | type: 'object',
103 | description: 'Health Check Succeeded',
104 | properties: Object.assign(
105 | { status: { type: 'string' } },
106 | opts.exposeStatusRoute.routeResponseSchemaOpts
107 | ),
108 | example: {
109 | status: 'ok'
110 | }
111 | },
112 | 500: {
113 | type: 'object',
114 | description: 'Error Performing Health Check',
115 | properties: {
116 | message: { type: 'string', description: 'Error message for failure during health check', example: 'Internal Server Error' },
117 | statusCode: { type: 'number', description: 'Code representing the error. Always matches the HTTP response code.', example: 500 }
118 | }
119 | },
120 | 503: {
121 | type: 'object',
122 | description: 'Health Check Failed',
123 | properties: {
124 | code: { type: 'string', description: 'Error code associated with the failing check', example: 'FST_UNDER_PRESSURE' },
125 | error: { type: 'string', description: 'Error thrown during health check', example: 'Service Unavailable' },
126 | message: { type: 'string', description: 'Error message to explain health check failure', example: 'Service Unavailable' },
127 | statusCode: { type: 'number', description: 'Code representing the error. Always matches the HTTP response code.', example: 503 }
128 | }
129 | }
130 | }
131 | }),
132 | handler: onStatus
133 | })
134 | }
135 |
136 | if (checkMaxEventLoopUtilization === false && checkMaxEventLoopDelay === false &&
137 | checkMaxHeapUsedBytes === false &&
138 | checkMaxRssBytes === false &&
139 | healthCheck === false) {
140 | return
141 | }
142 |
143 | const underPressureError = new UnderPressureError()
144 | const retryAfter = opts.retryAfter || 10
145 |
146 | fastify.addHook('onRequest', onRequest)
147 |
148 | function mapExposeStatusRoute (opts) {
149 | if (!opts) {
150 | return false
151 | }
152 | if (typeof opts === 'string') {
153 | return { url: opts }
154 | }
155 | return Object.assign({ url: '/status' }, opts)
156 | }
157 |
158 | function updateEventLoopDelay () {
159 | eventLoopDelay = Math.max(0, histogram.mean / 1e6 - resolution)
160 | if (Number.isNaN(eventLoopDelay)) eventLoopDelay = Infinity
161 | histogram.reset()
162 | }
163 |
164 | function updateEventLoopUtilization () {
165 | if (elu) {
166 | eventLoopUtilized = eventLoopUtilization(elu).utilization
167 | } else {
168 | eventLoopUtilized = 0
169 | }
170 | }
171 |
172 | function beginMemoryUsageUpdate () {
173 | updateMemoryUsage()
174 | timer.refresh()
175 | }
176 |
177 | function updateMemoryUsage () {
178 | const mem = process.memoryUsage()
179 | heapUsed = mem.heapUsed
180 | rssBytes = mem.rss
181 | updateEventLoopDelay()
182 | updateEventLoopUtilization()
183 | }
184 |
185 | function isUnderPressure () {
186 | if (checkMaxEventLoopDelay && eventLoopDelay > maxEventLoopDelay) {
187 | return true
188 | }
189 |
190 | if (checkMaxHeapUsedBytes && heapUsed > maxHeapUsedBytes) {
191 | return true
192 | }
193 |
194 | if (checkMaxRssBytes && rssBytes > maxRssBytes) {
195 | return true
196 | }
197 |
198 | if (!externalsHealthy) {
199 | return true
200 | }
201 |
202 | return checkMaxEventLoopUtilization && eventLoopUtilized > maxEventLoopUtilization
203 | }
204 |
205 | function onRequest (req, reply, next) {
206 | const _pressureHandler = req.routeOptions.config.pressureHandler || pressureHandler
207 | if (checkMaxEventLoopDelay && eventLoopDelay > maxEventLoopDelay) {
208 | handlePressure(_pressureHandler, req, reply, next, TYPE_EVENT_LOOP_DELAY, eventLoopDelay)
209 | return
210 | }
211 |
212 | if (checkMaxHeapUsedBytes && heapUsed > maxHeapUsedBytes) {
213 | handlePressure(_pressureHandler, req, reply, next, TYPE_HEAP_USED_BYTES, heapUsed)
214 | return
215 | }
216 |
217 | if (checkMaxRssBytes && rssBytes > maxRssBytes) {
218 | handlePressure(_pressureHandler, req, reply, next, TYPE_RSS_BYTES, rssBytes)
219 | return
220 | }
221 |
222 | if (!externalsHealthy) {
223 | handlePressure(_pressureHandler, req, reply, next, TYPE_HEALTH_CHECK, undefined)
224 | return
225 | }
226 |
227 | if (checkMaxEventLoopUtilization && eventLoopUtilized > maxEventLoopUtilization) {
228 | handlePressure(_pressureHandler, req, reply, next, TYPE_EVENT_LOOP_UTILIZATION, eventLoopUtilized)
229 | return
230 | }
231 |
232 | next()
233 | }
234 |
235 | function handlePressure (pressureHandler, req, reply, next, type, value) {
236 | if (typeof pressureHandler === 'function') {
237 | const result = pressureHandler(req, reply, type, value)
238 | if (result instanceof Promise) {
239 | result.then(() => next(), next)
240 | } else if (result == null) {
241 | next()
242 | } else {
243 | reply.send(result)
244 | }
245 | } else {
246 | reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
247 | next(underPressureError)
248 | }
249 | }
250 |
251 | function memoryUsage () {
252 | return {
253 | eventLoopDelay,
254 | rssBytes,
255 | heapUsed,
256 | eventLoopUtilized
257 | }
258 | }
259 |
260 | async function onStatus (req, reply) {
261 | const okResponse = { status: 'ok' }
262 | if (healthCheck) {
263 | try {
264 | const checkResult = await healthCheck(fastify)
265 | if (!checkResult) {
266 | req.log.error('external health check failed')
267 | reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
268 | throw underPressureError
269 | }
270 |
271 | return Object.assign(okResponse, checkResult)
272 | } catch (err) {
273 | req.log.error({ err }, 'external health check failed with error')
274 | reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
275 | throw underPressureError
276 | }
277 | }
278 |
279 | return okResponse
280 | }
281 |
282 | function onClose (_fastify, done) {
283 | clearTimeout(timer)
284 | clearTimeout(externalHealthCheckTimer)
285 | done()
286 | }
287 | }
288 |
289 | module.exports = fp(fastifyUnderPressure, {
290 | fastify: '5.x',
291 | name: '@fastify/under-pressure'
292 | })
293 | module.exports.default = fastifyUnderPressure
294 | module.exports.fastifyUnderPressure = fastifyUnderPressure
295 |
296 | module.exports.TYPE_EVENT_LOOP_DELAY = TYPE_EVENT_LOOP_DELAY
297 | module.exports.TYPE_EVENT_LOOP_UTILIZATION = TYPE_EVENT_LOOP_UTILIZATION
298 | module.exports.TYPE_HEALTH_CHECK = TYPE_HEALTH_CHECK
299 | module.exports.TYPE_HEAP_USED_BYTES = TYPE_HEAP_USED_BYTES
300 | module.exports.TYPE_RSS_BYTES = TYPE_RSS_BYTES
301 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fastify/under-pressure",
3 | "version": "9.0.3",
4 | "description": "Process load measuring plugin for Fastify, with automatic handling of 'Service Unavailable'",
5 | "main": "index.js",
6 | "type": "commonjs",
7 | "types": "types/index.d.ts",
8 | "scripts": {
9 | "lint": "eslint",
10 | "lint:fix": "eslint --fix",
11 | "test": "npm run test:unit && npm run test:typescript",
12 | "test:unit": "borp -C --check-coverage --reporter=@jsumners/line-reporter",
13 | "test:typescript": "tsd"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/fastify/under-pressure.git"
18 | },
19 | "bugs": {
20 | "url": "https://github.com/fastify/under-pressure/issues"
21 | },
22 | "homepage": "https://github.com/fastify/under-pressure#readme",
23 | "funding": [
24 | {
25 | "type": "github",
26 | "url": "https://github.com/sponsors/fastify"
27 | },
28 | {
29 | "type": "opencollective",
30 | "url": "https://opencollective.com/fastify"
31 | }
32 | ],
33 | "keywords": [
34 | "fastify",
35 | "service unavailable",
36 | "limit",
37 | "delay",
38 | "retry"
39 | ],
40 | "author": "Tomas Della Vedova - @delvedor (http://delved.org)",
41 | "contributors": [
42 | {
43 | "name": "Matteo Collina",
44 | "email": "hello@matteocollina.com"
45 | },
46 | {
47 | "name": "Manuel Spigolon",
48 | "email": "behemoth89@gmail.com"
49 | },
50 | {
51 | "name": "Aras Abbasi",
52 | "email": "aras.abbasi@gmail.com"
53 | },
54 | {
55 | "name": "Frazer Smith",
56 | "email": "frazer.dev@icloud.com",
57 | "url": "https://github.com/fdawgs"
58 | }
59 | ],
60 | "license": "MIT",
61 | "dependencies": {
62 | "@fastify/error": "^4.0.0",
63 | "fastify-plugin": "^5.0.0"
64 | },
65 | "devDependencies": {
66 | "@fastify/pre-commit": "^2.1.0",
67 | "@jsumners/line-reporter": "^1.0.1",
68 | "@types/node": "^22.0.0",
69 | "borp": "^0.20.0",
70 | "eslint": "^9.17.0",
71 | "fastify": "^5.0.0",
72 | "neostandard": "^0.12.0",
73 | "proxyquire": "^2.1.3",
74 | "semver": "^7.6.0",
75 | "sinon": "^18.0.0",
76 | "tsd": "^0.32.0"
77 | },
78 | "publishConfig": {
79 | "access": "public"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/test/exports.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test } = require('node:test')
4 | const assert = require('node:assert')
5 | const fastifyUnderPressure = require('..')
6 | const { TYPE_EVENT_LOOP_DELAY, TYPE_EVENT_LOOP_UTILIZATION, TYPE_HEALTH_CHECK, TYPE_HEAP_USED_BYTES, TYPE_RSS_BYTES } = require('..')
7 |
8 | test('module.exports', async (t) => {
9 | assert.strictEqual(typeof fastifyUnderPressure.default, 'function')
10 | assert.strictEqual(typeof fastifyUnderPressure.fastifyUnderPressure, 'function')
11 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.name, 'fastifyUnderPressure')
12 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure, fastifyUnderPressure.fastifyUnderPressure.fastifyUnderPressure)
13 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.default, fastifyUnderPressure.fastifyUnderPressure.fastifyUnderPressure)
14 |
15 | assert.strictEqual(TYPE_EVENT_LOOP_DELAY, 'eventLoopDelay')
16 | assert.strictEqual(TYPE_EVENT_LOOP_UTILIZATION, 'eventLoopUtilization')
17 | assert.strictEqual(TYPE_HEALTH_CHECK, 'healthCheck')
18 | assert.strictEqual(TYPE_HEAP_USED_BYTES, 'heapUsedBytes')
19 | assert.strictEqual(TYPE_RSS_BYTES, 'rssBytes')
20 |
21 | assert.strictEqual(fastifyUnderPressure.TYPE_RSS_BYTES, 'rssBytes')
22 | assert.strictEqual(fastifyUnderPressure.TYPE_EVENT_LOOP_DELAY, 'eventLoopDelay')
23 | assert.strictEqual(fastifyUnderPressure.TYPE_EVENT_LOOP_UTILIZATION, 'eventLoopUtilization')
24 | assert.strictEqual(fastifyUnderPressure.TYPE_HEALTH_CHECK, 'healthCheck')
25 | assert.strictEqual(fastifyUnderPressure.TYPE_HEAP_USED_BYTES, 'heapUsedBytes')
26 |
27 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.TYPE_EVENT_LOOP_DELAY, 'eventLoopDelay')
28 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.TYPE_EVENT_LOOP_UTILIZATION, 'eventLoopUtilization')
29 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.TYPE_HEALTH_CHECK, 'healthCheck')
30 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.TYPE_HEAP_USED_BYTES, 'heapUsedBytes')
31 | assert.strictEqual(fastifyUnderPressure.fastifyUnderPressure.TYPE_RSS_BYTES, 'rssBytes')
32 | })
33 |
--------------------------------------------------------------------------------
/test/forkRequest.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fork = require('node:child_process').fork
4 | const resolve = require('node:path').resolve
5 |
6 | module.exports = function forkRequest (address, delay = 100, cb) {
7 | const childProcess = fork(
8 | resolve(__dirname, 'request.js'),
9 | [address, delay],
10 | { windowsHide: true }
11 | )
12 |
13 | childProcess.on('message', (payload) => {
14 | if (payload.error) {
15 | cb(new Error(payload.error), JSON.parse(payload.response), payload.body)
16 | return
17 | }
18 | cb(null, JSON.parse(payload.response), payload.body)
19 | })
20 | childProcess.on('error', err => {
21 | cb(err)
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, afterEach, describe, beforeEach } = require('node:test')
4 | const { promisify } = require('node:util')
5 | const forkRequest = require('./forkRequest')
6 | const Fastify = require('fastify')
7 | const { monitorEventLoopDelay } = require('node:perf_hooks')
8 | const underPressure = require('../index')
9 | const { valid, satisfies, coerce } = require('semver')
10 |
11 | const wait = promisify(setTimeout)
12 | function block (msec) {
13 | const start = Date.now()
14 | /* eslint-disable no-empty */
15 | while (Date.now() - start < msec) {}
16 | }
17 |
18 | let fastify
19 |
20 | beforeEach(() => {
21 | fastify = Fastify()
22 | })
23 |
24 | afterEach(async () => {
25 | await fastify.close()
26 | })
27 |
28 | test('Should return 503 on maxEventLoopDelay', (t, done) => {
29 | t.plan(6)
30 | fastify.register(underPressure, {
31 | maxEventLoopDelay: 15
32 | })
33 |
34 | fastify.get('/', (_req, reply) => {
35 | reply.send({ hello: 'world' })
36 | })
37 |
38 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
39 | t.assert.ifError(err)
40 | fastify.server.unref()
41 |
42 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, response, body) => {
43 | t.assert.ifError(err)
44 | t.assert.equal(response.statusCode, 503)
45 | t.assert.equal(response.headers['retry-after'], '10')
46 | t.assert.deepStrictEqual(JSON.parse(body), {
47 | code: 'FST_UNDER_PRESSURE',
48 | error: 'Service Unavailable',
49 | message: 'Service Unavailable',
50 | statusCode: 503
51 | })
52 | t.assert.equal(fastify.isUnderPressure(), true)
53 | fastify.close()
54 | done()
55 | })
56 |
57 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
58 | })
59 | })
60 |
61 | const isSupportedVersion = satisfies(
62 | valid(coerce(process.version)),
63 | '12.19.0 || >=14.0.0'
64 | )
65 | test(
66 | 'Should return 503 on maxEventloopUtilization',
67 | { skip: !isSupportedVersion },
68 | (t, done) => {
69 | t.plan(6)
70 | fastify.register(underPressure, {
71 | maxEventLoopUtilization: 0.6,
72 | })
73 |
74 | fastify.get('/', (_req, reply) => {
75 | reply.send({ hello: 'world' })
76 | })
77 | fastify.listen({ port: 0, host: '127.0.0.1' }, async (err, address) => {
78 | t.assert.ifError(err)
79 | fastify.server.unref()
80 |
81 | forkRequest(address, 500, (err, response, body) => {
82 | t.assert.ifError(err)
83 | t.assert.equal(response.statusCode, 503)
84 | t.assert.equal(response.headers['retry-after'], '10')
85 | t.assert.deepStrictEqual(JSON.parse(body), {
86 | code: 'FST_UNDER_PRESSURE',
87 | error: 'Service Unavailable',
88 | message: 'Service Unavailable',
89 | statusCode: 503,
90 | })
91 | t.assert.equal(fastify.isUnderPressure(), true)
92 | done()
93 | })
94 |
95 | process.nextTick(() => block(1000))
96 | })
97 | })
98 |
99 | test('Should return 503 on maxHeapUsedBytes', (t, done) => {
100 | t.plan(6)
101 | fastify.register(underPressure, {
102 | maxHeapUsedBytes: 1
103 | })
104 |
105 | fastify.get('/', (_req, reply) => {
106 | reply.send({ hello: 'world' })
107 | })
108 |
109 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
110 | t.assert.ifError(err)
111 | fastify.server.unref()
112 |
113 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, response, body) => {
114 | t.assert.ifError(err)
115 | t.assert.equal(response.statusCode, 503)
116 | t.assert.equal(response.headers['retry-after'], '10')
117 | t.assert.deepStrictEqual(JSON.parse(body), {
118 | code: 'FST_UNDER_PRESSURE',
119 | error: 'Service Unavailable',
120 | message: 'Service Unavailable',
121 | statusCode: 503
122 | })
123 | t.assert.equal(fastify.isUnderPressure(), true)
124 | fastify.close()
125 | done()
126 | })
127 |
128 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
129 | })
130 | })
131 |
132 | test('Should return 503 on maxRssBytes', (t, done) => {
133 | t.plan(6)
134 | const fastify = Fastify()
135 | fastify.register(underPressure, {
136 | maxRssBytes: 1
137 | })
138 |
139 | fastify.get('/', (_req, reply) => {
140 | reply.send({ hello: 'world' })
141 | })
142 |
143 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
144 | t.assert.ifError(err)
145 | fastify.server.unref()
146 |
147 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, response, body) => {
148 | t.assert.ifError(err)
149 | t.assert.equal(response.statusCode, 503)
150 | t.assert.equal(response.headers['retry-after'], '10')
151 | t.assert.deepStrictEqual(JSON.parse(body), {
152 | code: 'FST_UNDER_PRESSURE',
153 | error: 'Service Unavailable',
154 | message: 'Service Unavailable',
155 | statusCode: 503
156 | })
157 | t.assert.equal(fastify.isUnderPressure(), true)
158 | fastify.close()
159 | done()
160 | })
161 |
162 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
163 | })
164 | })
165 |
166 | test('Custom message and retry after header', (t, done) => {
167 | t.plan(5)
168 | fastify.register(underPressure, {
169 | maxRssBytes: 1,
170 | message: 'Under pressure!',
171 | retryAfter: 50
172 | })
173 |
174 | fastify.get('/', (_req, reply) => {
175 | reply.send({ hello: 'world' })
176 | })
177 |
178 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
179 | t.assert.ifError(err)
180 | fastify.server.unref()
181 |
182 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, response, body) => {
183 | t.assert.ifError(err)
184 | t.assert.equal(response.statusCode, 503)
185 | t.assert.equal(response.headers['retry-after'], '50')
186 | t.assert.deepStrictEqual(JSON.parse(body), {
187 | code: 'FST_UNDER_PRESSURE',
188 | error: 'Service Unavailable',
189 | message: 'Under pressure!',
190 | statusCode: 503
191 | })
192 | fastify.close()
193 | done()
194 | })
195 |
196 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
197 | })
198 | })
199 |
200 | test('Custom error instance', (t, done) => {
201 | t.plan(5)
202 | class CustomError extends Error {
203 | constructor () {
204 | super('Custom error message')
205 | this.statusCode = 418
206 | this.code = 'FST_CUSTOM_ERROR'
207 | Error.captureStackTrace(this, CustomError)
208 | }
209 | }
210 |
211 | fastify.register(underPressure, {
212 | maxRssBytes: 1,
213 | customError: CustomError,
214 | })
215 |
216 | fastify.get('/', (_req, reply) => {
217 | reply.send({ hello: 'world' })
218 | })
219 |
220 | fastify.setErrorHandler((err, _req, reply) => {
221 | t.assert.ok(err instanceof Error)
222 | return reply.code(err.statusCode).send(err)
223 | })
224 |
225 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
226 | t.assert.ifError(err)
227 | fastify.server.unref()
228 |
229 | forkRequest(
230 | address,
231 | monitorEventLoopDelay ? 750 : 250,
232 | (err, response, body) => {
233 | t.assert.ifError(err)
234 | t.assert.equal(response.statusCode, 418)
235 | t.assert.deepStrictEqual(JSON.parse(body), {
236 | code: 'FST_CUSTOM_ERROR',
237 | error: "I'm a Teapot",
238 | message: 'Custom error message',
239 | statusCode: 418,
240 | })
241 | done()
242 | }
243 | )
244 |
245 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
246 | })
247 | })
248 |
249 | test('memoryUsage name space', (t, done) => {
250 | fastify.register(underPressure, {
251 | maxEventLoopDelay: 1000,
252 | maxHeapUsedBytes: 100000000,
253 | maxRssBytes: 100000000,
254 | maxEventLoopUtilization: 0.85,
255 | pressureHandler: (_req, _rep, _type, _value) => {
256 | t.assert.ok(false)
257 | t.assert.ok(fastify.memoryUsage().eventLoopDelay > 0)
258 | t.assert.ok(fastify.memoryUsage().heapUsed > 0)
259 | t.assert.ok(fastify.memoryUsage().rssBytes > 0)
260 | t.assert.ok(fastify.memoryUsage().eventLoopUtilized >= 0)
261 | },
262 | })
263 | fastify.get('/', (_req, reply) => {
264 | reply.send({ hello: 'world' })
265 | })
266 |
267 | fastify.listen({ port: 0, host: '127.0.0.1' }, async (err, address) => {
268 | t.assert.ifError(err)
269 | t.assert.equal(typeof fastify.memoryUsage, 'function')
270 | fastify.server.unref()
271 |
272 | // If using monitorEventLoopDelay give it time to collect
273 | // some samples
274 | if (monitorEventLoopDelay) {
275 | await wait(500)
276 | }
277 |
278 | forkRequest(
279 | address,
280 | monitorEventLoopDelay ? 750 : 250,
281 | (err, response, body) => {
282 | t.assert.ifError(err)
283 | t.assert.equal(response.statusCode, 200)
284 | t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' })
285 | done()
286 | }
287 | )
288 |
289 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
290 | })
291 | })
292 |
293 | test('memoryUsage name space (without check)', (t, done) => {
294 | const IS_WINDOWS = process.platform === 'win32'
295 | t.plan(9)
296 | fastify.register(underPressure)
297 |
298 | fastify.get('/', (_req, reply) => {
299 | t.assert.ok(fastify.memoryUsage().eventLoopDelay > 0)
300 | t.assert.ok(fastify.memoryUsage().heapUsed > 0)
301 | t.assert.ok(fastify.memoryUsage().rssBytes > 0)
302 | t.assert.ok(fastify.memoryUsage().eventLoopUtilized >= 0)
303 | reply.send({ hello: 'world' })
304 | })
305 |
306 | fastify.listen({ port: 0, host: '127.0.0.1' }, async (err, address) => {
307 | t.assert.ifError(err)
308 | t.assert.equal(typeof fastify.memoryUsage, 'function')
309 | fastify.server.unref()
310 |
311 | // If using monitorEventLoopDelay, give it time to collect some samples
312 | if (monitorEventLoopDelay) {
313 | await wait(IS_WINDOWS ? 1000 : 500)
314 | }
315 |
316 | forkRequest(
317 | address,
318 | monitorEventLoopDelay ? (IS_WINDOWS ? 1500 : 750) : (IS_WINDOWS ? 500 : 250),
319 | (err, response, body) => {
320 | t.assert.ifError(err)
321 | t.assert.equal(response.statusCode, 200)
322 | t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' })
323 | done()
324 | }
325 | )
326 |
327 | process.nextTick(() => block(monitorEventLoopDelay ? (IS_WINDOWS ? 3000 : 1500) : (IS_WINDOWS ? 1000 : 500)))
328 | })
329 | })
330 |
331 | describe('Custom health check', () => {
332 | test('should return 503 when custom health check returns false for healthCheck', (t, done) => {
333 | t.plan(6)
334 | fastify.register(underPressure, {
335 | healthCheck: async () => {
336 | return false
337 | },
338 | healthCheckInterval: 1000,
339 | })
340 |
341 | fastify.get('/', (_req, reply) => {
342 | reply.send({ hello: 'world' })
343 | })
344 |
345 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
346 | t.assert.ifError(err)
347 | fastify.server.unref()
348 |
349 | forkRequest(address, 0, (err, response, body) => {
350 | t.assert.ifError(err)
351 | t.assert.equal(response.statusCode, 503)
352 | t.assert.equal(response.headers['retry-after'], '10')
353 | t.assert.deepStrictEqual(JSON.parse(body), {
354 | code: 'FST_UNDER_PRESSURE',
355 | error: 'Service Unavailable',
356 | message: 'Service Unavailable',
357 | statusCode: 503,
358 | })
359 | t.assert.equal(fastify.isUnderPressure(), true)
360 | done()
361 | })
362 | })
363 | })
364 |
365 | test('should return 200 when custom health check returns true for healthCheck', (t, done) => {
366 | t.plan(4)
367 | fastify.register(underPressure, {
368 | healthCheck: async () => true,
369 | healthCheckInterval: 1000,
370 | })
371 |
372 | fastify.get('/', (_req, reply) => {
373 | reply.send({ hello: 'world' })
374 | })
375 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
376 | t.assert.ifError(err)
377 | fastify.server.unref()
378 |
379 | forkRequest(address, 0, (err, response, body) => {
380 | t.assert.ifError(err)
381 | t.assert.equal(response.statusCode, 200)
382 | t.assert.deepStrictEqual(JSON.parse(body), {
383 | hello: 'world',
384 | })
385 | done()
386 | })
387 | })
388 | })
389 |
390 | test('healthCheckInterval option', (t, done) => {
391 | t.plan(8)
392 | let check = true
393 |
394 | fastify.register(underPressure, {
395 | healthCheck: async () => check,
396 | healthCheckInterval: 100,
397 | })
398 |
399 | fastify.get('/', (_req, reply) => {
400 | reply.send({ hello: 'world' })
401 | })
402 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
403 | t.assert.ifError(err)
404 | fastify.server.unref()
405 | let alreadyFinished = false
406 | forkRequest(address, 0, (err, response, body) => {
407 | check = false
408 | t.assert.ifError(err)
409 | t.assert.equal(response.statusCode, 200)
410 | t.assert.deepStrictEqual(JSON.parse(body), {
411 | hello: 'world',
412 | })
413 | if (alreadyFinished) {
414 | done()
415 | }
416 | alreadyFinished = true
417 | })
418 |
419 | forkRequest(address, 100, (err, response, body) => {
420 | t.assert.ifError(err)
421 | t.assert.equal(response.statusCode, 503)
422 | t.assert.equal(response.headers['retry-after'], '10')
423 | t.assert.deepStrictEqual(JSON.parse(body), {
424 | code: 'FST_UNDER_PRESSURE',
425 | error: 'Service Unavailable',
426 | message: 'Service Unavailable',
427 | statusCode: 503,
428 | })
429 | if (alreadyFinished) {
430 | done()
431 | }
432 | alreadyFinished = true
433 | })
434 | })
435 | })
436 | })
437 |
438 | test('should wait for the initial healthCheck call before initialising the server', async (t) => {
439 | t.plan(2)
440 | let called = false
441 |
442 | fastify.register(underPressure, {
443 | healthCheck: async () => {
444 | await wait(100)
445 | t.assert.strictEqual(called, false)
446 | called = true
447 | },
448 | healthCheckInterval: 1000,
449 | })
450 |
451 | await fastify.listen({ port: 0, host: '127.0.0.1' })
452 |
453 | t.assert.ok(called)
454 | })
455 |
456 | test('should call the external health at every status route', (t, done) => {
457 | t.plan(5)
458 | let check = true
459 | fastify.register(underPressure, {
460 | healthCheck: async () => {
461 | return check
462 | },
463 | exposeStatusRoute: true,
464 | })
465 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
466 | t.assert.ifError(err)
467 | fastify.server.unref()
468 | check = false
469 |
470 | forkRequest(address + '/status', 0, (err, response, body) => {
471 | t.assert.ifError(err)
472 | t.assert.equal(response.statusCode, 503)
473 | t.assert.equal(response.headers['retry-after'], '10')
474 | t.assert.deepStrictEqual(JSON.parse(body), {
475 | code: 'FST_UNDER_PRESSURE',
476 | error: 'Service Unavailable',
477 | message: 'Service Unavailable',
478 | statusCode: 503,
479 | })
480 | done()
481 | })
482 | })
483 | })
484 |
485 | test('should call the external health at every status route, healthCheck throws', (t, done) => {
486 | t.plan(5)
487 | let check = true
488 | fastify.register(underPressure, {
489 | healthCheck: async () => {
490 | if (check === false) {
491 | throw new Error('kaboom')
492 | }
493 | return true
494 | },
495 | exposeStatusRoute: true,
496 | })
497 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
498 | t.assert.ifError(err)
499 | fastify.server.unref()
500 | check = false
501 |
502 | forkRequest(address + '/status', 0, (err, response, body) => {
503 | t.assert.ifError(err)
504 | t.assert.equal(response.statusCode, 503)
505 | t.assert.equal(response.headers['retry-after'], '10')
506 | t.assert.deepStrictEqual(JSON.parse(body), {
507 | code: 'FST_UNDER_PRESSURE',
508 | error: 'Service Unavailable',
509 | message: 'Service Unavailable',
510 | statusCode: 503,
511 | })
512 | done()
513 | })
514 | })
515 | })
516 |
517 | test('should return custom response if returned from the healthCheck function', (t, done) => {
518 | t.plan(4)
519 | fastify.register(underPressure, {
520 | healthCheck: async () => {
521 | return {
522 | some: 'value',
523 | anotherValue: 'another',
524 | status: 'overrride status',
525 | }
526 | },
527 | exposeStatusRoute: {
528 | routeResponseSchemaOpts: {
529 | some: { type: 'string' },
530 | anotherValue: { type: 'string' },
531 | },
532 | },
533 | })
534 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
535 | t.assert.ifError(err)
536 | fastify.server.unref()
537 |
538 | forkRequest(address + '/status', 0, (err, response, body) => {
539 | t.assert.ifError(err)
540 | t.assert.equal(response.statusCode, 200)
541 | t.assert.deepStrictEqual(JSON.parse(body), {
542 | some: 'value',
543 | anotherValue: 'another',
544 | status: 'overrride status',
545 | })
546 | done()
547 | })
548 | })
549 | })
550 |
551 | test('should be fastify instance as argument in the healthCheck function', (t, done) => {
552 | t.plan(4)
553 | fastify.register(underPressure, {
554 | healthCheck: async (fastifyInstance) => {
555 | return {
556 | fastifyInstanceOk: fastifyInstance === fastify,
557 | status: 'overrride status',
558 | }
559 | },
560 | exposeStatusRoute: {
561 | routeResponseSchemaOpts: {
562 | fastifyInstanceOk: { type: 'boolean' },
563 | },
564 | },
565 | })
566 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
567 | t.assert.ifError(err)
568 | fastify.server.unref()
569 |
570 | forkRequest(address + '/status', 0, (err, response, body) => {
571 | t.assert.ifError(err)
572 | t.assert.equal(response.statusCode, 200)
573 | t.assert.deepStrictEqual(JSON.parse(body), {
574 | fastifyInstanceOk: true,
575 | status: 'overrride status',
576 | })
577 | done()
578 | })
579 | })
580 | })
581 |
--------------------------------------------------------------------------------
/test/issues/216.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, after } = require('node:test')
4 | const assert = require('node:assert')
5 | const Fastify = require('fastify')
6 | const underPressure = require('../../index')
7 |
8 | let app
9 |
10 | after(async () => {
11 | await app.close()
12 | })
13 |
14 | test('should be unhealthy if healthCheck throws an error', async () => {
15 | app = Fastify()
16 | app.register(underPressure, {
17 | healthCheck: async () => {
18 | throw new Error('Kaboom!')
19 | },
20 | healthCheckInterval: 1000,
21 | exposeStatusRoute: true,
22 | pressureHandler: (_req, rep, type) => {
23 | assert.strictEqual(type, underPressure.TYPE_HEALTH_CHECK)
24 | rep.status(503).send('unhealthy')
25 | },
26 | })
27 |
28 | await app.ready()
29 | assert.ok(app.isUnderPressure())
30 |
31 | const response = await app.inject({
32 | method: 'GET',
33 | url: '/status',
34 | })
35 |
36 | assert.strictEqual(response.statusCode, 503)
37 | assert.strictEqual(response.body, 'unhealthy')
38 | })
39 |
--------------------------------------------------------------------------------
/test/pressurehandler.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, afterEach, describe, after, beforeEach } = require('node:test')
4 | const assert = require('node:assert')
5 | const { promisify } = require('node:util')
6 | const forkRequest = require('./forkRequest')
7 | const Fastify = require('fastify')
8 | const { monitorEventLoopDelay } = require('node:perf_hooks')
9 | const underPressure = require('../index')
10 | const { valid, satisfies, coerce } = require('semver')
11 | const sinon = require('sinon')
12 | const proxyquire = require('proxyquire')
13 |
14 | const wait = promisify(setTimeout)
15 | const isSupportedVersion = satisfies(valid(coerce(process.version)), '12.19.0 || >=14.0.0')
16 |
17 | function block (msec) {
18 | const start = Date.now()
19 | /* eslint-disable no-empty */
20 | while (Date.now() - start < msec) { }
21 | }
22 |
23 | let fastify
24 | beforeEach(() => {
25 | fastify = Fastify()
26 | })
27 |
28 | afterEach(async () => {
29 | await fastify.close()
30 | })
31 |
32 | describe('health check', async () => {
33 | test('simple', async () => {
34 | fastify.register(underPressure, {
35 | healthCheck: async () => false,
36 | healthCheckInterval: 1,
37 | pressureHandler: (_req, rep, type, value) => {
38 | assert.equal(type, underPressure.TYPE_HEALTH_CHECK)
39 | assert.equal(value, undefined)
40 | rep.send('B')
41 | }
42 | })
43 |
44 | fastify.get('/', (_req, rep) => rep.send('A'))
45 |
46 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
47 | })
48 |
49 | test('delayed handling with promise success', async () => {
50 | fastify.register(underPressure, {
51 | healthCheck: async () => false,
52 | healthCheckInterval: 1,
53 | pressureHandler: async (_req, rep, _type, _value) => {
54 | await wait(250)
55 | rep.send('B')
56 | }
57 | })
58 |
59 | fastify.get('/', (_req, rep) => rep.send('A'))
60 |
61 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
62 | })
63 |
64 | test('delayed handling with promise error', async () => {
65 | const errorMessage = 'promiseError'
66 |
67 | fastify.register(underPressure, {
68 | healthCheck: async () => false,
69 | healthCheckInterval: 1,
70 | pressureHandler: async (_req, _rep, _type, _value) => {
71 | await wait(250)
72 | throw new Error(errorMessage)
73 | }
74 | })
75 |
76 | fastify.get('/', (_req, rep) => rep.send('A'))
77 |
78 | const response = await fastify.inject().get('/').end()
79 | assert.equal(response.statusCode, 500)
80 | assert.equal(JSON.parse(response.body).message, errorMessage)
81 | })
82 |
83 | test('no handling', async () => {
84 | fastify.register(underPressure, {
85 | healthCheck: async () => false,
86 | healthCheckInterval: 1,
87 | pressureHandler: (_req, _rep, _type, _value) => { }
88 | })
89 |
90 | fastify.get('/', (_req, rep) => rep.send('A'))
91 |
92 | assert.equal((await fastify.inject().get('/').end()).body, 'A')
93 | })
94 |
95 | test('return response', async () => {
96 | fastify.register(underPressure, {
97 | healthCheck: async () => false,
98 | healthCheckInterval: 1,
99 | pressureHandler: (_req, _rep, _type, _value) => 'B'
100 | })
101 |
102 | fastify.get('/', (_req, rep) => rep.send('A'))
103 |
104 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
105 | })
106 |
107 | test('interval reentrance', async () => {
108 | const clock = sinon.useFakeTimers()
109 | after(() => sinon.restore())
110 |
111 | const healthCheckInterval = 500
112 |
113 | const healthCheck = sinon.fake(async () => {
114 | await wait(healthCheckInterval * 2)
115 | return true
116 | })
117 |
118 | fastify.register(underPressure, {
119 | healthCheck,
120 | healthCheckInterval
121 | })
122 |
123 | // not called until fastify has finished initializing
124 | sinon.assert.callCount(healthCheck, 0)
125 |
126 | await fastify.ready()
127 |
128 | // called immediately when registering the plugin
129 | sinon.assert.callCount(healthCheck, 1)
130 |
131 | // wait until next execution
132 | await clock.tickAsync(healthCheckInterval)
133 |
134 | // scheduled by the timer
135 | sinon.assert.callCount(healthCheck, 2)
136 |
137 | await clock.tickAsync(healthCheckInterval)
138 |
139 | // still running the previous invocation
140 | sinon.assert.callCount(healthCheck, 2)
141 |
142 | // wait until the last call resolves and schedules another invocation
143 | await healthCheck.lastCall.returnValue
144 |
145 | await clock.tickAsync(healthCheckInterval)
146 |
147 | // next timer invocation
148 | sinon.assert.callCount(healthCheck, 3)
149 | })
150 | })
151 |
152 | test('event loop delay', { skip: !monitorEventLoopDelay }, (t, done) => {
153 | t.plan(5)
154 | fastify.register(underPressure, {
155 | maxEventLoopDelay: 1,
156 | pressureHandler: (_req, rep, type, value) => {
157 | t.assert.equal(type, underPressure.TYPE_EVENT_LOOP_DELAY)
158 | t.assert.ok(value > 1)
159 | rep.send('B')
160 | }
161 | })
162 |
163 | fastify.get('/', (_req, rep) => rep.send('A'))
164 | fastify.listen({ port: 3000, host: '127.0.0.1' }, async (err, address) => {
165 | t.assert.ifError(err)
166 | fastify.server.unref()
167 |
168 | forkRequest(address, 500, (err, _response, body) => {
169 | t.assert.ifError(err)
170 | t.assert.equal(body, 'B')
171 | done()
172 | })
173 | process.nextTick(() => block(1500))
174 | })
175 | })
176 |
177 | test('heap bytes', (t, done) => {
178 | t.plan(5)
179 | fastify.register(underPressure, {
180 | maxHeapUsedBytes: 1,
181 | pressureHandler: (_req, rep, type, value) => {
182 | t.assert.equal(type, underPressure.TYPE_HEAP_USED_BYTES)
183 | t.assert.ok(value > 1)
184 | rep.send('B')
185 | }
186 | })
187 |
188 | fastify.get('/', (_req, rep) => rep.send('A'))
189 |
190 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
191 | t.assert.ifError(err)
192 | fastify.server.unref()
193 |
194 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, _response, body) => {
195 | t.assert.ifError(err)
196 | t.assert.equal(body.toString(), 'B')
197 | done()
198 | })
199 |
200 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
201 | })
202 | })
203 |
204 | test('rss bytes', (t, done) => {
205 | t.plan(5)
206 | fastify.register(underPressure, {
207 | maxRssBytes: 1,
208 | pressureHandler: (_req, rep, type, value) => {
209 | t.assert.equal(type, underPressure.TYPE_RSS_BYTES)
210 | t.assert.ok(value > 1)
211 | rep.send('B')
212 | }
213 | })
214 |
215 | fastify.get('/', (_req, rep) => rep.send('A'))
216 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
217 | t.assert.ifError(err)
218 | fastify.server.unref()
219 |
220 | forkRequest(address, monitorEventLoopDelay ? 750 : 250, (err, _response, body) => {
221 | t.assert.ifError(err)
222 | t.assert.equal(body.toString(), 'B')
223 | done()
224 | })
225 |
226 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
227 | })
228 | })
229 |
230 | test('event loop utilization', { skip: !isSupportedVersion }, (t, done) => {
231 | t.plan(5)
232 | fastify.register(underPressure, {
233 | maxEventLoopUtilization: 0.01,
234 | pressureHandler: (_req, rep, type, value) => {
235 | t.assert.equal(type, underPressure.TYPE_EVENT_LOOP_UTILIZATION)
236 | t.assert.ok(value > 0.01 && value <= 1)
237 | rep.send('B')
238 | }
239 | })
240 |
241 | fastify.get('/', async (_req, rep) => rep.send('A'))
242 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
243 | t.assert.ifError(err)
244 | fastify.server.unref()
245 |
246 | forkRequest(address, 500, (err, _response, body) => {
247 | t.assert.ifError(err)
248 | t.assert.equal(body.toString(), 'B')
249 | done()
250 | })
251 |
252 | process.nextTick(() => block(1000))
253 | })
254 | })
255 |
256 | test('event loop delay (NaN)', { skip: !isSupportedVersion }, (t, done) => {
257 | t.plan(5)
258 | const mockedPerfHooks = {
259 | monitorEventLoopDelay: () => ({
260 | enable: () => { },
261 | reset: () => { },
262 | mean: NaN,
263 | }),
264 | performance: {
265 | eventLoopUtilization: () => { },
266 | },
267 | }
268 |
269 | const mockedUnderPressure = proxyquire('../index', {
270 | 'node:perf_hooks': mockedPerfHooks,
271 | })
272 |
273 | fastify.register(mockedUnderPressure, {
274 | maxEventLoopDelay: 1000,
275 | pressureHandler: (_req, rep, type, value) => {
276 | t.assert.strictEqual(type, underPressure.TYPE_EVENT_LOOP_DELAY)
277 | t.assert.strictEqual(value, Infinity)
278 | rep.send('B')
279 | },
280 | })
281 |
282 | fastify.get('/', async (_req, rep) => rep.send('A'))
283 |
284 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
285 | t.assert.ifError(err)
286 | fastify.server.unref()
287 |
288 | forkRequest(address, 500, (err, _response, body) => {
289 | t.assert.ifError(err)
290 | t.assert.strictEqual(body.toString(), 'B')
291 | done()
292 | })
293 |
294 | process.nextTick(() => block(1000))
295 | })
296 | })
297 |
298 | describe('pressureHandler on route', async () => {
299 | test('simple', async () => {
300 | await fastify.register(underPressure, {
301 | healthCheck: async () => false,
302 | healthCheckInterval: 1
303 | })
304 |
305 | fastify.get('/', {
306 | config: {
307 | pressureHandler: (_req, rep, type, value) => {
308 | process._rawDebug('pressureHandler')
309 | assert.equal(type, underPressure.TYPE_HEALTH_CHECK)
310 | assert.equal(value, undefined)
311 | rep.send('B')
312 | }
313 | }
314 | }, (_req, rep) => rep.send('A'))
315 |
316 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
317 | })
318 |
319 | test('delayed handling with promise success', async () => {
320 | fastify.register(underPressure, {
321 | healthCheck: async () => false,
322 | healthCheckInterval: 1
323 | })
324 |
325 | fastify.get('/', {
326 | config: {
327 | pressureHandler: async (_req, rep, _type, _value) => {
328 | await wait(250)
329 | rep.send('B')
330 | }
331 | }
332 | }, (_req, rep) => rep.send('A'))
333 |
334 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
335 | })
336 |
337 | test('delayed handling with promise error', async () => {
338 | const errorMessage = 'promiseError'
339 |
340 | fastify.register(underPressure, {
341 | healthCheck: async () => false,
342 | healthCheckInterval: 1
343 | })
344 |
345 | fastify.get('/', {
346 | config: {
347 | pressureHandler: async (_req, _rep, _type, _value) => {
348 | await wait(250)
349 | throw new Error(errorMessage)
350 | }
351 | }
352 | }, (_req, rep) => rep.send('A'))
353 |
354 | const response = await fastify.inject().get('/').end()
355 | assert.equal(response.statusCode, 500)
356 | assert.equal(JSON.parse(response.body).message, errorMessage)
357 | })
358 |
359 | test('no handling', async () => {
360 | fastify.register(underPressure, {
361 | healthCheck: async () => false,
362 | healthCheckInterval: 1
363 | })
364 |
365 | fastify.get('/', {
366 | config: {
367 | pressureHandler: (_req, _rep, _type, _value) => { }
368 | }
369 | }, (_req, rep) => rep.send('A'))
370 |
371 | assert.equal((await fastify.inject().get('/').end()).body, 'A')
372 | })
373 |
374 | test('return response', async () => {
375 | fastify.register(underPressure, {
376 | healthCheck: async () => false,
377 | healthCheckInterval: 1
378 | })
379 |
380 | fastify.get('/', {
381 | config: {
382 | pressureHandler: (_req, _rep, _type, _value) => 'B'
383 | }
384 | }, (_req, rep) => rep.send('A'))
385 |
386 | assert.equal((await fastify.inject().get('/').end()).body, 'B')
387 | })
388 | })
389 |
--------------------------------------------------------------------------------
/test/request.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const promisify = require('node:util').promisify
4 | const wait = promisify(setTimeout)
5 |
6 | const address = process.argv[2]
7 | const delay = parseInt(process.argv[3])
8 |
9 | // custom stringification to avoid circular reference breaking
10 | function stringifyResponse (response) {
11 | return JSON.stringify({
12 | statusCode: response.status,
13 | headers: Object.fromEntries(response.headers)
14 | })
15 | }
16 |
17 | async function run () {
18 | await wait(delay)
19 |
20 | try {
21 | const result = await fetch(address)
22 |
23 | process.send({
24 | error: null,
25 | response: stringifyResponse(result),
26 | body: await result.text()
27 | })
28 |
29 | process.exit()
30 | } catch (result) {
31 | process.send({
32 | error: result.statusText,
33 | response: stringifyResponse(result),
34 | body: ''
35 | })
36 | process.exit(1)
37 | }
38 | }
39 |
40 | run()
41 |
--------------------------------------------------------------------------------
/test/statusRoute.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test } = require('node:test')
4 | const assert = require('node:assert')
5 | const forkRequest = require('./forkRequest')
6 | const Fastify = require('fastify')
7 | const { monitorEventLoopDelay } = require('node:perf_hooks')
8 | const underPressure = require('../index')
9 |
10 | function block (msec) {
11 | const start = Date.now()
12 | /* eslint-disable no-empty */
13 | while (Date.now() - start < msec) {}
14 | }
15 |
16 | test('Expose status route', (t, done) => {
17 | const fastify = Fastify()
18 | fastify.register(underPressure, {
19 | exposeStatusRoute: true,
20 | })
21 |
22 | fastify.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
23 | t.assert.ifError(err)
24 | fastify.server.unref()
25 |
26 | forkRequest(
27 | `${address}/status`,
28 | monitorEventLoopDelay ? 750 : 250,
29 | (err, response, body) => {
30 | t.assert.ifError(err)
31 | t.assert.equal(response.statusCode, 200)
32 | t.assert.deepStrictEqual(JSON.parse(body), { status: 'ok' })
33 | done()
34 | }
35 | )
36 |
37 | process.nextTick(() => block(monitorEventLoopDelay ? 1500 : 500))
38 | })
39 | })
40 |
41 | test('Expose custom status route', (t) => {
42 | const fastify = Fastify()
43 |
44 | fastify.register(underPressure, {
45 | exposeStatusRoute: '/alive',
46 | })
47 |
48 | fastify.inject(
49 | {
50 | url: '/status',
51 | },
52 | (err, response) => {
53 | assert.ifError(err)
54 | assert.equal(response.statusCode, 404)
55 | }
56 | )
57 |
58 | fastify.inject(
59 | {
60 | url: '/alive',
61 | },
62 | (err, response) => {
63 | assert.ifError(err)
64 | assert.equal(response.statusCode, 200)
65 | assert.deepStrictEqual(JSON.parse(response.payload), { status: 'ok' })
66 | }
67 | )
68 | })
69 |
70 | test('Expose status route with additional route options', async () => {
71 | const customConfig = {
72 | customVal: 'someVal',
73 | }
74 | const fastify = Fastify({ exposeHeadRoutes: false })
75 |
76 | fastify.addHook('onRoute', async (routeOptions) => {
77 | fastify.server.unref()
78 | process.nextTick(() => block(500))
79 | assert.equal(routeOptions.url, '/alive')
80 | assert.equal(routeOptions.logLevel, 'silent', 'log level not set')
81 | assert.strictEqual(routeOptions.config, customConfig, 'config not set')
82 | })
83 |
84 | fastify.register(underPressure, {
85 | exposeStatusRoute: {
86 | routeOpts: {
87 | logLevel: 'silent',
88 | config: customConfig,
89 | },
90 | url: '/alive',
91 | },
92 | })
93 |
94 | await fastify.ready()
95 | })
96 |
97 | test('Expose status route with additional route options and default url', async () => {
98 | const fastify = Fastify({ exposeHeadRoutes: false })
99 |
100 | fastify.addHook('onRoute', (routeOptions) => {
101 | fastify.server.unref()
102 | process.nextTick(() => block(500))
103 | assert.equal(routeOptions.url, '/status')
104 | assert.equal(routeOptions.logLevel, 'silent', 'log level not set')
105 | })
106 |
107 | fastify.register(underPressure, {
108 | exposeStatusRoute: {
109 | routeOpts: {
110 | logLevel: 'silent',
111 | },
112 | },
113 | })
114 |
115 | await fastify.ready()
116 | })
117 |
118 | test('Expose status route with additional route options, route schema options', async () => {
119 | const routeSchemaOpts = { hide: true }
120 |
121 | const fastify = Fastify({ exposeHeadRoutes: false })
122 |
123 | fastify.addHook('onRoute', (routeOptions) => {
124 | fastify.server.unref()
125 | process.nextTick(() => block(500))
126 | assert.equal(routeOptions.url, '/alive')
127 | assert.equal(routeOptions.logLevel, 'silent', 'log level not set')
128 | assert.deepStrictEqual(
129 | routeOptions.schema,
130 | Object.assign({}, routeSchemaOpts, {
131 | response: {
132 | 200: {
133 | type: 'object',
134 | description: 'Health Check Succeeded',
135 | properties: {
136 | status: { type: 'string' },
137 | },
138 | example: {
139 | status: 'ok',
140 | },
141 | },
142 | 500: {
143 | type: 'object',
144 | description: 'Error Performing Health Check',
145 | properties: {
146 | message: {
147 | type: 'string',
148 | description: 'Error message for failure during health check',
149 | example: 'Internal Server Error',
150 | },
151 | statusCode: {
152 | type: 'number',
153 | description:
154 | 'Code representing the error. Always matches the HTTP response code.',
155 | example: 500,
156 | },
157 | },
158 | },
159 | 503: {
160 | type: 'object',
161 | description: 'Health Check Failed',
162 | properties: {
163 | code: {
164 | type: 'string',
165 | description: 'Error code associated with the failing check',
166 | example: 'FST_UNDER_PRESSURE',
167 | },
168 | error: {
169 | type: 'string',
170 | description: 'Error thrown during health check',
171 | example: 'Service Unavailable',
172 | },
173 | message: {
174 | type: 'string',
175 | description: 'Error message to explain health check failure',
176 | example: 'Service Unavailable',
177 | },
178 | statusCode: {
179 | type: 'number',
180 | description:
181 | 'Code representing the error. Always matches the HTTP response code.',
182 | example: 503,
183 | },
184 | },
185 | },
186 | },
187 | }),
188 | 'config not set'
189 | )
190 | })
191 |
192 | fastify.register(underPressure, {
193 | exposeStatusRoute: {
194 | routeOpts: {
195 | logLevel: 'silent',
196 | },
197 | routeSchemaOpts,
198 | url: '/alive',
199 | },
200 | })
201 |
202 | await fastify.ready()
203 | })
204 |
205 | test('Expose status route with additional route options, route schema options and default url', async () => {
206 | const routeSchemaOpts = { hide: true }
207 |
208 | const fastify = Fastify({ exposeHeadRoutes: false })
209 |
210 | fastify.addHook('onRoute', (routeOptions) => {
211 | fastify.server.unref()
212 | process.nextTick(() => block(500))
213 | assert.equal(routeOptions.url, '/status')
214 | assert.equal(routeOptions.logLevel, 'silent', 'log level not set')
215 | assert.deepStrictEqual(
216 | routeOptions.schema,
217 | Object.assign({}, routeSchemaOpts, {
218 | response: {
219 | 200: {
220 | type: 'object',
221 | description: 'Health Check Succeeded',
222 | properties: {
223 | status: { type: 'string' },
224 | },
225 | example: {
226 | status: 'ok',
227 | },
228 | },
229 | 500: {
230 | type: 'object',
231 | description: 'Error Performing Health Check',
232 | properties: {
233 | message: {
234 | type: 'string',
235 | description: 'Error message for failure during health check',
236 | example: 'Internal Server Error',
237 | },
238 | statusCode: {
239 | type: 'number',
240 | description:
241 | 'Code representing the error. Always matches the HTTP response code.',
242 | example: 500,
243 | },
244 | },
245 | },
246 | 503: {
247 | type: 'object',
248 | description: 'Health Check Failed',
249 | properties: {
250 | code: {
251 | type: 'string',
252 | description: 'Error code associated with the failing check',
253 | example: 'FST_UNDER_PRESSURE',
254 | },
255 | error: {
256 | type: 'string',
257 | description: 'Error thrown during health check',
258 | example: 'Service Unavailable',
259 | },
260 | message: {
261 | type: 'string',
262 | description: 'Error message to explain health check failure',
263 | example: 'Service Unavailable',
264 | },
265 | statusCode: {
266 | type: 'number',
267 | description:
268 | 'Code representing the error. Always matches the HTTP response code.',
269 | example: 503,
270 | },
271 | },
272 | },
273 | },
274 | }),
275 | 'config not set'
276 | )
277 | })
278 |
279 | fastify.register(underPressure, {
280 | exposeStatusRoute: {
281 | routeOpts: {
282 | logLevel: 'silent',
283 | },
284 | routeSchemaOpts,
285 | },
286 | })
287 |
288 | await fastify.ready()
289 | })
290 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FastifyInstance,
3 | FastifyPluginAsync,
4 | FastifyReply,
5 | FastifyRequest
6 | } from 'fastify'
7 |
8 | declare module 'fastify' {
9 | interface FastifyInstance {
10 | memoryUsage(): { heapUsed: number; rssBytes: number; eventLoopDelay: number; eventLoopUtilized: number };
11 | isUnderPressure(): boolean;
12 | }
13 | }
14 |
15 | interface FastifyUnderPressureExports {
16 | TYPE_EVENT_LOOP_DELAY: 'eventLoopDelay'
17 | TYPE_HEAP_USED_BYTES: 'heapUsedBytes'
18 | TYPE_RSS_BYTES: 'rssBytes'
19 | TYPE_HEALTH_CHECK: 'healthCheck'
20 | TYPE_EVENT_LOOP_UTILIZATION: 'eventLoopUtilization'
21 | }
22 |
23 | type FastifyUnderPressure = FastifyPluginAsync & FastifyUnderPressureExports
24 |
25 | declare namespace fastifyUnderPressure {
26 | export interface FastifyUnderPressureOptions {
27 | maxEventLoopDelay?: number;
28 | maxEventLoopUtilization?: number;
29 | maxHeapUsedBytes?: number;
30 | maxRssBytes?: number;
31 | message?: string;
32 | retryAfter?: number;
33 | healthCheck?: (fastify: FastifyInstance) => Promise | boolean>;
34 | healthCheckInterval?: number;
35 | pressureHandler?: (request: FastifyRequest, reply: FastifyReply, type: string, value: number | undefined) => Promise | void;
36 | sampleInterval?: number;
37 | exposeStatusRoute?: boolean | string | { routeOpts: object; routeSchemaOpts?: object; routeResponseSchemaOpts?: object; url?: string };
38 | customError?: Error | (new () => Error);
39 | }
40 |
41 | export const TYPE_EVENT_LOOP_DELAY = 'eventLoopDelay'
42 | export const TYPE_HEAP_USED_BYTES = 'heapUsedBytes'
43 | export const TYPE_RSS_BYTES = 'rssBytes'
44 | export const TYPE_HEALTH_CHECK = 'healthCheck'
45 | export const TYPE_EVENT_LOOP_UTILIZATION = 'eventLoopUtilization'
46 |
47 | export const fastifyUnderPressure: FastifyUnderPressure
48 | export { fastifyUnderPressure as default }
49 | }
50 |
51 | declare function fastifyUnderPressure (...params: Parameters): ReturnType
52 | export = fastifyUnderPressure
53 |
--------------------------------------------------------------------------------
/types/index.test-d.ts:
--------------------------------------------------------------------------------
1 | import fastifyUnderPressure, { fastifyUnderPressure as namedFastifyUnderPressure, TYPE_EVENT_LOOP_DELAY, TYPE_EVENT_LOOP_UTILIZATION, TYPE_HEALTH_CHECK, TYPE_HEAP_USED_BYTES, TYPE_RSS_BYTES } from '..'
2 | import fastify from 'fastify'
3 | import { expectType } from 'tsd'
4 |
5 | {
6 | const server = fastify()
7 | server.register(fastifyUnderPressure, {
8 | maxEventLoopDelay: 1000,
9 | maxHeapUsedBytes: 100000000,
10 | maxRssBytes: 100000000
11 | })
12 |
13 | server.register(fastifyUnderPressure)
14 |
15 | server.get('/', (_req, reply) => {
16 | reply.send({ hello: 'world', underPressure: server.isUnderPressure() })
17 | })
18 |
19 | server.listen({ port: 3000 }, err => {
20 | if (err) throw err
21 | })
22 | };
23 |
24 | {
25 | const server = fastify()
26 | server.register(fastifyUnderPressure, {
27 | maxEventLoopDelay: 1000,
28 | message: 'Under pressure!',
29 | retryAfter: 50
30 | })
31 | };
32 |
33 | {
34 | const server = fastify()
35 | const memoryUsage = server.memoryUsage()
36 | console.log(memoryUsage.heapUsed)
37 | console.log(memoryUsage.rssBytes)
38 | console.log(memoryUsage.eventLoopDelay)
39 | };
40 |
41 | {
42 | const server = fastify()
43 | server.register(fastifyUnderPressure, {
44 | healthCheck: async function (fastifyInstance) {
45 | // do some magic to check if your db connection is healthy, etc...
46 | return fastifyInstance.register === server.register
47 | },
48 | healthCheckInterval: 500
49 | })
50 | };
51 |
52 | {
53 | const server = fastify()
54 | server.register(fastifyUnderPressure, {
55 | sampleInterval: 10
56 | })
57 | }
58 |
59 | {
60 | const server = fastify()
61 | server.register(fastifyUnderPressure, {
62 | exposeStatusRoute: '/v2/status',
63 | })
64 |
65 | server.register(fastifyUnderPressure, {
66 | exposeStatusRoute: true
67 | })
68 |
69 | server.register(fastifyUnderPressure, {
70 | exposeStatusRoute: {
71 | routeOpts: {
72 | logLevel: 'silent',
73 | config: {}
74 | },
75 | url: '/alive'
76 | }
77 | })
78 |
79 | server.register(fastifyUnderPressure, {
80 | exposeStatusRoute: {
81 | routeOpts: {
82 | logLevel: 'silent'
83 | }
84 | }
85 | })
86 |
87 | server.register(fastifyUnderPressure, {
88 | exposeStatusRoute: {
89 | routeOpts: {
90 | logLevel: 'silent'
91 | },
92 | routeSchemaOpts: {
93 | hide: true
94 | }
95 | }
96 | })
97 |
98 | server.register(fastifyUnderPressure, {
99 | customError: new Error('custom error message')
100 | })
101 |
102 | class CustomError extends Error {
103 | constructor () {
104 | super('Custom error message')
105 | Error.captureStackTrace(this, CustomError)
106 | }
107 | }
108 |
109 | server.register(fastifyUnderPressure, {
110 | customError: CustomError
111 | })
112 | }
113 |
114 | expectType<'eventLoopDelay'>(fastifyUnderPressure.TYPE_EVENT_LOOP_DELAY)
115 | expectType<'heapUsedBytes'>(fastifyUnderPressure.TYPE_HEAP_USED_BYTES)
116 | expectType<'rssBytes'>(fastifyUnderPressure.TYPE_RSS_BYTES)
117 | expectType<'healthCheck'>(fastifyUnderPressure.TYPE_HEALTH_CHECK)
118 | expectType<'eventLoopUtilization'>(fastifyUnderPressure.TYPE_EVENT_LOOP_UTILIZATION)
119 |
120 | expectType<'eventLoopDelay'>(namedFastifyUnderPressure.TYPE_EVENT_LOOP_DELAY)
121 | expectType<'heapUsedBytes'>(namedFastifyUnderPressure.TYPE_HEAP_USED_BYTES)
122 | expectType<'rssBytes'>(namedFastifyUnderPressure.TYPE_RSS_BYTES)
123 | expectType<'healthCheck'>(namedFastifyUnderPressure.TYPE_HEALTH_CHECK)
124 | expectType<'eventLoopUtilization'>(namedFastifyUnderPressure.TYPE_EVENT_LOOP_UTILIZATION)
125 |
126 | expectType<'eventLoopDelay'>(TYPE_EVENT_LOOP_DELAY)
127 | expectType<'heapUsedBytes'>(TYPE_HEAP_USED_BYTES)
128 | expectType<'rssBytes'>(TYPE_RSS_BYTES)
129 | expectType<'healthCheck'>(TYPE_HEALTH_CHECK)
130 | expectType<'eventLoopUtilization'>(TYPE_EVENT_LOOP_UTILIZATION)
131 |
--------------------------------------------------------------------------------