├── .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 | [![CI](https://github.com/fastify/under-pressure/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/under-pressure/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/under-pressure.svg?style=flat)](https://www.npmjs.com/package/@fastify/under-pressure) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](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 | --------------------------------------------------------------------------------