├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── examples └── example.js ├── index.js ├── package.json ├── test └── index.test.js └── types ├── index.d.ts └── index.test-d.ts /.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) 2018 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 | 2 | 3 | # @fastify/circuit-breaker 4 | 5 | [![CI](https://github.com/fastify/fastify-circuit-breaker/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-circuit-breaker/actions/workflows/ci.yml) 6 | [![NPM version](https://img.shields.io/npm/v/@fastify/circuit-breaker.svg?style=flat)](https://www.npmjs.com/package/@fastify/circuit-breaker) 7 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 8 | 9 | A low overhead [circuit breaker](https://martinfowler.com/bliki/CircuitBreaker.html) for your routes. 10 | 11 | ## Install 12 | ``` 13 | npm i @fastify/circuit-breaker 14 | ``` 15 | 16 | ### Compatibility 17 | | Plugin version | Fastify version | 18 | | ---------------|-----------------| 19 | | `>=4.x` | `^5.x` | 20 | | `^3.x` | `^4.x` | 21 | | `>=1.x <3.x` | `^3.x` | 22 | | `^0.x` | `^2.x` | 23 | | `^0.x` | `^1.x` | 24 | 25 | 26 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin 27 | in the table above. 28 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details. 29 | 30 | ## Usage 31 | Register the plugin and, if needed, pass it custom options.
32 | This plugin will add an `onSend` hook and expose a `circuitBreaker` utility.
33 | Call `fastify.circuitBreaker()` when declaring the `preHandler` option of a route, in this way you will put that very specific route under the *circuit breaking* check. 34 | ```js 35 | const fastify = require('fastify')() 36 | 37 | fastify.register(require('@fastify/circuit-breaker')) 38 | 39 | fastify.register(function (instance, opts, next) { 40 | instance.route({ 41 | method: 'GET', 42 | url: '/', 43 | schema: { 44 | querystring: { 45 | error: { type: 'boolean' }, 46 | delay: { type: 'number' } 47 | } 48 | }, 49 | preHandler: instance.circuitBreaker(), 50 | handler: function (req, reply) { 51 | setTimeout(() => { 52 | reply.send( 53 | req.query.error ? new Error('kaboom') : { hello: 'world' } 54 | ) 55 | }, req.query.delay || 0) 56 | } 57 | }) 58 | next() 59 | }) 60 | 61 | fastify.listen({ port: 3000 }, err => { 62 | if (err) throw err 63 | console.log('Server listening at http://localhost:3000') 64 | }) 65 | ``` 66 | 67 | ### Options 68 | You can pass the following options during the plugin registration, this way the values will be used in all routes. 69 | ```js 70 | fastify.register(require('@fastify/circuit-breaker'), { 71 | threshold: 3, // default 5 72 | timeout: 5000, // default 10000 73 | resetTimeout: 5000, // default 10000 74 | onCircuitOpen: async (req, reply) => { 75 | reply.statusCode = 500 76 | throw new Error('a custom error') 77 | }, 78 | onTimeout: async (req, reply) => { 79 | reply.statusCode = 504 80 | return 'timed out' 81 | } 82 | }) 83 | ``` 84 | - `threshold`: the maximum number of failures accepted before opening the circuit. 85 | - `timeout:` the maximum number of milliseconds you can wait before returning a `TimeoutError`. 86 | - `resetTimeout`: number of milliseconds before the circuit will move from `open` to `half-open` 87 | - `onCircuitOpen`: async function that gets called when the circuit is `open` due to errors. It can modify the reply and return a `string` | `Buffer` | `Stream` payload. If an `Error` is thrown it will be routed to your error handler. 88 | - `onTimeout`: async function that gets called when the circuit is `open` due to timeouts. It can modify the reply and return a `string` | `Buffer` | `Stream` | `Error` payload. If an `Error` is thrown it will be routed to your error handler. 89 | 90 | Otherwise, you can customize every single route by passing the same options to the `circuitBreaker` utility: 91 | ```js 92 | fastify.circuitBreaker({ 93 | threshold: 3, // default 5 94 | timeout: 5000, // default 10000 95 | resetTimeout: 5000 // default 10000 96 | }) 97 | ``` 98 | If you pass the options directly to the utility, it will take precedence over the global configuration. 99 | 100 | ### Customize error messages 101 | If needed you can change the default error message for the *circuit open error* and the *timeout error*: 102 | ```js 103 | fastify.register(require('@fastify/circuit-breaker'), { 104 | timeoutErrorMessage: 'Ronf...', // default 'Timeout' 105 | circuitOpenErrorMessage: 'Oh gosh!' // default 'Circuit open' 106 | }) 107 | ``` 108 | 109 | ## Caveats 110 | Since it is not possible to apply the classic timeout feature of the pattern, in this case the timeout will measure the time that the route takes to execute and **once the route has finished** if the time taken is higher than the timeout it will return an error, even if the route has produced a successful response. 111 | 112 | If you need a classic circuit breaker to wrap around an API call consider using [`easy-breaker`](https://github.com/delvedor/easy-breaker). 113 | 114 | ## Acknowledgments 115 | Image courtesy of [Martin Fowler](https://martinfowler.com/bliki/CircuitBreaker.html). 116 | 117 | 118 | ## License 119 | 120 | Licensed under [MIT](./LICENSE). 121 | 122 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')() 4 | 5 | fastify.register(require('..'), { 6 | threshold: 3, 7 | timeout: 5000, 8 | resetTimeout: 5000 9 | }) 10 | 11 | fastify.register(function (instance, _opts, next) { 12 | instance.route({ 13 | method: 'GET', 14 | url: '/', 15 | schema: { 16 | querystring: { 17 | error: { type: 'boolean' }, 18 | delay: { type: 'number' } 19 | } 20 | }, 21 | beforeHandler: fastify.circuitBreaker(), 22 | handler: function (req, reply) { 23 | setTimeout(() => { 24 | reply.send( 25 | req.query.error ? new Error('kaboom') : { hello: 'world' } 26 | ) 27 | }, req.query.delay || 0) 28 | } 29 | }) 30 | next() 31 | }) 32 | 33 | fastify.listen({ port: 3000 }, err => { 34 | if (err) throw err 35 | console.log('Server listening at http://localhost:3000') 36 | }) 37 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const { FifoObject } = require('toad-cache') 5 | const createError = require('@fastify/error') 6 | 7 | const OPEN = Symbol('open') 8 | const HALFOPEN = Symbol('half-open') 9 | const CLOSE = Symbol('close') 10 | 11 | function fastifyCircuitBreaker (fastify, opts, next) { 12 | opts = opts || {} 13 | const timeout = opts.timeout || 1000 * 10 14 | const resetTimeout = opts.resetTimeout || 1000 * 10 15 | const threshold = opts.threshold || 5 16 | const timeoutErrorMessage = opts.timeoutErrorMessage || 'Timeout' 17 | const circuitOpenErrorMessage = opts.circuitOpenErrorMessage || 'Circuit open' 18 | const onCircuitOpen = opts.onCircuitOpen 19 | const onTimeout = opts.onTimeout 20 | const cache = new FifoObject(opts.cache || 500) 21 | 22 | let routeId = 0 23 | 24 | fastify.decorateRequest('_cbTime', 0) 25 | fastify.decorateRequest('_cbIsOpen', false) 26 | fastify.decorateRequest('_cbRouteId', 0) 27 | fastify.decorate('circuitBreaker', circuitBreaker) 28 | fastify.addHook('onSend', onSend) 29 | 30 | next() 31 | 32 | function circuitBreaker (opts) { 33 | opts = opts || {} 34 | const thisRouteId = ++routeId 35 | cache.set(thisRouteId, { 36 | status: CLOSE, 37 | failures: 0, 38 | currentlyRunningRequest: 0, 39 | isResetTimerRunning: false, 40 | threshold: opts.threshold || threshold, 41 | timeout: opts.timeout || timeout, 42 | resetTimeout: opts.resetTimeout || resetTimeout, 43 | onCircuitOpen: opts.onCircuitOpen || onCircuitOpen, 44 | onTimeout: opts.onTimeout || onTimeout 45 | }) 46 | return async function beforeHandler (req, reply) { 47 | const route = cache.get(thisRouteId) 48 | if (route.status === OPEN) { 49 | req._cbIsOpen = true 50 | if (route.onCircuitOpen) { 51 | const errorPayload = await route.onCircuitOpen(req, reply) 52 | return reply.send(errorPayload) 53 | } 54 | 55 | return reply.send(new CircuitOpenError()) 56 | } 57 | 58 | if (route.status === HALFOPEN && route.currentlyRunningRequest >= 1) { 59 | req._cbIsOpen = true 60 | if (route.onCircuitOpen) { 61 | const errorPayload = await route.onCircuitOpen(req, reply) 62 | return reply.send(errorPayload) 63 | } 64 | 65 | throw new CircuitOpenError() 66 | } 67 | 68 | route.currentlyRunningRequest++ 69 | req._cbRouteId = thisRouteId 70 | req._cbTime = getTime() 71 | } 72 | } 73 | 74 | async function onSend (req, reply, _payload) { 75 | if (req._cbRouteId === 0 || req._cbIsOpen === true) { 76 | return 77 | } 78 | const route = cache.get(req._cbRouteId) 79 | route.currentlyRunningRequest-- 80 | 81 | if (getTime() - req._cbTime > route.timeout) { 82 | route.failures++ 83 | if (route.failures >= route.threshold) { 84 | route.status = OPEN 85 | runTimer(req._cbRouteId) 86 | } 87 | if (route.onTimeout) { 88 | const errorPayload = await route.onTimeout(req, reply) 89 | return errorPayload 90 | } 91 | 92 | const err = new TimeoutError() 93 | reply.code(err.statusCode) 94 | throw err 95 | } 96 | 97 | if (reply.raw.statusCode < 500) { 98 | route.status = CLOSE 99 | route.failures = 0 100 | return 101 | } 102 | 103 | route.failures++ 104 | if (route.status === HALFOPEN) { 105 | route.status = OPEN 106 | runTimer(req._cbRouteId) 107 | return 108 | } 109 | 110 | if (route.failures >= route.threshold) { 111 | route.status = OPEN 112 | runTimer(req._cbRouteId) 113 | if (route.onCircuitOpen) { 114 | const errorPayload = await route.onCircuitOpen(req, reply) 115 | return errorPayload 116 | } 117 | 118 | const err = new CircuitOpenError() 119 | reply.code(err.statusCode) 120 | throw err 121 | } 122 | } 123 | 124 | function runTimer (routeId) { 125 | const route = cache.get(routeId) 126 | if (route.isResetTimerRunning === true) return 127 | route.isResetTimerRunning = true 128 | setTimeout(() => { 129 | route.isResetTimerRunning = false 130 | route.status = HALFOPEN 131 | }, route.resetTimeout) 132 | } 133 | 134 | const TimeoutError = createError( 135 | 'FST_ERR_CIRCUIT_BREAKER_TIMEOUT', 136 | timeoutErrorMessage, 137 | 503 138 | ) 139 | 140 | const CircuitOpenError = createError( 141 | 'FST_ERR_CIRCUIT_BREAKER_OPEN', 142 | circuitOpenErrorMessage, 143 | 503 144 | ) 145 | 146 | function getTime () { 147 | const ts = process.hrtime() 148 | return (ts[0] * 1e3) + (ts[1] / 1e6) 149 | } 150 | } 151 | 152 | module.exports = fp(fastifyCircuitBreaker, { 153 | fastify: '5.x', 154 | name: '@fastify/circuit-breaker' 155 | }) 156 | module.exports.default = fastifyCircuitBreaker 157 | module.exports.fastifyCircuitBreaker = fastifyCircuitBreaker 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/circuit-breaker", 3 | "version": "4.0.2", 4 | "description": "A low overhead circuit breaker for your routes", 5 | "main": "index.js", 6 | "types": "types/index.d.ts", 7 | "type": "commonjs", 8 | "scripts": { 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix", 11 | "test": "npm run test:unit && npm run test:typescript", 12 | "test:unit": "c8 -100 node --test", 13 | "test:typescript": "tsd" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/fastify/fastify-circuit-breaker.git" 18 | }, 19 | "keywords": [ 20 | "fastify", 21 | "circuit breaker", 22 | "circuit", 23 | "breaker", 24 | "overhead", 25 | "speed" 26 | ], 27 | "author": "Tomas Della Vedova - @delvedor (http://delved.org)", 28 | "contributors": [ 29 | { 30 | "name": "Matteo Collina", 31 | "email": "hello@matteocollina.com" 32 | }, 33 | { 34 | "name": "Manuel Spigolon", 35 | "email": "behemoth89@gmail.com" 36 | }, 37 | { 38 | "name": "Aras Abbasi", 39 | "email": "aras.abbasi@gmail.com" 40 | }, 41 | { 42 | "name": "Frazer Smith", 43 | "email": "frazer.dev@icloud.com", 44 | "url": "https://github.com/fdawgs" 45 | } 46 | ], 47 | "license": "MIT", 48 | "devDependencies": { 49 | "@fastify/pre-commit": "^2.1.0", 50 | "@types/node": "^22.0.0", 51 | "c8": "^10.1.2", 52 | "eslint": "^9.17.0", 53 | "fastify": "^5.0.0", 54 | "neostandard": "^0.12.0", 55 | "tsd": "^0.32.0" 56 | }, 57 | "dependencies": { 58 | "@fastify/error": "^4.0.0", 59 | "fastify-plugin": "^5.0.0", 60 | "toad-cache": "^3.7.0" 61 | }, 62 | "bugs": { 63 | "url": "https://github.com/fastify/fastify-circuit-breaker/issues" 64 | }, 65 | "homepage": "https://github.com/fastify/fastify-circuit-breaker#readme", 66 | "funding": [ 67 | { 68 | "type": "github", 69 | "url": "https://github.com/sponsors/fastify" 70 | }, 71 | { 72 | "type": "opencollective", 73 | "url": "https://opencollective.com/fastify" 74 | } 75 | ], 76 | "pre-commit": [ 77 | "lint", 78 | "test" 79 | ], 80 | "publishConfig": { 81 | "access": "public" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const circuitBreaker = require('..') 6 | const { setTimeout: sleep } = require('timers/promises') 7 | 8 | const opts = { 9 | schema: { 10 | querystring: { 11 | type: 'object', 12 | properties: { 13 | error: { type: 'boolean' }, 14 | delay: { type: 'integer' } 15 | } 16 | } 17 | } 18 | } 19 | 20 | test('Should respond with a 503 once the threshold has been reached', async t => { 21 | t.plan(12) 22 | 23 | const fastify = Fastify() 24 | await fastify.register(circuitBreaker, { 25 | threshold: 3, 26 | timeout: 1000, 27 | resetTimeout: 1000 28 | }) 29 | 30 | fastify.after(() => { 31 | opts.preHandler = fastify.circuitBreaker() 32 | fastify.get('/', opts, (req, reply) => { 33 | t.assert.strictEqual(typeof req._cbTime, 'number') 34 | setTimeout(() => { 35 | reply.send( 36 | req.query.error ? new Error('kaboom') : { hello: 'world' } 37 | ) 38 | }, req.query.delay || 0) 39 | }) 40 | }) 41 | 42 | let res = await fastify.inject('/?error=true') 43 | t.assert.ok(res) 44 | t.assert.strictEqual(res.statusCode, 500) 45 | t.assert.deepStrictEqual({ 46 | error: 'Internal Server Error', 47 | message: 'kaboom', 48 | statusCode: 500 49 | }, JSON.parse(res.payload)) 50 | 51 | res = await fastify.inject('/?error=true') 52 | t.assert.ok(res) 53 | t.assert.strictEqual(res.statusCode, 500) 54 | t.assert.deepStrictEqual({ 55 | error: 'Internal Server Error', 56 | message: 'kaboom', 57 | statusCode: 500 58 | }, JSON.parse(res.payload)) 59 | 60 | res = await fastify.inject('/?error=true') 61 | t.assert.ok(res) 62 | t.assert.strictEqual(res.statusCode, 503) 63 | t.assert.deepStrictEqual({ 64 | error: 'Service Unavailable', 65 | message: 'Circuit open', 66 | statusCode: 503, 67 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 68 | }, JSON.parse(res.payload)) 69 | }) 70 | 71 | test('Should respond with a 503 once the threshold has been reached (timeout)', async t => { 72 | t.plan(15) 73 | 74 | const fastify = Fastify() 75 | fastify.register(circuitBreaker, { 76 | threshold: 3, 77 | timeout: 50, 78 | resetTimeout: 1000 79 | }) 80 | 81 | fastify.after(() => { 82 | opts.preHandler = fastify.circuitBreaker() 83 | fastify.get('/', opts, (req, reply) => { 84 | t.assert.strictEqual(typeof req._cbTime, 'number') 85 | setTimeout(() => { 86 | reply.send( 87 | req.query.error ? new Error('kaboom') : { hello: 'world' } 88 | ) 89 | }, req.query.delay || 0) 90 | }) 91 | }) 92 | 93 | fastify.inject('/?error=false&delay=100', (err, res) => { 94 | t.assert.ifError(err) 95 | t.assert.strictEqual(res.statusCode, 503) 96 | t.assert.deepStrictEqual({ 97 | error: 'Service Unavailable', 98 | message: 'Timeout', 99 | statusCode: 503, 100 | code: 'FST_ERR_CIRCUIT_BREAKER_TIMEOUT' 101 | }, JSON.parse(res.payload)) 102 | }) 103 | 104 | fastify.inject('/?error=false&delay=100', (err, res) => { 105 | t.assert.ifError(err) 106 | t.assert.strictEqual(res.statusCode, 503) 107 | t.assert.deepStrictEqual({ 108 | error: 'Service Unavailable', 109 | message: 'Timeout', 110 | statusCode: 503, 111 | code: 'FST_ERR_CIRCUIT_BREAKER_TIMEOUT' 112 | }, JSON.parse(res.payload)) 113 | }) 114 | 115 | fastify.inject('/?error=false&delay=100', (err, res) => { 116 | t.assert.ifError(err) 117 | t.assert.strictEqual(res.statusCode, 503) 118 | t.assert.deepStrictEqual({ 119 | error: 'Service Unavailable', 120 | message: 'Timeout', 121 | statusCode: 503, 122 | code: 'FST_ERR_CIRCUIT_BREAKER_TIMEOUT' 123 | }, JSON.parse(res.payload)) 124 | }) 125 | 126 | setTimeout(() => { 127 | fastify.inject('/?error=false&delay=100', (err, res) => { 128 | t.assert.ifError(err) 129 | t.assert.strictEqual(res.statusCode, 503) 130 | t.assert.deepStrictEqual({ 131 | error: 'Service Unavailable', 132 | message: 'Circuit open', 133 | statusCode: 503, 134 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 135 | }, JSON.parse(res.payload)) 136 | }) 137 | }, 200) 138 | 139 | await sleep(200) 140 | }) 141 | 142 | test('Should return 503 until the circuit is open', async t => { 143 | t.plan(12) 144 | 145 | const fastify = Fastify() 146 | await fastify.register(circuitBreaker, { 147 | threshold: 2, 148 | timeout: 1000, 149 | resetTimeout: 500 150 | }) 151 | 152 | fastify.after(() => { 153 | opts.preHandler = fastify.circuitBreaker() 154 | fastify.get('/', opts, (req, reply) => { 155 | t.assert.strictEqual(typeof req._cbTime, 'number') 156 | setTimeout(() => { 157 | reply.send( 158 | req.query.error ? new Error('kaboom') : { hello: 'world' } 159 | ) 160 | }, req.query.delay || 0) 161 | }) 162 | }) 163 | 164 | let res = await fastify.inject('/?error=true') 165 | t.assert.ok(res) 166 | t.assert.strictEqual(res.statusCode, 500) 167 | t.assert.deepStrictEqual({ 168 | error: 'Internal Server Error', 169 | message: 'kaboom', 170 | statusCode: 500 171 | }, JSON.parse(res.payload)) 172 | 173 | res = await fastify.inject('/?error=true') 174 | t.assert.ok(res) 175 | t.assert.strictEqual(res.statusCode, 503) 176 | t.assert.deepStrictEqual({ 177 | error: 'Service Unavailable', 178 | message: 'Circuit open', 179 | statusCode: 503, 180 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 181 | }, JSON.parse(res.payload)) 182 | 183 | await sleep(1000) 184 | res = await fastify.inject('/?error=false') 185 | t.assert.ok(res) 186 | t.assert.strictEqual(res.statusCode, 200) 187 | t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) 188 | }) 189 | 190 | test('If the staus is half-open and there is an error the state should be open again', async t => { 191 | t.plan(15) 192 | 193 | const fastify = Fastify() 194 | fastify.register(circuitBreaker, { 195 | threshold: 2, 196 | timeout: 1000, 197 | resetTimeout: 500 198 | }) 199 | 200 | fastify.after(() => { 201 | opts.preHandler = fastify.circuitBreaker() 202 | fastify.get('/', opts, (req, reply) => { 203 | t.assert.strictEqual(typeof req._cbTime, 'number') 204 | setTimeout(() => { 205 | reply.send( 206 | req.query.error ? new Error('kaboom') : { hello: 'world' } 207 | ) 208 | }, req.query.delay || 0) 209 | }) 210 | }) 211 | 212 | fastify.inject('/?error=true', (err, res) => { 213 | t.assert.ifError(err) 214 | t.assert.strictEqual(res.statusCode, 500) 215 | t.assert.deepStrictEqual({ 216 | error: 'Internal Server Error', 217 | message: 'kaboom', 218 | statusCode: 500 219 | }, JSON.parse(res.payload)) 220 | }) 221 | 222 | fastify.inject('/?error=true', (err, res) => { 223 | t.assert.ifError(err) 224 | t.assert.strictEqual(res.statusCode, 503) 225 | t.assert.deepStrictEqual({ 226 | error: 'Service Unavailable', 227 | message: 'Circuit open', 228 | statusCode: 503, 229 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 230 | }, JSON.parse(res.payload)) 231 | }) 232 | 233 | setTimeout(() => { 234 | fastify.inject('/?error=true', (err, res) => { 235 | t.assert.ifError(err) 236 | t.assert.strictEqual(res.statusCode, 500) 237 | t.assert.deepStrictEqual({ 238 | error: 'Internal Server Error', 239 | message: 'kaboom', 240 | statusCode: 500 241 | }, JSON.parse(res.payload)) 242 | }) 243 | 244 | fastify.inject('/?error=true', (err, res) => { 245 | t.assert.ifError(err) 246 | t.assert.strictEqual(res.statusCode, 503) 247 | t.assert.deepStrictEqual({ 248 | error: 'Service Unavailable', 249 | message: 'Circuit open', 250 | statusCode: 503, 251 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 252 | }, JSON.parse(res.payload)) 253 | }) 254 | }, 1000) 255 | 256 | await sleep(1200) 257 | }) 258 | 259 | test('Should customize circuit open error message', async t => { 260 | t.plan(4) 261 | 262 | const fastify = Fastify() 263 | fastify.register(circuitBreaker, { 264 | threshold: 1, 265 | circuitOpenErrorMessage: 'Oh gosh!' 266 | }) 267 | 268 | fastify.after(() => { 269 | opts.preHandler = fastify.circuitBreaker() 270 | fastify.get('/', opts, (req, reply) => { 271 | t.assert.strictEqual(typeof req._cbTime, 'number') 272 | setTimeout(() => { 273 | reply.send( 274 | req.query.error ? new Error('kaboom') : { hello: 'world' } 275 | ) 276 | }, req.query.delay || 0) 277 | }) 278 | }) 279 | 280 | const res = await fastify.inject('/?error=true') 281 | t.assert.ok(res) 282 | t.assert.strictEqual(res.statusCode, 503) 283 | t.assert.deepStrictEqual({ 284 | error: 'Service Unavailable', 285 | message: 'Oh gosh!', 286 | statusCode: 503, 287 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 288 | }, JSON.parse(res.payload)) 289 | }) 290 | 291 | test('Should customize timeout error message', async t => { 292 | t.plan(4) 293 | 294 | const fastify = Fastify() 295 | fastify.register(circuitBreaker, { 296 | threshold: 2, 297 | timeout: 100, 298 | timeoutErrorMessage: 'Oh gosh!' 299 | }) 300 | 301 | fastify.after(() => { 302 | opts.preHandler = fastify.circuitBreaker() 303 | fastify.get('/', opts, (req, reply) => { 304 | t.assert.strictEqual(typeof req._cbTime, 'number') 305 | setTimeout(() => { 306 | reply.send( 307 | req.query.error ? new Error('kaboom') : { hello: 'world' } 308 | ) 309 | }, req.query.delay || 0) 310 | }) 311 | }) 312 | 313 | const res = await fastify.inject('/?error=true&delay=200') 314 | t.assert.ok(res) 315 | t.assert.strictEqual(res.statusCode, 503) 316 | t.assert.deepStrictEqual({ 317 | error: 'Service Unavailable', 318 | message: 'Oh gosh!', 319 | statusCode: 503, 320 | code: 'FST_ERR_CIRCUIT_BREAKER_TIMEOUT' 321 | }, JSON.parse(res.payload)) 322 | }) 323 | 324 | test('One route should not interfere with others', async t => { 325 | t.plan(7) 326 | 327 | const fastify = Fastify() 328 | fastify.register(circuitBreaker, { 329 | threshold: 1 330 | }) 331 | 332 | fastify.after(() => { 333 | opts.preHandler = fastify.circuitBreaker() 334 | fastify.get('/', opts, (req, reply) => { 335 | t.assert.strictEqual(typeof req._cbTime, 'number') 336 | setTimeout(() => { 337 | reply.send( 338 | req.query.error ? new Error('kaboom') : { hello: 'world' } 339 | ) 340 | }, req.query.delay || 0) 341 | }) 342 | 343 | const options = { beforeHandler: fastify.circuitBreaker() } 344 | fastify.get('/other', options, (_req, reply) => { 345 | reply.send({ hello: 'world' }) 346 | }) 347 | }) 348 | 349 | let res = await fastify.inject('/?error=true') 350 | t.assert.ok(res) 351 | t.assert.strictEqual(res.statusCode, 503) 352 | t.assert.deepStrictEqual({ 353 | error: 'Service Unavailable', 354 | message: 'Circuit open', 355 | statusCode: 503, 356 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 357 | }, JSON.parse(res.payload)) 358 | 359 | res = await fastify.inject('/other') 360 | t.assert.ok(res) 361 | t.assert.strictEqual(res.statusCode, 200) 362 | t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) 363 | }) 364 | 365 | test('Custom options should overwrite the globals', async t => { 366 | t.plan(4) 367 | 368 | const fastify = Fastify() 369 | await fastify.register(circuitBreaker, { 370 | threshold: 1 371 | }) 372 | 373 | fastify.after(() => { 374 | opts.preHandler = fastify.circuitBreaker({ threshold: 2 }) 375 | fastify.get('/', opts, (req, reply) => { 376 | t.assert.strictEqual(typeof req._cbTime, 'number') 377 | setTimeout(() => { 378 | reply.send( 379 | req.query.error ? new Error('kaboom') : { hello: 'world' } 380 | ) 381 | }, req.query.delay || 0) 382 | }) 383 | }) 384 | 385 | const res = await fastify.inject('/?error=true') 386 | t.assert.ok(res) 387 | t.assert.strictEqual(res.statusCode, 500) 388 | t.assert.deepStrictEqual({ 389 | error: 'Internal Server Error', 390 | message: 'kaboom', 391 | statusCode: 500 392 | }, JSON.parse(res.payload)) 393 | }) 394 | 395 | test('Should handle also errors with statusCode property', async t => { 396 | t.plan(6) 397 | 398 | const fastify = Fastify() 399 | fastify.register(circuitBreaker, { 400 | threshold: 2 401 | }) 402 | 403 | fastify.after(() => { 404 | opts.preHandler = fastify.circuitBreaker() 405 | fastify.get('/', opts, (_req, reply) => { 406 | const error = new Error('kaboom') 407 | error.statusCode = 501 408 | reply.send(error) 409 | }) 410 | }) 411 | 412 | let res = await fastify.inject('/') 413 | t.assert.ok(res) 414 | t.assert.strictEqual(res.statusCode, 501) 415 | t.assert.deepStrictEqual({ 416 | error: 'Not Implemented', 417 | message: 'kaboom', 418 | statusCode: 501 419 | }, JSON.parse(res.payload)) 420 | 421 | res = await fastify.inject('/') 422 | t.assert.ok(res) 423 | t.assert.strictEqual(res.statusCode, 503) 424 | t.assert.deepStrictEqual({ 425 | error: 'Service Unavailable', 426 | message: 'Circuit open', 427 | statusCode: 503, 428 | code: 'FST_ERR_CIRCUIT_BREAKER_OPEN' 429 | }, JSON.parse(res.payload)) 430 | }) 431 | 432 | test('If a route is not under the circuit breaker, _cbRouteId should always be equal to 0', async t => { 433 | t.plan(8) 434 | 435 | const fastify = Fastify() 436 | fastify.register(circuitBreaker) 437 | 438 | fastify.get('/first', (req, reply) => { 439 | t.assert.strictEqual(req._cbRouteId, 0) 440 | reply.send({ hello: 'world' }) 441 | }) 442 | 443 | fastify.get('/second', (req, reply) => { 444 | t.assert.strictEqual(req._cbRouteId, 0) 445 | reply.send({ hello: 'world' }) 446 | }) 447 | 448 | let res = await fastify.inject('/first') 449 | t.assert.ok(res) 450 | t.assert.strictEqual(res.statusCode, 200) 451 | t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) 452 | 453 | res = await fastify.inject('/second') 454 | t.assert.ok(res) 455 | t.assert.strictEqual(res.statusCode, 200) 456 | t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) 457 | }) 458 | 459 | test('Should work only if the status code is >= 500', async t => { 460 | t.plan(6) 461 | 462 | const fastify = Fastify() 463 | fastify.register(circuitBreaker, { 464 | threshold: 1 465 | }) 466 | 467 | fastify.after(() => { 468 | opts.preHandler = fastify.circuitBreaker() 469 | fastify.get('/first', opts, (_req, reply) => { 470 | const error = new Error('kaboom') 471 | error.statusCode = 400 472 | reply.send(error) 473 | }) 474 | 475 | fastify.get('/second', opts, (_req, reply) => { 476 | reply.code(400).send(new Error('kaboom')) 477 | }) 478 | }) 479 | 480 | let res = await fastify.inject('/first') 481 | t.assert.ok(res) 482 | t.assert.strictEqual(res.statusCode, 400) 483 | t.assert.deepStrictEqual({ 484 | error: 'Bad Request', 485 | message: 'kaboom', 486 | statusCode: 400 487 | }, JSON.parse(res.payload)) 488 | 489 | res = await fastify.inject('/second') 490 | t.assert.ok(res) 491 | t.assert.strictEqual(res.statusCode, 400) 492 | t.assert.deepStrictEqual({ 493 | error: 'Bad Request', 494 | message: 'kaboom', 495 | statusCode: 400 496 | }, JSON.parse(res.payload)) 497 | }) 498 | 499 | test('Should call onCircuitOpen when the threshold has been reached', async t => { 500 | t.plan(6) 501 | 502 | const fastify = Fastify() 503 | fastify.register(circuitBreaker, { 504 | threshold: 2, 505 | onCircuitOpen: (_req, reply) => { 506 | reply.statusCode = 503 507 | return JSON.stringify({ message: 'hi' }) 508 | } 509 | }) 510 | 511 | fastify.after(() => { 512 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 513 | reply.send(new Error('kaboom')) 514 | }) 515 | }) 516 | 517 | let res = await fastify.inject('/') 518 | t.assert.ok(res) 519 | t.assert.strictEqual(res.statusCode, 500) 520 | t.assert.deepStrictEqual({ 521 | error: 'Internal Server Error', 522 | message: 'kaboom', 523 | statusCode: 500 524 | }, JSON.parse(res.payload)) 525 | 526 | res = await fastify.inject('/') 527 | t.assert.ok(res) 528 | t.assert.strictEqual(res.statusCode, 503) 529 | t.assert.deepStrictEqual({ 530 | message: 'hi' 531 | }, JSON.parse(res.payload)) 532 | }) 533 | 534 | test('Should call onTimeout when the timeout has been reached', async t => { 535 | t.plan(3) 536 | 537 | const fastify = Fastify() 538 | fastify.register(circuitBreaker, { 539 | timeout: 50, 540 | onTimeout: (_req, reply) => { 541 | reply.statusCode = 504 542 | return 'timed out' 543 | } 544 | }) 545 | 546 | fastify.after(() => { 547 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 548 | setTimeout(() => { 549 | reply.send({ hello: 'world' }) 550 | }, 100) 551 | }) 552 | }) 553 | 554 | const res = await fastify.inject('/') 555 | t.assert.ok(res) 556 | t.assert.strictEqual(res.statusCode, 504) 557 | t.assert.strictEqual(res.payload, 'timed out') 558 | }) 559 | 560 | test('onCircuitOpen will handle a thrown error', async t => { 561 | t.plan(6) 562 | 563 | const fastify = Fastify() 564 | fastify.register(circuitBreaker, { 565 | threshold: 2, 566 | onCircuitOpen: (_req, reply) => { 567 | reply.statusCode = 503 568 | throw new Error('circuit open') 569 | } 570 | }) 571 | 572 | fastify.after(() => { 573 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 574 | reply.send(new Error('kaboom')) 575 | }) 576 | }) 577 | 578 | let res = await fastify.inject('/') 579 | t.assert.ok(res) 580 | t.assert.strictEqual(res.statusCode, 500) 581 | t.assert.deepStrictEqual({ 582 | error: 'Internal Server Error', 583 | message: 'kaboom', 584 | statusCode: 500 585 | }, JSON.parse(res.payload)) 586 | 587 | res = await fastify.inject('/') 588 | t.assert.ok(res) 589 | t.assert.strictEqual(res.statusCode, 503) 590 | t.assert.deepStrictEqual({ 591 | error: 'Service Unavailable', 592 | message: 'circuit open', 593 | statusCode: 503 594 | }, JSON.parse(res.payload)) 595 | }) 596 | 597 | test('onTimeout will handle a thrown error', async t => { 598 | t.plan(2) 599 | 600 | const fastify = Fastify() 601 | fastify.register(circuitBreaker, { 602 | timeout: 50, 603 | onTimeout: (_req, reply) => { 604 | reply.statusCode = 504 605 | throw new Error('timed out') 606 | } 607 | }) 608 | 609 | fastify.after(() => { 610 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 611 | setTimeout(() => { 612 | reply.send({ hello: 'world' }) 613 | }, 100) 614 | }) 615 | }) 616 | 617 | const res = await fastify.inject('/') 618 | t.assert.ok(res) 619 | t.assert.deepStrictEqual({ 620 | error: 'Gateway Timeout', 621 | message: 'timed out', 622 | statusCode: 504 623 | }, JSON.parse(res.payload)) 624 | }) 625 | 626 | test('onCircuitOpen can be an async function', async t => { 627 | t.plan(6) 628 | 629 | const fastify = Fastify() 630 | fastify.register(circuitBreaker, { 631 | threshold: 2, 632 | onCircuitOpen: async (_req, reply) => { 633 | const statusCode = await Promise.resolve(503) 634 | reply.statusCode = statusCode 635 | throw new Error('circuit open') 636 | } 637 | }) 638 | 639 | fastify.after(() => { 640 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 641 | reply.send(new Error('kaboom')) 642 | }) 643 | }) 644 | 645 | let res = await fastify.inject('/') 646 | t.assert.ok(res) 647 | t.assert.strictEqual(res.statusCode, 500) 648 | t.assert.deepStrictEqual({ 649 | error: 'Internal Server Error', 650 | message: 'kaboom', 651 | statusCode: 500 652 | }, JSON.parse(res.payload)) 653 | 654 | res = await fastify.inject('/') 655 | t.assert.ok(res) 656 | t.assert.strictEqual(res.statusCode, 503) 657 | t.assert.deepStrictEqual({ 658 | error: 'Service Unavailable', 659 | message: 'circuit open', 660 | statusCode: 503 661 | }, JSON.parse(res.payload)) 662 | }) 663 | 664 | test('onTimeout can be an async function', async t => { 665 | t.plan(2) 666 | 667 | const fastify = Fastify() 668 | fastify.register(circuitBreaker, { 669 | timeout: 50, 670 | onTimeout: async (_req, reply) => { 671 | const statusCode = await Promise.resolve(504) 672 | reply.statusCode = statusCode 673 | throw new Error('timed out') 674 | } 675 | }) 676 | 677 | fastify.after(() => { 678 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (_req, reply) => { 679 | setTimeout(() => { 680 | reply.send({ hello: 'world' }) 681 | }, 100) 682 | }) 683 | }) 684 | 685 | const res = await fastify.inject('/') 686 | t.assert.ok(res) 687 | t.assert.deepStrictEqual({ 688 | error: 'Gateway Timeout', 689 | message: 'timed out', 690 | statusCode: 504 691 | }, JSON.parse(res.payload)) 692 | }) 693 | 694 | test('Should not throw error if no options is passed', async t => { 695 | t.plan(3) 696 | const fastify = Fastify() 697 | const fastify2 = Fastify() 698 | const fastify3 = Fastify() 699 | t.assert.strictEqual(circuitBreaker(fastify, undefined, () => {}), undefined) 700 | t.assert.strictEqual(circuitBreaker(fastify2, null, () => {}), undefined) 701 | t.assert.strictEqual(circuitBreaker(fastify3, {}, () => {}), undefined) 702 | }) 703 | 704 | test('Should throw error on route status open and circuit open', async t => { 705 | t.plan(5) 706 | 707 | const fastify = Fastify() 708 | await fastify.register(circuitBreaker, { 709 | threshold: 1, 710 | timeout: 1000, 711 | resetTimeout: 1500, 712 | onCircuitOpen: async (_req, reply) => { 713 | reply.statusCode = 500 714 | return JSON.stringify({ err: 'custom error' }) 715 | } 716 | }) 717 | 718 | fastify.after(() => { 719 | fastify.get('/', { preHandler: fastify.circuitBreaker() }, (req, reply) => { 720 | t.assert.strictEqual(typeof req._cbTime, 'number') 721 | setTimeout(() => { 722 | reply.send(new Error('kaboom')) 723 | }, 0) 724 | }) 725 | }) 726 | 727 | let res = await fastify.inject('/?error=true') 728 | t.assert.ok(res) 729 | 730 | await sleep(1000) 731 | res = await fastify.inject('/?error=false') 732 | t.assert.ok(res) 733 | t.assert.strictEqual(res.statusCode, 500) 734 | t.assert.deepStrictEqual(res.json(), { err: 'custom error' }) 735 | }) 736 | 737 | test('Should throw error on route status half open and circuit open', async t => { 738 | t.plan(15) 739 | 740 | const fastify = Fastify() 741 | fastify.register(circuitBreaker, { 742 | threshold: 2, 743 | timeout: 1000, 744 | resetTimeout: 500, 745 | onCircuitOpen: async (_req, reply) => { 746 | reply.statusCode = 500 747 | return JSON.stringify({ err: 'custom error' }) 748 | } 749 | }) 750 | 751 | fastify.after(() => { 752 | opts.preHandler = fastify.circuitBreaker() 753 | 754 | fastify.get('/', opts, (req, reply) => { 755 | t.assert.strictEqual(typeof req._cbTime, 'number') 756 | setTimeout(() => { 757 | reply.send(new Error('kaboom')) 758 | }, 0) 759 | }) 760 | }) 761 | 762 | fastify.inject('/?error=true', (err, res) => { 763 | t.assert.ifError(err) 764 | t.assert.strictEqual(res.statusCode, 500) 765 | t.assert.deepStrictEqual({ 766 | error: 'Internal Server Error', 767 | message: 'kaboom', 768 | statusCode: 500 769 | }, JSON.parse(res.payload)) 770 | }) 771 | 772 | fastify.inject('/?error=true', (err, res) => { 773 | t.assert.ifError(err) 774 | t.assert.strictEqual(res.statusCode, 500) 775 | t.assert.deepStrictEqual(res.json(), { err: 'custom error' }) 776 | }) 777 | 778 | setTimeout(() => { 779 | fastify.inject('/?error=true', (err, res) => { 780 | t.assert.ifError(err) 781 | t.assert.strictEqual(res.statusCode, 500) 782 | t.assert.deepStrictEqual({ 783 | error: 'Internal Server Error', 784 | message: 'kaboom', 785 | statusCode: 500 786 | }, JSON.parse(res.payload)) 787 | }) 788 | 789 | fastify.inject('/?error=true', (err, res) => { 790 | t.assert.strictEqual(null, err) 791 | t.assert.strictEqual(res.statusCode, 500) 792 | t.assert.deepStrictEqual(res.json(), { err: 'custom error' }) 793 | }) 794 | }, 1000) 795 | 796 | await sleep(1200) 797 | }) 798 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FastifyPluginCallback, 3 | FastifyRequest, 4 | FastifyReply, 5 | HookHandlerDoneFunction, 6 | } from 'fastify' 7 | import { Stream } from 'node:stream' 8 | 9 | declare module 'fastify' { 10 | interface FastifyInstance { 11 | circuitBreaker( 12 | options?: fastifyCircuitBreaker.FastifyCircuitBreakerOptions 13 | ): fastifyCircuitBreaker.FastifyCircuitBreakerBeforeHandler; 14 | } 15 | } 16 | 17 | type FastifyCircuitBreaker = FastifyPluginCallback 18 | 19 | declare namespace fastifyCircuitBreaker { 20 | export interface FastifyCircuitBreakerBeforeHandler { 21 | ( 22 | req: FastifyRequest, 23 | reply: FastifyReply, 24 | next: HookHandlerDoneFunction 25 | ): Promise | void; 26 | } 27 | 28 | export type FastifyCircuitBreakerOptions = { 29 | /** 30 | * The maximum numbers of failures you accept to have before opening the circuit. 31 | * @default 5 32 | */ 33 | threshold?: number; 34 | 35 | /** 36 | * The maximum number of milliseconds you can wait before return a `TimeoutError`. 37 | * @default 10000 38 | */ 39 | timeout?: number; 40 | 41 | /** 42 | * The number of milliseconds before the circuit will move from `open` to `half-open`. 43 | * @default 10000 44 | */ 45 | resetTimeout?: number; 46 | 47 | /** 48 | * Gets called when the circuit is `open` due to timeouts. 49 | * It can modify the reply and return a `string` | `Buffer` | `Stream` | 50 | * `Error` payload. If an `Error` is thrown it will be routed to your error 51 | * handler. 52 | */ 53 | onTimeout?: (request: FastifyRequest, reply: FastifyReply) => void | string | Buffer | Stream | Error | Promise; 54 | 55 | /** 56 | * 57 | * @default 'Timeout' 58 | */ 59 | timeoutErrorMessage?: string; 60 | 61 | /** 62 | * Gets called when the circuit is `open` due to errors. 63 | * It can modify the reply and return a `string` | `Buffer` | `Stream` 64 | * payload. If an `Error` is thrown it will be routed to your error handler. 65 | */ 66 | onCircuitOpen?: (request: FastifyRequest, reply: FastifyReply) => void | string | Buffer | Stream | Promise; 67 | 68 | /** 69 | * @default 'Circuit open' 70 | */ 71 | circuitOpenErrorMessage?: string; 72 | 73 | /** 74 | * The amount of cached requests. 75 | * @default 500 76 | */ 77 | cache?: number; 78 | } 79 | export const fastifyCircuitBreaker: FastifyCircuitBreaker 80 | export { fastifyCircuitBreaker as default } 81 | } 82 | 83 | declare function fastifyCircuitBreaker (...params: Parameters): ReturnType 84 | export = fastifyCircuitBreaker 85 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyReply, FastifyRequest } from 'fastify' 2 | import { expectType } from 'tsd' 3 | import FastifyCircuitBreaker, { FastifyCircuitBreakerOptions } from '..' 4 | 5 | const app = fastify() 6 | 7 | app.register(FastifyCircuitBreaker) 8 | app.register(FastifyCircuitBreaker, {}) 9 | app.register(FastifyCircuitBreaker, { timeout: 5000 }) 10 | app.register(FastifyCircuitBreaker, { threshold: 5 }) 11 | app.register(FastifyCircuitBreaker, { resetTimeout: 10000 }) 12 | app.register(FastifyCircuitBreaker, { 13 | timeout: 5000, 14 | threshold: 5, 15 | resetTimeout: 10000, 16 | }) 17 | 18 | const fastifyCircuitBreakerOptions: FastifyCircuitBreakerOptions = { 19 | timeout: 5000, 20 | threshold: 5, 21 | resetTimeout: 10000, 22 | } 23 | app.register(FastifyCircuitBreaker, fastifyCircuitBreakerOptions) 24 | 25 | app.get( 26 | '/', 27 | { 28 | preHandler: app.circuitBreaker(), 29 | }, 30 | () => { } 31 | ) 32 | 33 | app.register(FastifyCircuitBreaker, { timeoutErrorMessage: 'Timeon' }) 34 | app.register(FastifyCircuitBreaker, { 35 | onTimeout: async (req, reply) => { 36 | expectType(req) 37 | expectType(reply) 38 | const statusCode = await Promise.resolve(504) 39 | reply.statusCode = statusCode 40 | throw new Error('timed out') 41 | } 42 | }) 43 | app.register(FastifyCircuitBreaker, { 44 | onTimeout: (req, reply) => { 45 | expectType(req) 46 | expectType(reply) 47 | reply.statusCode = 504 48 | return 'timed out' 49 | } 50 | }) 51 | app.register(FastifyCircuitBreaker, { 52 | onTimeout: async (req, reply) => { 53 | expectType(req) 54 | expectType(reply) 55 | reply.statusCode = 504 56 | return 'timed out' 57 | } 58 | }) 59 | 60 | app.register(FastifyCircuitBreaker, { circuitOpenErrorMessage: 'circus open' }) 61 | app.register(FastifyCircuitBreaker, { 62 | onCircuitOpen: async (req, reply) => { 63 | expectType(req) 64 | expectType(reply) 65 | const statusCode = await Promise.resolve(504) 66 | reply.statusCode = statusCode 67 | throw new Error('circuit open') 68 | } 69 | }) 70 | app.register(FastifyCircuitBreaker, { 71 | onCircuitOpen: (req, reply) => { 72 | expectType(req) 73 | expectType(reply) 74 | reply.statusCode = 504 75 | return 'circuit open' 76 | } 77 | }) 78 | app.register(FastifyCircuitBreaker, { 79 | onCircuitOpen: async (req, reply) => { 80 | expectType(req) 81 | expectType(reply) 82 | reply.statusCode = 504 83 | return 'circuit open' 84 | } 85 | }) 86 | --------------------------------------------------------------------------------