@fastify/under-pressure
Version:
Process load measuring plugin for Fastify, with automatic handling of 'Service Unavailable'
301 lines (253 loc) • 9.53 kB
JavaScript
'use strict'
const fe = require('@fastify/error')
const fp = require('fastify-plugin')
const assert = require('node:assert')
const { monitorEventLoopDelay } = require('node:perf_hooks')
const { eventLoopUtilization } = require('node:perf_hooks').performance
const SERVICE_UNAVAILABLE = 503
const createError = (msg = 'Service Unavailable') => fe('FST_UNDER_PRESSURE', msg, SERVICE_UNAVAILABLE)
const TYPE_EVENT_LOOP_DELAY = 'eventLoopDelay'
const TYPE_HEAP_USED_BYTES = 'heapUsedBytes'
const TYPE_RSS_BYTES = 'rssBytes'
const TYPE_HEALTH_CHECK = 'healthCheck'
const TYPE_EVENT_LOOP_UTILIZATION = 'eventLoopUtilization'
function getSampleInterval (value, eventLoopResolution) {
const sampleInterval = value || 1000
return Math.max(eventLoopResolution, sampleInterval)
}
async function fastifyUnderPressure (fastify, opts = {}) {
const resolution = 10
const sampleInterval = getSampleInterval(opts.sampleInterval, resolution)
const maxEventLoopDelay = opts.maxEventLoopDelay || 0
const maxHeapUsedBytes = opts.maxHeapUsedBytes || 0
const maxRssBytes = opts.maxRssBytes || 0
const healthCheck = opts.healthCheck || false
const healthCheckInterval = opts.healthCheckInterval || -1
const UnderPressureError = opts.customError || createError(opts.message)
const maxEventLoopUtilization = opts.maxEventLoopUtilization || 0
const pressureHandler = opts.pressureHandler
const checkMaxEventLoopDelay = maxEventLoopDelay > 0
const checkMaxHeapUsedBytes = maxHeapUsedBytes > 0
const checkMaxRssBytes = maxRssBytes > 0
const checkMaxEventLoopUtilization = maxEventLoopUtilization > 0
let heapUsed = 0
let rssBytes = 0
let eventLoopDelay = 0
let elu
let eventLoopUtilized = 0
const histogram = monitorEventLoopDelay({ resolution })
histogram.enable()
if (eventLoopUtilization) {
elu = eventLoopUtilization()
}
fastify.decorate('memoryUsage', memoryUsage)
fastify.decorate('isUnderPressure', isUnderPressure)
const timer = setTimeout(beginMemoryUsageUpdate, sampleInterval)
timer.unref()
let externalsHealthy = false
let externalHealthCheckTimer
if (healthCheck) {
assert(typeof healthCheck === 'function', 'opts.healthCheck should be a function that returns a promise that resolves to true or false')
assert(healthCheckInterval > 0 || opts.exposeStatusRoute, 'opts.healthCheck requires opts.healthCheckInterval or opts.exposeStatusRoute')
const doCheck = async () => {
try {
externalsHealthy = await healthCheck(fastify)
} catch (error) {
externalsHealthy = false
fastify.log.error({ error }, 'external healthCheck function supplied to `under-pressure` threw an error. setting the service status to unhealthy.')
}
}
await doCheck()
if (healthCheckInterval > 0) {
const beginCheck = async () => {
await doCheck()
externalHealthCheckTimer.refresh()
}
externalHealthCheckTimer = setTimeout(beginCheck, healthCheckInterval)
externalHealthCheckTimer.unref()
}
} else {
externalsHealthy = true
}
fastify.addHook('onClose', onClose)
opts.exposeStatusRoute = mapExposeStatusRoute(opts.exposeStatusRoute)
if (opts.exposeStatusRoute) {
fastify.route({
...opts.exposeStatusRoute.routeOpts,
url: opts.exposeStatusRoute.url,
method: 'GET',
schema: Object.assign({}, opts.exposeStatusRoute.routeSchemaOpts, {
response: {
200: {
type: 'object',
description: 'Health Check Succeeded',
properties: Object.assign(
{ status: { type: 'string' } },
opts.exposeStatusRoute.routeResponseSchemaOpts
),
example: {
status: 'ok'
}
},
500: {
type: 'object',
description: 'Error Performing Health Check',
properties: {
message: { type: 'string', description: 'Error message for failure during health check', example: 'Internal Server Error' },
statusCode: { type: 'number', description: 'Code representing the error. Always matches the HTTP response code.', example: 500 }
}
},
503: {
type: 'object',
description: 'Health Check Failed',
properties: {
code: { type: 'string', description: 'Error code associated with the failing check', example: 'FST_UNDER_PRESSURE' },
error: { type: 'string', description: 'Error thrown during health check', example: 'Service Unavailable' },
message: { type: 'string', description: 'Error message to explain health check failure', example: 'Service Unavailable' },
statusCode: { type: 'number', description: 'Code representing the error. Always matches the HTTP response code.', example: 503 }
}
}
}
}),
handler: onStatus
})
}
if (checkMaxEventLoopUtilization === false && checkMaxEventLoopDelay === false &&
checkMaxHeapUsedBytes === false &&
checkMaxRssBytes === false &&
healthCheck === false) {
return
}
const underPressureError = new UnderPressureError()
const retryAfter = opts.retryAfter || 10
fastify.addHook('onRequest', onRequest)
function mapExposeStatusRoute (opts) {
if (!opts) {
return false
}
if (typeof opts === 'string') {
return { url: opts }
}
return Object.assign({ url: '/status' }, opts)
}
function updateEventLoopDelay () {
eventLoopDelay = Math.max(0, histogram.mean / 1e6 - resolution)
if (Number.isNaN(eventLoopDelay)) eventLoopDelay = Infinity
histogram.reset()
}
function updateEventLoopUtilization () {
if (elu) {
eventLoopUtilized = eventLoopUtilization(elu).utilization
} else {
eventLoopUtilized = 0
}
}
function beginMemoryUsageUpdate () {
updateMemoryUsage()
timer.refresh()
}
function updateMemoryUsage () {
const mem = process.memoryUsage()
heapUsed = mem.heapUsed
rssBytes = mem.rss
updateEventLoopDelay()
updateEventLoopUtilization()
}
function isUnderPressure () {
if (checkMaxEventLoopDelay && eventLoopDelay > maxEventLoopDelay) {
return true
}
if (checkMaxHeapUsedBytes && heapUsed > maxHeapUsedBytes) {
return true
}
if (checkMaxRssBytes && rssBytes > maxRssBytes) {
return true
}
if (!externalsHealthy) {
return true
}
return checkMaxEventLoopUtilization && eventLoopUtilized > maxEventLoopUtilization
}
function onRequest (req, reply, next) {
const _pressureHandler = req.routeOptions.config.pressureHandler || pressureHandler
if (checkMaxEventLoopDelay && eventLoopDelay > maxEventLoopDelay) {
handlePressure(_pressureHandler, req, reply, next, TYPE_EVENT_LOOP_DELAY, eventLoopDelay)
return
}
if (checkMaxHeapUsedBytes && heapUsed > maxHeapUsedBytes) {
handlePressure(_pressureHandler, req, reply, next, TYPE_HEAP_USED_BYTES, heapUsed)
return
}
if (checkMaxRssBytes && rssBytes > maxRssBytes) {
handlePressure(_pressureHandler, req, reply, next, TYPE_RSS_BYTES, rssBytes)
return
}
if (!externalsHealthy) {
handlePressure(_pressureHandler, req, reply, next, TYPE_HEALTH_CHECK, undefined)
return
}
if (checkMaxEventLoopUtilization && eventLoopUtilized > maxEventLoopUtilization) {
handlePressure(_pressureHandler, req, reply, next, TYPE_EVENT_LOOP_UTILIZATION, eventLoopUtilized)
return
}
next()
}
function handlePressure (pressureHandler, req, reply, next, type, value) {
if (typeof pressureHandler === 'function') {
const result = pressureHandler(req, reply, type, value)
if (result instanceof Promise) {
result.then(() => next(), next)
} else if (result == null) {
next()
} else {
reply.send(result)
}
} else {
reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
next(underPressureError)
}
}
function memoryUsage () {
return {
eventLoopDelay,
rssBytes,
heapUsed,
eventLoopUtilized
}
}
async function onStatus (req, reply) {
const okResponse = { status: 'ok' }
if (healthCheck) {
try {
const checkResult = await healthCheck(fastify)
if (!checkResult) {
req.log.error('external health check failed')
reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
throw underPressureError
}
return Object.assign(okResponse, checkResult)
} catch (err) {
req.log.error({ err }, 'external health check failed with error')
reply.status(SERVICE_UNAVAILABLE).header('Retry-After', retryAfter)
throw underPressureError
}
}
return okResponse
}
function onClose (_fastify, done) {
clearTimeout(timer)
clearTimeout(externalHealthCheckTimer)
done()
}
}
module.exports = fp(fastifyUnderPressure, {
fastify: '5.x',
name: '@fastify/under-pressure'
})
module.exports.default = fastifyUnderPressure
module.exports.fastifyUnderPressure = fastifyUnderPressure
module.exports.TYPE_EVENT_LOOP_DELAY = TYPE_EVENT_LOOP_DELAY
module.exports.TYPE_EVENT_LOOP_UTILIZATION = TYPE_EVENT_LOOP_UTILIZATION
module.exports.TYPE_HEALTH_CHECK = TYPE_HEALTH_CHECK
module.exports.TYPE_HEAP_USED_BYTES = TYPE_HEAP_USED_BYTES
module.exports.TYPE_RSS_BYTES = TYPE_RSS_BYTES