├── test ├── mocha.opts ├── mocha.js ├── index.js └── perseverance.js ├── .eslintrc ├── .eslintignore ├── .gitignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .npmignore ├── .snyk ├── .babelrc ├── .editorconfig ├── src ├── index.js └── perseverance.js ├── LICENSE.md ├── README.md ├── package.json └── CONTRIBUTING.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/mocha 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-hfreire" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lib 4 | coverage 5 | test 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | coverage 4 | node_modules 5 | lib 6 | *.log 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hfreire 2 | custom: 'https://paypal.me/hfreire' 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .dependabot/ 2 | .github/ 3 | src/ 4 | test/ 5 | .babelrc 6 | .editorconfig 7 | .eslint* 8 | .snyk 9 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.1 3 | ignore: {} 4 | patch: {} 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": 6 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017, Hugo Freire . 3 | * 4 | * This source code is licensed under the license found in the 5 | * LICENSE.md file in the root directory of this source tree. 6 | */ 7 | 8 | module.exports = require('./perseverance') 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: fix 11 | prefix-development: chore 12 | include: scope 13 | -------------------------------------------------------------------------------- /test/mocha.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017, Hugo Freire . 3 | * 4 | * This source code is licensed under the license found in the 5 | * LICENSE.md file in the root directory of this source tree. 6 | */ 7 | 8 | const Promise = require('bluebird') 9 | 10 | const chai = require('chai') 11 | chai.use(require('chai-as-promised')) 12 | chai.config.includeStack = true 13 | 14 | const td = require('testdouble') 15 | td.config({ 16 | promiseConstructor: Promise, 17 | ignoreWarnings: true 18 | }) 19 | 20 | global.should = chai.should() 21 | global.td = td 22 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017, Hugo Freire . 3 | * 4 | * This source code is licensed under the license found in the 5 | * LICENSE.md file in the root directory of this source tree. 6 | */ 7 | 8 | describe('Module', () => { 9 | let subject 10 | let Perseverance 11 | 12 | before(() => { 13 | Perseverance = td.object([]) 14 | }) 15 | 16 | afterEach(() => td.reset()) 17 | 18 | describe('when loading', () => { 19 | beforeEach(() => { 20 | td.replace('../src/perseverance', Perseverance) 21 | 22 | subject = require('../src/index') 23 | }) 24 | 25 | it('should export perseverance', () => { 26 | subject.should.be.equal(Perseverance) 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | env: 11 | CI: true 12 | VERSION_COMMIT: ${{ github.sha }} 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.14 19 | - name: Install NPM dependencies 20 | run: npm ci 21 | - name: Build source code 22 | run: npm run build --if-present 23 | - name: Test source code 24 | run: npm test 25 | - name: Submit coveralls test coverage report 26 | uses: coverallsapp/github-action@v1.1.2 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | - name: Check if release should be created 30 | run: npm run semantic-release 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 [Hugo Freire](mailto:hugo@exec.sh) 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 | -------------------------------------------------------------------------------- /src/perseverance.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017, Hugo Freire . 3 | * 4 | * This source code is licensed under the license found in the 5 | * LICENSE.md file in the root directory of this source tree. 6 | */ 7 | 8 | const _ = require('lodash') 9 | const Promise = require('bluebird') 10 | const retry = require('bluebird-retry') 11 | const Brakes = require('brakes') 12 | const { default: PQueue } = require('p-queue') 13 | const { RateLimiter } = require('limiter') 14 | 15 | const execFunction = function (fn) { 16 | return fn() 17 | } 18 | 19 | const execRateable = function (fn) { 20 | return this._rate.removeTokensAsync(1) 21 | .then(() => execFunction(fn)) 22 | } 23 | 24 | const execRetrieable = function (fn) { 25 | return retry(() => execRateable.bind(this)(fn), _.get(this._options, 'retry')) 26 | } 27 | 28 | const execBreakable = function (fn) { 29 | return this._circuitBreaker.exec(fn) 30 | } 31 | 32 | const execQueueable = function (fn) { 33 | return new Promise((resolve, reject) => { 34 | return this._queue.add(() => { 35 | return execBreakable.bind(this)(fn) 36 | .then(resolve) 37 | .catch(reject) 38 | }) 39 | }) 40 | } 41 | 42 | const defaultOptions = { 43 | retry: { max_tries: 2, interval: 500, timeout: 2000, throw_original: true }, 44 | breaker: { timeout: 2500, threshold: 0.8, circuitDuration: 10000 }, 45 | rate: { 46 | executions: 1, 47 | period: 10, 48 | queue: { concurrency: 1 } 49 | } 50 | } 51 | 52 | class Perseverance { 53 | constructor (options = {}) { 54 | this._options = _.defaultsDeep({}, options, defaultOptions) 55 | 56 | this._circuitBreaker = new Brakes(execRetrieable.bind(this), _.get(this._options, 'breaker')) 57 | 58 | this._rate = Promise.promisifyAll(new RateLimiter(_.get(this._options, 'rate.executions'), _.get(this._options, 'rate.period'))) 59 | this._queue = new PQueue(_.get(this._options, 'rate.queue')) 60 | } 61 | 62 | get circuitBreaker () { 63 | return this._circuitBreaker 64 | } 65 | 66 | exec (fn) { 67 | return Promise.try(() => { 68 | if (!_.isFunction(fn)) { 69 | throw new Error('invalid arguments') 70 | } 71 | }) 72 | .then(() => execQueueable.bind(this)(fn)) 73 | } 74 | } 75 | 76 | module.exports = Perseverance 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Make your functions :muscle: resilient and :traffic_light: fail-fast to :poop: failures or :watch: delays 2 | 3 | [![](https://github.com/hfreire/perseverance/workflows/ci/badge.svg)](https://github.com/hfreire/perseverance/actions?workflow=ci) 4 | [![Coverage Status](https://coveralls.io/repos/github/hfreire/perseverance/badge.svg?branch=master)](https://coveralls.io/github/hfreire/perseverance?branch=master) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/hfreire/perseverance/badge.svg)](https://snyk.io/test/github/hfreire/perseverance) 6 | [![](https://img.shields.io/github/release/hfreire/perseverance.svg)](https://github.com/hfreire/perseverance/releases) 7 | [![Version](https://img.shields.io/npm/v/perseverance.svg)](https://www.npmjs.com/package/perseverance) 8 | [![Downloads](https://img.shields.io/npm/dt/perseverance.svg)](https://www.npmjs.com/package/perseverance) [![Greenkeeper badge](https://badges.greenkeeper.io/hfreire/perseverance.svg)](https://greenkeeper.io/) 9 | 10 | > Add rate limit, retry and circuit-breaker behaviour to your functions. 11 | 12 | ### Features 13 | * Limits :hand: rate of executions to comply with third-party API limits :white_check_mark: 14 | * Retries :shit: failing executions in temporary, unexpected system and :boom: network failures :white_check_mark: 15 | * Uses circuit breakers to :traffic_light: fail-fast until it is safe to retry :white_check_mark: 16 | * Supports [Bluebird](https://github.com/petkaantonov/bluebird) :bird: promises :white_check_mark: 17 | 18 | ### How to install 19 | ``` 20 | npm install perseverance 21 | ``` 22 | 23 | ### How to contribute 24 | You can contribute either with code (e.g., new features, bug fixes and documentation) or by [donating 5 EUR](https://paypal.me/hfreire/5). You can read the [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute with code. 25 | 26 | All donation proceedings will go to the [Sverige för UNHCR](https://sverigeforunhcr.se), a swedish partner of the [UNHCR - The UN Refugee Agency](http://www.unhcr.org), a global organisation dedicated to saving lives, protecting rights and building a better future for refugees, forcibly displaced communities and stateless people. 27 | 28 | ### Used by 29 | * [request-on-steroids](https://github.com/hfreire/request-on-steroids) - An HTTP client :sparkles: with retry, circuit-breaker and tor support :package: out-of-the-box 30 | * [facebook-login-for-robots](https://github.com/hfreire/facebook-login-for-robots) - Facebook Login for 🤖 robots 31 | 32 | ### License 33 | Read the [license](./LICENSE.md) for permissions and limitations. 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perseverance", 3 | "description": "Make your functions :muscle: resilient and :traffic_light: fail-fast to :poop: failures or :watch: delays", 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": ">= 6.0.0" 7 | }, 8 | "main": "lib/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/hfreire/perseverance.git" 12 | }, 13 | "author": "Hugo Freire ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/hfreire/perseverance/issues" 17 | }, 18 | "homepage": "https://github.com/hfreire/perseverance#readme", 19 | "dependencies": { 20 | "bluebird": "3.7.2", 21 | "bluebird-retry": "0.11.0", 22 | "brakes": "2.8.0", 23 | "limiter": "1.1.5", 24 | "lodash": "4.17.21", 25 | "random-http-useragent": "1.1.34", 26 | "p-queue": "6.6.2" 27 | }, 28 | "devDependencies": { 29 | "babel-cli": "6.26.0", 30 | "babel-preset-env": "1.7.0", 31 | "chai": "4.3.4", 32 | "chai-as-promised": "7.1.1", 33 | "eslint": "6.8.0", 34 | "eslint-config-hfreire": "2.0.7", 35 | "eslint-plugin-import": "2.25.2", 36 | "eslint-plugin-jest": "25.2.2", 37 | "eslint-plugin-json": "3.1.0", 38 | "eslint-plugin-mocha": "6.3.0", 39 | "eslint-plugin-node": "11.1.0", 40 | "eslint-plugin-promise": "4.3.1", 41 | "eslint-plugin-standard": "5.0.0", 42 | "eslint-plugin-unicorn": "19.0.1", 43 | "istanbul": "0.4.5", 44 | "mocha": "9.1.3", 45 | "pre-git": "3.17.1", 46 | "semantic-release": "17.4.7", 47 | "testdouble": "3.16.3", 48 | "snyk": "1.749.0" 49 | }, 50 | "config": { 51 | "pre-git": { 52 | "commit-msg": "conventional", 53 | "allow-untracked-files": true 54 | } 55 | }, 56 | "snyk": true, 57 | "scripts": { 58 | "eslint": "node_modules/.bin/eslint --ext .json --ext .js .", 59 | "istanbul": "node_modules/.bin/istanbul cover --include-all-sources --root src --print detail ./node_modules/mocha/bin/_mocha -- --recursive test", 60 | "snyk:test": "./node_modules/.bin/snyk test", 61 | "snyk:protect": "./node_modules/.bin/snyk protect", 62 | "babel": "mkdir -p lib && ./node_modules/.bin/babel src/ -d lib", 63 | "semantic-release": "./node_modules/.bin/semantic-release", 64 | "clean": "rm -rf lib coverage", 65 | "lint": "npm run eslint", 66 | "prepare": "npm run snyk:protect", 67 | "test": "npm run clean && npm run lint && npm run istanbul", 68 | "compile": "npm run clean && npm run babel", 69 | "commit": "./node_modules/.bin/commit-wizard", 70 | "prepublish": "npm run compile" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | This GitHub repo follows the [GitHub Flow](https://guides.github.com/introduction/flow/) git workflow. In essence, you contribute by making changes in your fork and then generating a pull request of those changes to be merged with the upstream. 3 | 4 | ### How to fork this repo 5 | You can read more about forking a GitHub repo [here](https://help.github.com/articles/fork-a-repo). Once you've forked this repo, you're now ready to clone the repo in your computer and start hacking and tinkering with its code. 6 | 7 | Clone the GitHub repo 8 | ``` 9 | git clone https://github.com/my-github-username/perseverance 10 | ``` 11 | 12 | Change current directory 13 | ``` 14 | cd perseverance 15 | ``` 16 | 17 | Install NPM dependencies 18 | ``` 19 | npm install 20 | ``` 21 | 22 | ### How to keep your fork synced 23 | It's generally a good idea to pull upstream changes and merge them with your fork regularly. [Greenkeeper app](https://github.com/marketplace/greenkeeper) is installed in this GitHub project, it will automatically update dependencies and merge them with upstream if possible. 24 | 25 | Add remote upstream 26 | ``` 27 | git remote add upstream https://github.com/hfreire/perseverance 28 | ``` 29 | 30 | Fetch from remote upstream master branch 31 | ``` 32 | git fetch upstream master 33 | ``` 34 | 35 | Merge upstream with your local master branch 36 | ``` 37 | git merge upstream/master 38 | ``` 39 | 40 | Install, update and prune removed NPM dependencies 41 | ``` 42 | npm install && npm prune 43 | ``` 44 | 45 | ### How to know what to contribute 46 | The list of outstanding feature requests and bugs can be found in the [GitHub issue tracker](https://github.com/hfreire/perseverance/issues) of this repo. Please, feel free to propose features or report bugs that are not there. 47 | 48 | ### How to style the code 49 | With the exception rules from [eslint-config-hfreire](https://github.com/hfreire/eslint-config-hfreire), this repo follows the [JavaScript Standard Style](https://standardjs.com/) rules. 50 | 51 | Run the NPM script that will verify the code for style guide violations 52 | ``` 53 | npm run lint 54 | ``` 55 | 56 | ### How to test the code locally 57 | You are encouraged to write automated test cases of your changes. This repo uses [Mocha](https://mochajs.org/) test framework with [testdouble.js](https://github.com/testdouble/testdouble.js) for faking, mocking and stubbing and [Chai](http://chaijs.com) for assertion. 58 | 59 | Run the NPM script that will verify failing test cases and report automated test coverage 60 | ``` 61 | npm run coverage 62 | ``` 63 | 64 | ### How to commit changes 65 | This repo follows the [AngularJS git commit guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits). 66 | 67 | Run the NPM script that will commit changes through an interactive menu 68 | ``` 69 | npm run commit 70 | ``` 71 | 72 | ### How to generate a pull request 73 | You can read more about creating a GitHub pull request from a fork [here](https://help.github.com/articles/creating-a-pull-request-from-a-fork). 74 | 75 | ### How to get your pull request accepted 76 | Every pull request is welcomed, but it's important, as well, to have maintainable code and avoid regression bugs while adding features or fixing other bugs. 77 | 78 | Once you generate a pull request, GitHub and third-party apps will verify if the changes are suitable to be merged with upstream. [GitHub Actions CI workflow](https://github.com/hfreire/perseverance/actions?workflow=ci) will verify your changes for style guide violations and failing test cases, while, [Coveralls](https://coveralls.io/github/hfreire/perseverance) will verify the coverage of the automated test cases against the code. 79 | 80 | You are encouraged to verify your changes by testing the code locally. 81 | 82 | Run the NPM script that will verify the code for style guide violations, failing test cases and report automated test coverage 83 | ``` 84 | npm test 85 | ``` 86 | -------------------------------------------------------------------------------- /test/perseverance.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017, Hugo Freire . 3 | * 4 | * This source code is licensed under the license found in the 5 | * LICENSE.md file in the root directory of this source tree. 6 | */ 7 | 8 | /* eslint-disable promise/no-callback-in-promise */ 9 | 10 | describe('Perseverance', () => { 11 | let subject 12 | let Brakes 13 | let Limiter 14 | let PQueue 15 | 16 | before(() => { 17 | Brakes = td.constructor([ 'exec' ]) 18 | 19 | Limiter = td.object([]) 20 | Limiter.RateLimiter = td.constructor([]) 21 | }) 22 | 23 | afterEach(() => td.reset()) 24 | 25 | describe('when constructing', () => { 26 | beforeEach(() => { 27 | td.replace('brakes', Brakes) 28 | 29 | td.replace('limiter', Limiter) 30 | 31 | PQueue = td.replace('p-queue').default 32 | 33 | const Perseverance = require('../src/perseverance') 34 | subject = new Perseverance() 35 | }) 36 | 37 | it('should construct brakes instance with default options', () => { 38 | const captor = td.matchers.captor() 39 | 40 | td.verify(new Brakes(td.matchers.anything(), captor.capture()), { times: 1 }) 41 | 42 | const options = captor.value 43 | options.should.have.property('timeout') 44 | options.should.have.property('threshold') 45 | options.should.have.property('circuitDuration') 46 | }) 47 | 48 | it('should construct rate-limiter instance with default options', () => { 49 | td.verify(new Limiter.RateLimiter(1, 10), { times: 1 }) 50 | }) 51 | 52 | it('should construct p-queue instance with default options', () => { 53 | const captor = td.matchers.captor() 54 | 55 | td.verify(new PQueue(captor.capture()), { times: 1 }) 56 | 57 | const options = captor.value 58 | options.should.have.property('concurrency', 1) 59 | }) 60 | }) 61 | 62 | describe('when constructing and loading brakes', () => { 63 | beforeEach(() => { 64 | const Perseverance = require('../src/perseverance') 65 | subject = new Perseverance() 66 | }) 67 | 68 | it('should create a circuit breaker with slaveCircuit function', () => { 69 | subject._circuitBreaker.should.have.property('slaveCircuit') 70 | subject._circuitBreaker.slaveCircuit.should.be.instanceOf(Function) 71 | }) 72 | }) 73 | 74 | describe('when constructing and loading limiter', () => { 75 | beforeEach(() => { 76 | const Perseverance = require('../src/perseverance') 77 | subject = new Perseverance() 78 | }) 79 | 80 | it('should create a queue with removeTokensAsync function', () => { 81 | subject._rate.should.have.property('removeTokensAsync') 82 | subject._rate.removeTokensAsync.should.be.instanceOf(Function) 83 | }) 84 | }) 85 | 86 | describe('when constructing and loading queue', () => { 87 | beforeEach(() => { 88 | const Perseverance = require('../src/perseverance') 89 | subject = new Perseverance() 90 | }) 91 | 92 | it('should create a queue with add function', () => { 93 | subject._queue.should.have.property('add') 94 | subject._queue.add.should.be.instanceOf(Function) 95 | }) 96 | }) 97 | 98 | describe('when executing a function', () => { 99 | let fn 100 | 101 | before(() => { 102 | fn = td.function() 103 | }) 104 | 105 | beforeEach(() => { 106 | const Perseverance = require('../src/perseverance') 107 | subject = new Perseverance() 108 | }) 109 | 110 | it('should execute function', () => { 111 | return subject.exec(fn) 112 | .then(() => { 113 | td.verify(fn(), { times: 1 }) 114 | }) 115 | }) 116 | }) 117 | 118 | describe('when executing a non-function', () => { 119 | const fn = {} 120 | 121 | beforeEach(() => { 122 | const Perseverance = require('../src/perseverance') 123 | subject = new Perseverance() 124 | }) 125 | 126 | it('should reject with invalid arguments error', (done) => { 127 | subject.exec(fn) 128 | .catch((error) => { 129 | error.should.be.instanceOf(Error) 130 | error.message.should.be.equal('invalid arguments') 131 | 132 | done() 133 | }) 134 | }) 135 | }) 136 | 137 | describe('when getting circuit breaker', () => { 138 | beforeEach(() => { 139 | td.replace('brakes', Brakes) 140 | 141 | const Perseverance = require('../src/perseverance') 142 | subject = new Perseverance() 143 | }) 144 | 145 | it('should return a brakes instance', () => { 146 | subject.circuitBreaker.should.be.instanceOf(Brakes) 147 | }) 148 | }) 149 | }) 150 | --------------------------------------------------------------------------------