├── .eslintignore ├── test ├── .eslintrc.json ├── retry.test.js ├── circuit-breaker.test.js ├── request.test.js └── client.test.js ├── .zappr.yaml ├── MAINTAINERS ├── .gitattributes ├── .editorconfig ├── tsconfig.json ├── .eslintrc.json ├── SECURITY.md ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── package.json ├── LICENSE ├── lib ├── retry.ts ├── circuit-breaker.ts ├── request.ts └── client.ts ├── CHANGELOG.md ├── CONTRIBUTING.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "@typescript-eslint/no-var-requires": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.zappr.yaml: -------------------------------------------------------------------------------- 1 | approvals: 2 | groups: 3 | zalando: 4 | minimum: 2 5 | from: 6 | orgs: 7 | - "zalando" 8 | X-Zalando-Team: "pathfinder" 9 | X-Zalando-Type: code 10 | 11 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Oskari Porkka 2 | Boopathi Nedunchezhiyan 3 | Jeremy Colin 4 | Jan Brockmeyer 5 | Mohit Karekar 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # This makes the repo play nice with git on Windows 2 | * text=auto 3 | 4 | # do not show up in local diffs 5 | # do not show up as expanded in github diffs 6 | # https://github.com/github/linguist#generated-code 7 | package-lock.json binary merge=union linguist-generated 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # For most files 7 | [*] 8 | indent_size = 2 9 | indent_style = space 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | # Makefile needs tabs 16 | [{Makefile}] 17 | indent_style = tab 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "noImplicitAny": true, 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "strict": true, 9 | "declaration": true, 10 | "alwaysStrict": true, 11 | "outDir": "./dist", 12 | "rootDir": "./lib", 13 | "lib": [ 14 | "es2020" 15 | ], 16 | "typeRoots": ["./types", "./node_modules/@types"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@typescript-eslint/recommended", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "rules": { 8 | "@typescript-eslint/explicit-member-accessibility": "off", 9 | "@typescript-eslint/array-type": "off", 10 | "@typescript-eslint/no-use-before-define": "off", 11 | "@typescript-eslint/no-parameter-properties": "off", 12 | "@typescript-eslint/explicit-function-return-type": "off", 13 | "@typescript-eslint/no-explicit-any": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | We acknowledge that every line of code that we write may potentially contain security issues. 2 | We are trying to deal with it responsibly and provide patches as quickly as possible. 3 | 4 | We host our bug bounty program on HackerOne, it is currently private, therefore if you would like to report a vulnerability and get rewarded for it, please ask to join our program by filling this form: 5 | 6 | https://corporate.zalando.com/en/services-and-contact#security-form 7 | 8 | You can also send your report via this form if you do not want to join our bug bounty program and just want to report a vulnerability or security issue. 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | api.md 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | .nyc_output 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Build directory 30 | dist 31 | 32 | # STUPS stuff 33 | scm-source.json 34 | 35 | # IDE stuff 36 | .idea 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [12.x, 14.x, 16.x] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Cache Node.js modules 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.OS }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.OS }}-node-${{ matrix.node-version }}- 24 | ${{ runner.OS }}-node- 25 | ${{ runner.OS }}- 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Build and test 29 | run: npm test 30 | env: 31 | CI: true 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perron", 3 | "version": "0.11.5", 4 | "description": "A sane client for web services", 5 | "engines": { 6 | "node": ">=12.0.0" 7 | }, 8 | "main": "dist/client.js", 9 | "scripts": { 10 | "prepublishOnly": "npm run test", 11 | "lint": "eslint . --ext .ts,.tsx,.js", 12 | "test": "npm run lint && tsc && mocha test", 13 | "test-cov": "npm run lint && tsc && nyc --check-coverage --lines 90 --functions 85 --branches 85 mocha test", 14 | "tdd": "mocha test --watch" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:zalando-incubator/perron.git" 19 | }, 20 | "files": [ 21 | "dist/*" 22 | ], 23 | "types": "./dist/client.d.ts", 24 | "author": "Team Pathfinder ", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@types/mocha": "^5.2.7", 28 | "@types/node": "^8.10.49", 29 | "@typescript-eslint/eslint-plugin": "^5.8.0", 30 | "@typescript-eslint/parser": "^5.8.0", 31 | "eslint": "^7.32.0", 32 | "eslint-config-prettier": "^4.3.0", 33 | "eslint-plugin-prettier": "^3.1.0", 34 | "mocha": "^6.1.4", 35 | "nock": "^11.3.2", 36 | "nyc": "^14.1.1", 37 | "prettier": "^1.18.2", 38 | "proxyquire": "^2.1.0", 39 | "sinon": "^7.3.2", 40 | "typescript": "^3.5.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright for portions of circuit breaker code are held by Yammer Inc. 2 | Copyright for portion of the retry related code are held by Tim Koschützki (tim@debuggable.com) and Felix Geisendörfer (felix@debuggable.com) 3 | All other copyright for Perron are held by Zalando. 4 | 5 | The MIT License 6 | 7 | Copyright (c) 2013 Yammer Inc. 8 | Copyright (c) 2016 Zalando SE 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /lib/retry.ts: -------------------------------------------------------------------------------- 1 | export function operation( 2 | options: OperationOptions, 3 | fn: (currentAttempt: number) => void 4 | ) { 5 | return new RetryOperation(timeouts(options), fn); 6 | } 7 | 8 | export function timeouts(options: OperationOptions) { 9 | if (options.minTimeout > options.maxTimeout) { 10 | throw new Error("minTimeout is greater than maxTimeout"); 11 | } 12 | 13 | const timeouts: number[] = []; 14 | for (let i = 0; i < options.retries; i++) { 15 | timeouts.push(createTimeout(i, options)); 16 | } 17 | 18 | // sort the array numerically ascending 19 | timeouts.sort(function(a, b) { 20 | return a - b; 21 | }); 22 | 23 | return timeouts; 24 | } 25 | 26 | function createTimeout( 27 | attempt: number, 28 | opts: Required 29 | ): number { 30 | const random = opts.randomize ? Math.random() + 1 : 1; 31 | 32 | let timeout = Math.round( 33 | random * opts.minTimeout * Math.pow(opts.factor, attempt) 34 | ); 35 | timeout = Math.min(timeout, opts.maxTimeout); 36 | 37 | return timeout; 38 | } 39 | export interface OperationOptions extends CreateTimeoutOptions { 40 | /** 41 | * The maximum amount of times to retry the operation. 42 | * @default 0 43 | */ 44 | retries: number; 45 | } 46 | 47 | interface CreateTimeoutOptions { 48 | /** 49 | * The exponential factor to use. 50 | * @default 2 51 | */ 52 | factor: number; 53 | /** 54 | * The number of milliseconds before starting the first retry. 55 | * @default 200 56 | */ 57 | minTimeout: number; 58 | /** 59 | * The maximum number of milliseconds between two retries. 60 | * @default 400 61 | */ 62 | maxTimeout: number; 63 | /** 64 | * Randomizes the timeouts by multiplying a factor between 1-2. 65 | * @default true 66 | */ 67 | randomize: boolean; 68 | } 69 | 70 | class RetryOperation { 71 | private readonly _timeouts: number[]; 72 | private readonly _fn: (currentAttempt: number) => void; 73 | private _attempts: number; 74 | constructor(timeouts: number[], fn: (currentAttempt: number) => void) { 75 | this._timeouts = timeouts; 76 | this._fn = fn; 77 | this._attempts = 1; 78 | } 79 | 80 | retry() { 81 | if (this._attempts > this._timeouts.length) { 82 | return false; 83 | } 84 | const timeout = this._timeouts[this._attempts - 1]; 85 | setTimeout(() => { 86 | this._attempts++; 87 | this._fn(this._attempts); 88 | }, timeout); 89 | 90 | return true; 91 | } 92 | 93 | attempt() { 94 | this._fn(this._attempts); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.11.5 2 | * use ServiceClientError type for Errors in shouldRetry and onRetry options. 3 | 4 | ## 0.11.3 5 | 6 | * Use `timing` option from client if `timing` is not set in the request options. #91 7 | * Improved TypeScript definition of `ServiceClientRequestFilter`. #100 8 | 9 | ## 0.11.2 10 | 11 | * Fixed an error that is thrown when `dropRequestAfter` is set and a connection error happens. #96 12 | 13 | ## 0.11.1 14 | 15 | * Added support for measuring TLS timings. 16 | 17 | ## 0.11.0 18 | 19 | * Added `span` option where the tcp events will be logged. The interface matches the opentracing span interface. 20 | * Improved retries performance and memory usage. 21 | 22 | ## 0.10.0 23 | 24 | * Added `readTimeout` options to be able to timeout when a socket is 25 | idle for a certain period of time. 26 | 27 | ## 0.9.1 28 | 29 | * Having a circuit breaker configured no longer results in Node process 30 | not exiting properly. 31 | * Improved circuit breaker performance and memory usage. 32 | 33 | ## 0.9.0 34 | 35 | Added custom error classes for different error types, including ability to distinguish connection timeout error, user timeout error, and maximum retries error. For more details see [Handling Errors section in the README](./README.md#handling-errors) 36 | 37 | ### Breaking Changes 38 | 39 | TypeScript type definition for request headers has been made more 40 | strict to avoid runtime errors caused by `undefined` headers. 41 | 42 | See [pull request](https://github.com/zalando-incubator/perron/pull/77/files) for details. 43 | 44 | ### Deprecation Notices 45 | 46 | Usage of `type` field on `ServiceClientError` to understand the type of the error is now deprecated in favor of `instanceof` checks for new error classes added in this release. 47 | 48 | ## 0.7.0 49 | 50 | ### Breaking Changes 51 | 52 | In 0.5.0 we changed the exports of the module to be forward-compatible with ES modules. If you are using CommonJS-style require calls, they need to updated from: 53 | 54 | ```js 55 | const ServiceClient = require('perron') 56 | ``` 57 | 58 | to 59 | 60 | ```js 61 | const {ServiceClient} = require('perron') 62 | ``` 63 | 64 | So `ServiceClient` is now a named export. 65 | 66 | If you were using babel to transpile your code, no changes should be necessary. 67 | 68 | ## 0.6.0 69 | 70 | ### Breaking Changes 71 | 72 | In 0.6.0 we changed the fields of `timings` and `timingPhases` on `ServiceClientResponse` to be nullable, or `undefined`able to be accurate. Previously `timings` had `-1` when a field was missing, and `timingPhases` had wrong numbers in those cases. 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Perron 2 | 3 | **Thank you for your interest in making Perron even better and more awesome. Your contributions are highly welcome.** 4 | 5 | There are multiple ways of getting involved: 6 | 7 | - [Report a bug](#report-a-bug) 8 | - [Suggest a feature](#suggest-a-feature) 9 | - [Contribute code](#contribute-code) 10 | 11 | Below are a few guidelines we would like you to follow. 12 | If you need help, please reach out to us: [team-pathfinder@zalando.de](mailto:team-pathfinder@zalando.de) 13 | 14 | ## Report a bug 15 | Reporting bugs is one of the best ways to contribute. Before creating a bug report, please check that an [issue](https://github.com/zalando-incubator/perron/issues) reporting the same problem does not already exist. If there is an such an issue, you may add your information as a comment. 16 | 17 | To report a new bug you should open an issue that summarizes the bug and set the label to "bug". 18 | 19 | If you want to provide a fix along with your bug report: That is great! In this case please send us a pull request as described in section [Contribute Code](#contribute-code). 20 | 21 | ## Suggest a feature 22 | To request a new feature you should open an [issue](https://github.com/zalando-incubator/perron/issues/new) and summarize the desired functionality and its use case. Set the issue label to "feature". 23 | 24 | ## Contribute code 25 | This is a rough outline of what the workflow for code contributions looks like: 26 | - Check the list of open [issues](https://github.com/zalando-incubator/perron/issues). Either assign an existing issue to yourself, or create a new one that you would like work on and discuss your ideas and use cases. 27 | - Fork the repository on GitHub 28 | - Create a topic branch from where you want to base your work. This is usually master. 29 | - Make commits of logical units. 30 | - Write good commit messages (see below). 31 | - Push your changes to a topic branch in your fork of the repository. 32 | - Submit a pull request to [zalando-incubator/perron](https://github.com/zalando-incubator/perron) 33 | - Your pull request must receive a :thumbsup: from two [Maintainers](https://github.com/zalando-incubator/perron/blob/master/MAINTAINERS) 34 | 35 | Thanks for your contributions! 36 | 37 | ### Code style 38 | Perron uses [eslint](http://eslint.org) and [editorconfig](http://www.editorconfig.org) to validate a consistent code style. 39 | 40 | ### Commit messages 41 | Your commit messages ideally can answer two questions: what changed and why. The subject line should feature the “what” and the body of the commit should describe the “why”. 42 | 43 | When creating a pull request, its comment should reference the corresponding issue id. 44 | 45 | **Have fun and enjoy hacking!** 46 | -------------------------------------------------------------------------------- /test/retry.test.js: -------------------------------------------------------------------------------- 1 | const { operation, timeouts } = require("../dist/retry"); 2 | const assert = require("assert"); 3 | const sinon = require("sinon"); 4 | 5 | const baseOptions = { 6 | retries: 10, 7 | factor: 2, 8 | minTimeout: 1000, 9 | maxTimeout: 10000000, 10 | randomize: false 11 | }; 12 | 13 | describe("Retry", function() { 14 | let clock; 15 | 16 | beforeEach(() => { 17 | clock = sinon.useFakeTimers(); 18 | }); 19 | 20 | afterEach(() => { 21 | clock.restore(); 22 | }); 23 | it("should attempt the operation", () => { 24 | const fn = sinon.spy(); 25 | const op = operation(baseOptions, fn); 26 | op.attempt(); 27 | sinon.assert.called(fn); 28 | }); 29 | 30 | it("should retry a failed operation", done => { 31 | const error = new Error("some error"); 32 | let attempts = 0; 33 | const op = operation({ ...baseOptions, retries: 3 }, currentAttempt => { 34 | attempts++; 35 | assert.equal(currentAttempt, attempts); 36 | if (op.retry(error)) { 37 | clock.tick(baseOptions.maxTimeout); 38 | return; 39 | } 40 | 41 | assert.strictEqual(attempts, 4); 42 | done(); 43 | }); 44 | op.attempt(); 45 | }); 46 | 47 | describe("timeout generation", () => { 48 | it("should work with default values", () => { 49 | const calculatedTimeouts = timeouts(baseOptions); 50 | 51 | assert.equal(calculatedTimeouts.length, 10); 52 | assert.equal(calculatedTimeouts[0], 1000); 53 | assert.equal(calculatedTimeouts[1], 2000); 54 | assert.equal(calculatedTimeouts[2], 4000); 55 | }); 56 | it("should work with randomize", () => { 57 | const minTimeout = 5000; 58 | const calculatedTimeouts = timeouts({ 59 | ...baseOptions, 60 | minTimeout: minTimeout, 61 | randomize: true 62 | }); 63 | 64 | assert.equal(calculatedTimeouts.length, 10); 65 | assert.ok(calculatedTimeouts[0] > minTimeout); 66 | assert.ok(calculatedTimeouts[1] > calculatedTimeouts[0]); 67 | assert.ok(calculatedTimeouts[2] > calculatedTimeouts[1]); 68 | }); 69 | it("should work with limits", () => { 70 | const minTimeout = 1000; 71 | const maxTimeout = 10000; 72 | const calculatedTimeouts = timeouts({ 73 | ...baseOptions, 74 | minTimeout, 75 | maxTimeout 76 | }); 77 | 78 | for (let i = 0; i < calculatedTimeouts.length; i++) { 79 | assert.ok(calculatedTimeouts[i] >= minTimeout); 80 | assert.ok(calculatedTimeouts[i] <= maxTimeout); 81 | } 82 | }); 83 | it("should have incremental timeouts", () => { 84 | const calculatedTimeouts = timeouts(baseOptions); 85 | let lastTimeout = calculatedTimeouts[0]; 86 | for (let i = 1; i < calculatedTimeouts.length; i++) { 87 | assert.ok(calculatedTimeouts[i] > lastTimeout); 88 | lastTimeout = calculatedTimeouts[i]; 89 | } 90 | }); 91 | it("should have incremental timeouts for factors less than one", () => { 92 | const calculatedTimeouts = timeouts({ 93 | ...baseOptions, 94 | retries: 3, 95 | factor: 0.5 96 | }); 97 | assert.deepEqual([250, 500, 1000], calculatedTimeouts); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /lib/circuit-breaker.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/yammer/circuit-breaker-js 2 | 3 | export interface CircuitBreakerOptions { 4 | /** milliseconds */ 5 | windowDuration?: number; 6 | numBuckets?: number; 7 | /** milliseconds */ 8 | timeoutDuration?: number; 9 | /** percentage */ 10 | errorThreshold?: number; 11 | /** absolute number */ 12 | volumeThreshold?: number; 13 | 14 | onCircuitOpen?: (m: Metrics) => void; 15 | onCircuitClose?: (m: Metrics) => void; 16 | } 17 | 18 | const enum State { 19 | OPEN, 20 | HALF_OPEN, 21 | CLOSED 22 | } 23 | 24 | export interface Metrics { 25 | totalCount: number; 26 | errorCount: number; 27 | errorPercentage: number; 28 | } 29 | 30 | interface Bucket { 31 | failures: number; 32 | successes: number; 33 | timeouts: number; 34 | shortCircuits: number; 35 | } 36 | 37 | export type Command = (success: () => void, failure: () => void) => void; 38 | 39 | // eslint-disable-next-line @typescript-eslint/no-empty-function 40 | function noop() {} 41 | 42 | export interface CircuitBreakerPublicApi { 43 | run(command: Command, fallback?: () => void): void; 44 | forceClose(): void; 45 | forceOpen(): void; 46 | unforce(): void; 47 | isOpen(): boolean; 48 | } 49 | 50 | export class CircuitBreaker implements CircuitBreakerPublicApi { 51 | public windowDuration: number; 52 | public timeoutDuration: number; 53 | public errorThreshold: number; 54 | public volumeThreshold: number; 55 | 56 | public onCircuitOpen: (m: Metrics) => void; 57 | public onCircuitClose: (m: Metrics) => void; 58 | 59 | private readonly buckets: Bucket[]; 60 | private bucketIndex: number; 61 | private state: State; 62 | private forced?: State; 63 | 64 | constructor(options?: CircuitBreakerOptions) { 65 | options = options || {}; 66 | 67 | this.windowDuration = options.windowDuration || 10000; 68 | this.timeoutDuration = options.timeoutDuration || 3000; 69 | this.errorThreshold = options.errorThreshold || 50; 70 | this.volumeThreshold = options.volumeThreshold || 5; 71 | 72 | this.onCircuitOpen = options.onCircuitOpen || noop; 73 | this.onCircuitClose = options.onCircuitClose || noop; 74 | 75 | this.buckets = []; 76 | const numberOfBuckets = Math.max(1, options.numBuckets || 10); 77 | for (let i = 0; i < numberOfBuckets; ++i) { 78 | this.buckets.push({ 79 | failures: 0, 80 | successes: 0, 81 | timeouts: 0, 82 | shortCircuits: 0 83 | }); 84 | } 85 | this.bucketIndex = 0; 86 | this.state = State.CLOSED; 87 | this.forced = undefined; 88 | 89 | this.startTicker(); 90 | } 91 | 92 | public run(command: Command, fallback?: () => void) { 93 | if (this.isOpen()) { 94 | this.executeFallback(fallback || noop); 95 | } else { 96 | this.executeCommand(command); 97 | } 98 | } 99 | 100 | public forceClose() { 101 | this.forced = this.state; 102 | this.state = State.CLOSED; 103 | } 104 | 105 | public forceOpen() { 106 | this.forced = this.state; 107 | this.state = State.OPEN; 108 | } 109 | 110 | public unforce() { 111 | if (this.forced !== undefined) { 112 | this.state = this.forced; 113 | this.forced = undefined; 114 | } 115 | } 116 | 117 | public isOpen() { 118 | return this.state === State.OPEN; 119 | } 120 | 121 | private startTicker() { 122 | // eslint-disable-next-line @typescript-eslint/no-this-alias 123 | const self = this; 124 | const bucketDuration = this.windowDuration / this.buckets.length; 125 | 126 | const tick = () => { 127 | ++this.bucketIndex; 128 | 129 | // FIXME this is very broken as it means that the time 130 | // till CB is changing to half-open state depends on 131 | // the index of the bucket at the time when it opened. 132 | if (this.bucketIndex >= this.buckets.length) { 133 | this.bucketIndex = 0; 134 | 135 | if (self.isOpen()) { 136 | self.state = State.HALF_OPEN; 137 | } 138 | } 139 | 140 | // Since we are recycling the buckets they need to be 141 | // reset before the can be used again. 142 | const bucket = this.lastBucket(); 143 | bucket.failures = 0; 144 | bucket.successes = 0; 145 | bucket.timeouts = 0; 146 | bucket.shortCircuits = 0; 147 | }; 148 | 149 | setInterval(tick, bucketDuration).unref(); 150 | } 151 | 152 | private lastBucket() { 153 | return this.buckets[this.bucketIndex]; 154 | } 155 | 156 | private executeCommand(command: Command) { 157 | // eslint-disable-next-line @typescript-eslint/no-this-alias 158 | const self = this; 159 | let timeout: NodeJS.Timer | undefined; 160 | 161 | const increment = function(prop: keyof Bucket) { 162 | return function() { 163 | if (!timeout) { 164 | return; 165 | } 166 | 167 | const bucket = self.lastBucket(); 168 | bucket[prop]++; 169 | 170 | if (self.forced == null) { 171 | self.updateState(); 172 | } 173 | 174 | clearTimeout(timeout); 175 | timeout = undefined; 176 | }; 177 | }; 178 | 179 | timeout = setTimeout(increment("timeouts"), this.timeoutDuration); 180 | 181 | command(increment("successes"), increment("failures")); 182 | } 183 | 184 | private executeFallback(fallback: () => void) { 185 | fallback(); 186 | 187 | const bucket = this.lastBucket(); 188 | bucket.shortCircuits++; 189 | } 190 | 191 | private calculateMetrics() { 192 | let totalCount = 0; 193 | let errorCount = 0; 194 | 195 | for (const bucket of this.buckets) { 196 | const errors = bucket.failures + bucket.timeouts; 197 | 198 | errorCount += errors; 199 | totalCount += errors + bucket.successes; 200 | } 201 | 202 | const errorPercentage = 203 | (errorCount / (totalCount > 0 ? totalCount : 1)) * 100; 204 | 205 | return { 206 | totalCount, 207 | errorCount, 208 | errorPercentage 209 | }; 210 | } 211 | 212 | private updateState() { 213 | const metrics = this.calculateMetrics(); 214 | 215 | if (this.state == State.HALF_OPEN) { 216 | const lastCommandFailed = 217 | !this.lastBucket().successes && metrics.errorCount > 0; 218 | 219 | if (lastCommandFailed) { 220 | this.state = State.OPEN; 221 | } else { 222 | this.state = State.CLOSED; 223 | this.onCircuitClose(metrics); 224 | } 225 | } else { 226 | const overErrorThreshold = metrics.errorPercentage > this.errorThreshold; 227 | const overVolumeThreshold = metrics.totalCount > this.volumeThreshold; 228 | const overThreshold = overVolumeThreshold && overErrorThreshold; 229 | 230 | if (overThreshold) { 231 | this.state = State.OPEN; 232 | this.onCircuitOpen(metrics); 233 | } 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /test/circuit-breaker.test.js: -------------------------------------------------------------------------------- 1 | const { CircuitBreaker } = require("../dist/circuit-breaker"); 2 | const assert = require("assert"); 3 | const sinon = require("sinon"); 4 | 5 | describe("CircuitBreaker", function() { 6 | let breaker; 7 | let clock; 8 | 9 | const success = function() { 10 | const command = function(success) { 11 | success(); 12 | }; 13 | 14 | breaker.run(command); 15 | }; 16 | 17 | const fail = function() { 18 | const command = function(success, failed) { 19 | failed(); 20 | }; 21 | 22 | breaker.run(command); 23 | }; 24 | 25 | const timeout = function() { 26 | // eslint-disable-next-line @typescript-eslint/no-empty-function 27 | const command = function() {}; 28 | breaker.run(command); 29 | 30 | clock.tick(1000); 31 | clock.tick(1000); 32 | clock.tick(1000); 33 | }; 34 | 35 | beforeEach(function() { 36 | clock = sinon.useFakeTimers(); 37 | breaker = new CircuitBreaker(); 38 | }); 39 | 40 | afterEach(function() { 41 | clock.restore(); 42 | }); 43 | 44 | describe("with a working service", function() { 45 | it("should run the command", function() { 46 | const command = sinon.spy(); 47 | breaker.run(command); 48 | 49 | sinon.assert.called(command); 50 | }); 51 | 52 | it("should be able to notify the breaker if the command was successful", function() { 53 | success(); 54 | 55 | const bucket = breaker.lastBucket(); 56 | assert.strictEqual(bucket.successes, 1); 57 | }); 58 | 59 | it("should be able to notify the breaker if the command failed", function() { 60 | fail(); 61 | 62 | const bucket = breaker.lastBucket(); 63 | assert.strictEqual(bucket.failures, 1); 64 | }); 65 | 66 | it("should record a timeout if not a success or failure", function() { 67 | timeout(); 68 | 69 | const bucket = breaker.lastBucket(); 70 | assert.strictEqual(bucket.timeouts, 1); 71 | }); 72 | 73 | it("should not call timeout if there is a success", function() { 74 | success(); 75 | 76 | clock.tick(1000); 77 | clock.tick(1000); 78 | clock.tick(1000); 79 | 80 | const bucket = breaker.lastBucket(); 81 | assert.strictEqual(bucket.timeouts, 0); 82 | }); 83 | 84 | it("should not call timeout if there is a failure", function() { 85 | fail(); 86 | 87 | clock.tick(1000); 88 | clock.tick(1000); 89 | clock.tick(1000); 90 | 91 | const bucket = breaker.lastBucket(); 92 | assert.strictEqual(bucket.timeouts, 0); 93 | }); 94 | 95 | it("should not record a success when there is a timeout", function() { 96 | const command = function(success) { 97 | clock.tick(1000); 98 | clock.tick(1000); 99 | clock.tick(1000); 100 | 101 | success(); 102 | }; 103 | 104 | breaker.run(command); 105 | 106 | const bucket = breaker.lastBucket(); 107 | assert.strictEqual(bucket.successes, 0); 108 | }); 109 | 110 | it("should not record a failure when there is a timeout", function() { 111 | const command = function(success, fail) { 112 | clock.tick(1000); 113 | clock.tick(1000); 114 | clock.tick(1000); 115 | 116 | fail(); 117 | }; 118 | 119 | breaker.run(command); 120 | 121 | const bucket = breaker.lastBucket(); 122 | assert.strictEqual(bucket.failures, 0); 123 | }); 124 | }); 125 | 126 | describe("with a broken service", function() { 127 | beforeEach(function() { 128 | sinon.stub(breaker, "isOpen").returns(true); 129 | }); 130 | 131 | it("should not run the command", function() { 132 | const command = sinon.spy(); 133 | breaker.run(command); 134 | 135 | sinon.assert.notCalled(command); 136 | }); 137 | 138 | it("should run the fallback if one is provided", function() { 139 | const command = sinon.spy(); 140 | const fallback = sinon.spy(); 141 | 142 | breaker.run(command, fallback); 143 | 144 | sinon.assert.called(fallback); 145 | }); 146 | 147 | it("should record a short circuit", function() { 148 | const command = sinon.spy(); 149 | breaker.run(command); 150 | 151 | sinon.assert.notCalled(command); 152 | 153 | const bucket = breaker.lastBucket(); 154 | assert.strictEqual(bucket.shortCircuits, 1); 155 | }); 156 | }); 157 | 158 | describe("isOpen", function() { 159 | it("should be false if errors are below the threshold", function() { 160 | breaker.errorThreshold = 75; 161 | 162 | fail(); 163 | fail(); 164 | fail(); 165 | success(); 166 | 167 | assert.strictEqual(breaker.isOpen(), false); 168 | }); 169 | 170 | it("should be true if errors are above the threshold", function() { 171 | breaker.errorThreshold = 75; 172 | 173 | fail(); 174 | fail(); 175 | fail(); 176 | fail(); 177 | fail(); 178 | success(); 179 | 180 | assert.strictEqual(breaker.isOpen(), true); 181 | }); 182 | 183 | it("should be true if timeouts are above the threshold", function() { 184 | breaker.errorThreshold = 25; 185 | breaker.volumeThreshold = 1; 186 | 187 | timeout(); 188 | timeout(); 189 | success(); 190 | 191 | assert.strictEqual(breaker.isOpen(), true); 192 | }); 193 | 194 | it("should maintain failed state after window has passed", function() { 195 | breaker.errorThreshold = 25; 196 | breaker.volumeThreshold = 1; 197 | 198 | fail(); 199 | fail(); 200 | fail(); 201 | fail(); 202 | 203 | clock.tick(11001); 204 | 205 | fail(); 206 | 207 | assert.strictEqual(breaker.isOpen(), true); 208 | }); 209 | 210 | it("should retry after window has elapsed", function() { 211 | fail(); 212 | fail(); 213 | fail(); 214 | fail(); 215 | 216 | clock.tick(11001); 217 | 218 | const command = sinon.spy(); 219 | breaker.run(command); 220 | 221 | sinon.assert.called(command); 222 | }); 223 | 224 | it("should include errors within the current time window", function() { 225 | breaker.errorThreshold = 75; 226 | 227 | fail(); 228 | fail(); 229 | fail(); 230 | fail(); 231 | fail(); 232 | success(); 233 | 234 | clock.tick(1001); 235 | 236 | assert.strictEqual(breaker.isOpen(), true); 237 | }); 238 | 239 | it("should not be broken without having more than minimum number of errors", function() { 240 | breaker.errorThreshold = 25; 241 | breaker.volumeThreshold = 1; 242 | 243 | fail(); 244 | 245 | assert.strictEqual(breaker.isOpen(), false); 246 | }); 247 | }); 248 | 249 | describe("logging", function() { 250 | let openSpy; 251 | let closeSpy; 252 | 253 | beforeEach(function() { 254 | openSpy = sinon.spy(); 255 | closeSpy = sinon.spy(); 256 | 257 | breaker.volumeThreshold = 1; 258 | breaker.onCircuitOpen = openSpy; 259 | breaker.onCircuitClose = closeSpy; 260 | }); 261 | 262 | it("should call the onCircuitOpen method when a failure is recorded", function() { 263 | fail(); 264 | fail(); 265 | 266 | sinon.assert.called(openSpy); 267 | }); 268 | 269 | it("should call the onCircuitClosed method when the break is successfully reset", function() { 270 | fail(); 271 | fail(); 272 | fail(); 273 | fail(); 274 | 275 | clock.tick(11001); 276 | 277 | success(); 278 | 279 | sinon.assert.called(closeSpy); 280 | }); 281 | }); 282 | 283 | describe("forceClose", function() { 284 | it("should bypass threshold checks", function() { 285 | fail(); 286 | fail(); 287 | fail(); 288 | fail(); 289 | fail(); 290 | fail(); 291 | 292 | breaker.forceClose(); 293 | 294 | const command = sinon.spy(); 295 | breaker.run(command); 296 | 297 | sinon.assert.called(command); 298 | assert.strictEqual(breaker.isOpen(), false); 299 | }); 300 | 301 | it("should not collect stats", function() { 302 | fail(); 303 | fail(); 304 | fail(); 305 | fail(); 306 | fail(); 307 | fail(); 308 | 309 | breaker.forceClose(); 310 | success(); 311 | success(); 312 | success(); 313 | success(); 314 | success(); 315 | 316 | const command = sinon.spy(); 317 | breaker.run(command); 318 | 319 | sinon.assert.called(command); 320 | assert.strictEqual(breaker.isOpen(), false); 321 | }); 322 | }); 323 | 324 | describe("forceOpen", function() { 325 | it("should bypass threshold checks", function() { 326 | success(); 327 | success(); 328 | success(); 329 | success(); 330 | success(); 331 | success(); 332 | 333 | breaker.forceOpen(); 334 | 335 | const command = sinon.spy(); 336 | breaker.run(command); 337 | 338 | sinon.assert.notCalled(command); 339 | assert.strictEqual(breaker.isOpen(), true); 340 | }); 341 | 342 | it("should not collect stats", function() { 343 | success(); 344 | success(); 345 | success(); 346 | success(); 347 | success(); 348 | success(); 349 | 350 | breaker.forceOpen(); 351 | fail(); 352 | fail(); 353 | fail(); 354 | fail(); 355 | fail(); 356 | 357 | const command = sinon.spy(); 358 | breaker.run(command); 359 | 360 | sinon.assert.notCalled(command); 361 | assert.strictEqual(breaker.isOpen(), true); 362 | }); 363 | }); 364 | 365 | describe("unforce", function() { 366 | it("should recover from a force-closed circuit", function() { 367 | fail(); 368 | fail(); 369 | fail(); 370 | fail(); 371 | fail(); 372 | fail(); 373 | 374 | breaker.forceClose(); 375 | breaker.unforce(); 376 | 377 | const command = sinon.spy(); 378 | breaker.run(command); 379 | 380 | sinon.assert.notCalled(command); 381 | assert.strictEqual(breaker.isOpen(), true); 382 | }); 383 | 384 | it("should recover from a force-open circuit", function() { 385 | success(); 386 | success(); 387 | success(); 388 | success(); 389 | success(); 390 | success(); 391 | 392 | breaker.forceOpen(); 393 | breaker.unforce(); 394 | 395 | const command = sinon.spy(); 396 | breaker.run(command); 397 | 398 | sinon.assert.called(command); 399 | assert.strictEqual(breaker.isOpen(), false); 400 | }); 401 | }); 402 | }); 403 | -------------------------------------------------------------------------------- /lib/request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IncomingHttpHeaders, 3 | IncomingMessage, 4 | request as httpRequest 5 | } from "http"; 6 | import { request as httpsRequest, RequestOptions } from "https"; 7 | import * as querystring from "querystring"; 8 | import * as zlib from "zlib"; 9 | import { ServiceClientError } from "./client"; 10 | import { Socket } from "net"; 11 | import { Readable } from "stream"; 12 | 13 | const DEFAULT_READ_TIMEOUT = 2000; 14 | const DEFAULT_CONNECTION_TIMEOUT = 1000; 15 | 16 | const getInterval = (time: [number, number]): number => { 17 | const diff = process.hrtime(time); 18 | return Math.round(diff[0] * 1000 + diff[1] / 1000000); 19 | }; 20 | 21 | export const enum EventSource { 22 | HTTP_RESPONSE = "http_response", 23 | HTTP_REQUEST = "http_request", 24 | HTTP_RESPONSE_BODY_STREAM = "http_response_body_stream", 25 | SOCKET = "socket" 26 | } 27 | 28 | export const enum EventName { 29 | START = "start", 30 | END = "end", 31 | DNS = "dns", 32 | TLS = "tls", 33 | TIMEOUT = "timeout", 34 | ERROR = "error", 35 | BYTES = "bytes" 36 | } 37 | 38 | /** 39 | * The NodeJS typescript definitions has OutgoingHttpHeaders 40 | * with `undefined` as one of the possible values, but NodeJS 41 | * runtime will throw an error for undefined values in headers. 42 | * 43 | * This overwrites the headers type in the RequestOptions 44 | * and removes undefined from one of the possible values of headers. 45 | */ 46 | export interface OutgoingHttpHeaders { 47 | [header: string]: number | string | string[]; 48 | } 49 | 50 | interface Span { 51 | log(keyValuePairs: { [key: string]: any }, timestamp?: number): this; 52 | } 53 | 54 | export interface ServiceClientRequestOptions extends RequestOptions { 55 | pathname: string; 56 | query?: object; 57 | timing?: boolean; 58 | autoDecodeUtf8?: boolean; 59 | dropRequestAfter?: number; 60 | body?: any; 61 | headers?: OutgoingHttpHeaders; 62 | /** 63 | * Happens when the socket connection cannot be established 64 | */ 65 | timeout?: number; 66 | /** 67 | * Happens after the socket connection is successfully established 68 | * and there is no activity on that socket 69 | */ 70 | readTimeout?: number; 71 | /** 72 | * Opentracing like span interface to log events 73 | */ 74 | span?: Span; 75 | } 76 | 77 | export class ServiceClientResponse { 78 | public timings?: Timings; 79 | public timingPhases?: TimingPhases; 80 | public retryErrors: ServiceClientError[]; 81 | constructor( 82 | public statusCode: number, 83 | public headers: IncomingHttpHeaders, 84 | public body: Buffer | string | object | object[], 85 | public request: ServiceClientRequestOptions 86 | ) { 87 | this.retryErrors = []; 88 | } 89 | } 90 | 91 | export interface Timings { 92 | lookup?: number; 93 | socket?: number; 94 | connect?: number; 95 | secureConnect?: number; 96 | response?: number; 97 | end?: number; 98 | } 99 | export interface TimingPhases { 100 | wait?: number; 101 | dns?: number; 102 | tcp?: number; 103 | tls?: number; 104 | firstByte?: number; 105 | download?: number; 106 | total?: number; 107 | } 108 | 109 | const subtract = (a?: number, b?: number): number | undefined => { 110 | if (typeof a === "number" && typeof b === "number") { 111 | return a - b; 112 | } 113 | return undefined; 114 | }; 115 | 116 | export class RequestError extends Error { 117 | public timings?: Timings; 118 | public timingPhases?: TimingPhases; 119 | public requestOptions: ServiceClientRequestOptions; 120 | constructor( 121 | message: string, 122 | requestOptions: ServiceClientRequestOptions, 123 | timings?: Timings 124 | ) { 125 | super(message); 126 | this.requestOptions = requestOptions; 127 | this.timings = timings; 128 | this.timingPhases = timings && makeTimingPhases(timings); 129 | } 130 | } 131 | 132 | export class NetworkError extends RequestError { 133 | constructor( 134 | originalError: Error, 135 | requestOptions: ServiceClientRequestOptions, 136 | timings?: Timings 137 | ) { 138 | super(originalError.message, requestOptions, timings); 139 | this.stack = originalError.stack; 140 | } 141 | } 142 | 143 | export class ConnectionTimeoutError extends RequestError { 144 | constructor(requestOptions: ServiceClientRequestOptions, timings?: Timings) { 145 | super("socket timeout", requestOptions, timings); 146 | } 147 | } 148 | 149 | export class ReadTimeoutError extends RequestError { 150 | constructor(requestOptions: ServiceClientRequestOptions, timings?: Timings) { 151 | super("read timeout", requestOptions, timings); 152 | } 153 | } 154 | 155 | export class UserTimeoutError extends RequestError { 156 | constructor(requestOptions: ServiceClientRequestOptions, timings?: Timings) { 157 | super("request timeout", requestOptions, timings); 158 | } 159 | } 160 | 161 | export class BodyStreamError extends RequestError { 162 | constructor( 163 | originalError: Error, 164 | requestOptions: ServiceClientRequestOptions, 165 | timings?: Timings 166 | ) { 167 | super(originalError.message, requestOptions, timings); 168 | this.stack = originalError.stack; 169 | } 170 | } 171 | 172 | const makeTimingPhases = (timings: Timings): TimingPhases => { 173 | return { 174 | wait: timings.socket, 175 | dns: subtract(timings.lookup, timings.socket), 176 | tcp: subtract(timings.connect, timings.lookup), 177 | tls: subtract(timings.secureConnect, timings.connect), 178 | firstByte: subtract(timings.response, timings.secureConnect), 179 | download: subtract(timings.end, timings.response), 180 | total: timings.end 181 | }; 182 | }; 183 | 184 | export const request = ( 185 | options: ServiceClientRequestOptions 186 | ): Promise => { 187 | options = { 188 | protocol: "https:", 189 | autoDecodeUtf8: true, 190 | ...options 191 | }; 192 | 193 | function logEvent(source: EventSource, name: EventName, value?: any) { 194 | if (options.span != null) { 195 | options.span.log({ 196 | [source]: value != null ? { name, value } : name 197 | }); 198 | } 199 | } 200 | 201 | if ("pathname" in options && !("path" in options)) { 202 | if ("query" in options) { 203 | let query = querystring.stringify(options.query); 204 | if (query) { 205 | query = "?" + query; 206 | } 207 | options.path = `${options.pathname}${query}`; 208 | } else { 209 | options.path = options.pathname; 210 | } 211 | } 212 | 213 | const connectionTimeout = options.timeout || DEFAULT_CONNECTION_TIMEOUT; 214 | const readTimeout = options.readTimeout || DEFAULT_READ_TIMEOUT; 215 | 216 | const httpRequestFn = 217 | options.protocol === "https:" ? httpsRequest : httpRequest; 218 | return new Promise((resolve, reject: (error: RequestError) => void) => { 219 | let hasRequestEnded = false; 220 | let startTime: [number, number]; 221 | let timings: Timings; 222 | if (options.timing) { 223 | startTime = process.hrtime(); 224 | timings = { 225 | lookup: undefined, 226 | socket: undefined, 227 | connect: undefined, 228 | secureConnect: undefined, 229 | response: undefined, 230 | end: undefined 231 | }; 232 | } 233 | 234 | const requestObject = httpRequestFn(options); 235 | requestObject.setTimeout(readTimeout, () => { 236 | logEvent(EventSource.HTTP_REQUEST, EventName.TIMEOUT); 237 | requestObject.socket.destroy(); 238 | reject(new ReadTimeoutError(options)); 239 | }); 240 | 241 | requestObject.once("error", err => { 242 | hasRequestEnded = true; 243 | logEvent(EventSource.HTTP_REQUEST, EventName.ERROR, err.message); 244 | reject(new NetworkError(err, options)); 245 | }); 246 | 247 | // Fires once the socket is assigned to a request 248 | requestObject.once("socket", (socket: Socket) => { 249 | logEvent(EventSource.SOCKET, EventName.START); 250 | if (options.timing) { 251 | timings.socket = getInterval(startTime); 252 | } 253 | if (socket.connecting) { 254 | socket.setTimeout(connectionTimeout, () => { 255 | logEvent(EventSource.SOCKET, EventName.TIMEOUT); 256 | // socket should be manually cleaned up 257 | socket.destroy(); 258 | reject(new ConnectionTimeoutError(options)); 259 | }); 260 | socket.once("lookup", () => { 261 | logEvent(EventSource.SOCKET, EventName.DNS); 262 | if (options.timing) { 263 | timings.lookup = getInterval(startTime); 264 | } 265 | }); 266 | // connect event would kick in only for new socket connections 267 | // and not for connections that are kept alive 268 | socket.once("connect", () => { 269 | logEvent(EventSource.SOCKET, EventName.END); 270 | if (options.timing) { 271 | timings.connect = getInterval(startTime); 272 | } 273 | }); 274 | socket.once("secureConnect", () => { 275 | logEvent(EventSource.HTTP_REQUEST, EventName.TLS); 276 | if (options.timing) { 277 | timings.secureConnect = getInterval(startTime); 278 | } 279 | }); 280 | } else { 281 | if (options.timing) { 282 | timings.lookup = timings.socket; 283 | timings.connect = timings.socket; 284 | timings.secureConnect = timings.socket; 285 | } 286 | } 287 | }); 288 | 289 | requestObject.on("response", (response: IncomingMessage) => { 290 | logEvent(EventSource.HTTP_RESPONSE, EventName.START); 291 | if (options.timing) { 292 | timings.response = getInterval(startTime); 293 | } 294 | 295 | const { headers, statusCode } = response; 296 | let bodyStream; 297 | 298 | const encoding = headers && headers["content-encoding"]; 299 | if (encoding === "gzip" || encoding === "deflate") { 300 | response.on("error", err => { 301 | logEvent(EventSource.HTTP_RESPONSE, EventName.ERROR, err.message); 302 | reject(new NetworkError(err, options)); 303 | }); 304 | bodyStream = response.pipe(zlib.createUnzip()); 305 | } else { 306 | bodyStream = response; 307 | } 308 | 309 | let chunks: Buffer[] = []; 310 | let bufferLength = 0; 311 | 312 | bodyStream.on("error", err => { 313 | logEvent( 314 | EventSource.HTTP_RESPONSE_BODY_STREAM, 315 | EventName.ERROR, 316 | err.message 317 | ); 318 | reject(new NetworkError(err, options)); 319 | }); 320 | 321 | bodyStream.on("data", data => { 322 | logEvent( 323 | EventSource.HTTP_RESPONSE_BODY_STREAM, 324 | EventName.BYTES, 325 | data.length 326 | ); 327 | bufferLength += data.length; 328 | chunks.push(data as Buffer); 329 | }); 330 | 331 | bodyStream.on("end", () => { 332 | logEvent( 333 | EventSource.HTTP_RESPONSE_BODY_STREAM, 334 | EventName.END, 335 | bufferLength 336 | ); 337 | hasRequestEnded = true; 338 | 339 | let body; 340 | const bufferedBody: Buffer = Buffer.concat(chunks, bufferLength); 341 | if (options.autoDecodeUtf8) { 342 | body = bufferedBody.toString("utf8"); 343 | } else { 344 | body = bufferedBody; 345 | } 346 | 347 | // to avoid leaky behavior 348 | chunks = []; 349 | bufferLength = 0; 350 | 351 | const serviceClientResponse = new ServiceClientResponse( 352 | statusCode || 0, 353 | headers, 354 | body, 355 | options 356 | ); 357 | 358 | if (options.timing) { 359 | timings.end = getInterval(startTime); 360 | serviceClientResponse.timings = timings; 361 | serviceClientResponse.timingPhases = makeTimingPhases(timings); 362 | } 363 | resolve(serviceClientResponse); 364 | logEvent(EventSource.HTTP_RESPONSE, EventName.END); 365 | }); 366 | }); 367 | 368 | if (options.dropRequestAfter) { 369 | setTimeout(() => { 370 | if (!hasRequestEnded) { 371 | requestObject.abort(); 372 | const err = new UserTimeoutError(options, timings); 373 | logEvent(EventSource.HTTP_REQUEST, EventName.ERROR, err.message); 374 | reject(err); 375 | } 376 | }, options.dropRequestAfter); 377 | } 378 | 379 | logEvent(EventSource.HTTP_REQUEST, EventName.START); 380 | if (options.body) { 381 | if (typeof options.body.pipe === "function") { 382 | const requestBody: Readable = options.body; 383 | requestBody.pipe(requestObject); 384 | requestBody.on("error", err => { 385 | requestObject.abort(); 386 | reject(new BodyStreamError(err, options, timings)); 387 | }); 388 | return; 389 | } 390 | requestObject.write(options.body); 391 | } 392 | requestObject.end(); 393 | }); 394 | }; 395 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ATTENTION! This project is not supported anymore. 2 | 3 | [![Build Status](https://github.com/zalando-incubator/perron/workflows/CI/badge.svg?branch=master)](https://github.com/zalando-incubator/perron/actions) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | # Perron 7 | 8 | A sane client for web services with a built-in circuit-breaker, support for filtering both request and response. 9 | ``` 10 | npm install perron --save 11 | ``` 12 | 13 | **[Changelog](https://github.com/zalando-incubator/perron/blob/master/CHANGELOG.md)** 14 | 15 | ## Quick Example 16 | 17 | The following is a minimal example of using `perron` to call a web service: 18 | 19 | ```js 20 | const {ServiceClient} = require('perron'); 21 | 22 | // A separate instance of `perron` is required per host 23 | const catWatch = new ServiceClient('https://catwatch.opensource.zalan.do'); 24 | 25 | catWatch.request({ 26 | pathname: '/projects', 27 | query: { 28 | limit: 10 29 | } 30 | }).then(data => console.log(data)); 31 | ``` 32 | 33 | ## Making Requests 34 | 35 | Each call to `request` method will return a Promise, that would either resolve to a successful `ServiceClient.Response` containing following fields: 36 | 37 | ```js 38 | { 39 | statusCode, // same as Node IncomingMessage.statusCode 40 | headers, // same as Node IncomingMessage.headers 41 | body // if JSON, then parsed body, otherwise a string 42 | } 43 | ``` 44 | 45 | `request` method accepts an objects with all of the same properties as [https.request](https://nodejs.org/api/https.html#https_https_request_options_callback) method in Node.js, except from a `hostname` field, which is taken from the options passed when creating an instance of `ServiceClient`. Additionally you can add a `timeout` and `readTimeout` fields, which define time spans in ms for socket connection and read timeouts. 46 | 47 | ## Handling Errors 48 | 49 | For the error case you will get a custom error type `ServiceClientError`. A custom type is useful in case you change the request to some other processing in your app and then need to distinguish between your app error and requests errors in a final catch. 50 | 51 | `ServiceClientError` class contains an optional `response` field that is available if any response was received before there was an error. 52 | 53 | If you have not specified retry options, you can use `instanceof` checks on the error to determine exact reason: 54 | 55 | ```js 56 | catWatch.request({ 57 | path: '/projects?limit=10' 58 | }).then(console.log, logError); 59 | 60 | function logError(err) { 61 | if (err instanceof BodyParseError) { 62 | console.log('Got a JSON response but parsing it failed'); 63 | console.log('Raw response was', err.response); 64 | } else if (err instanceof RequestFilterError) { 65 | console.log('Request filter failed'); 66 | } else if (err instanceof ResponseFilterError) { 67 | console.log('Response filter failed'); 68 | console.log('Raw response was', err.response); 69 | } else if (err instanceof CircuitOpenError) { 70 | console.log('Circuit breaker is open'); 71 | } else if (err instanceof RequestConnectionTimeoutError) { 72 | console.log('Connection timeout'); 73 | console.log('Request options were', err.requestOptions); 74 | } else if (err instanceof RequestReadTimeoutError) { 75 | console.log('Socket read timeout'); 76 | console.log('Request options were', err.requestOptions); 77 | } else if (err instanceof RequestUserTimeoutError) { 78 | console.log('Request dropped after timeout specified in `dropRequestAfter` option'); 79 | console.log('Request options were', err.requestOptions); 80 | } else if (err instanceof RequestNetworkError) { 81 | console.log('Network error (socket, dns, etc.)'); 82 | console.log('Request options were', err.requestOptions); 83 | } else if (err instanceof InternalError) { 84 | // This error should not happen during normal operations 85 | // and usually indicates a bug in perron or misconfiguration 86 | console.log('Unknown internal error'); 87 | } 88 | } 89 | ``` 90 | 91 | If you have retries configured, there are only 3 types of errors you will get that are relating to circuit breakers and retries, however you can access original errors that led to retries through `retryErrors` field available on both the successful response: 92 | 93 | ```js 94 | 95 | catWatch.request({ 96 | path: '/projects?limit=10' 97 | }).then(function (result) { 98 | console.log("Response was", result.body); 99 | if (result.retryErrors.length) { 100 | console.log("Request successful, but there were retries:"); 101 | result.retryErrors.forEach(logError); 102 | } 103 | }, logError); 104 | 105 | function logRetryError(err) { 106 | if (err instanceof CircuitOpenError) { 107 | console.log('Circuit breaker is open'); 108 | } else if (err instanceof ShouldRetryRejectedError) { 109 | console.log('Provided `shouldRetry` function rejected retry attempt'); 110 | err.retryErrors.forEach(logError); 111 | } else if (err instanceof MaximumRetriesReachedError) { 112 | console.log('Reached maximum retry count'); 113 | err.retryErrors.forEach(logError); 114 | } 115 | } 116 | ``` 117 | 118 | ## Circuit Breaker 119 | 120 | It's almost always a good idea to have a circuit breaker around your service calls, and generally one per hostname is also a good default since 5xx usually means something is wrong with the whole service and not a specific endpoint. 121 | 122 | This is why `perron` by default includes one circuit breaker per instance. Internally `perron` uses [circuit-breaker-js](https://github.com/yammer/circuit-breaker-js), so you can use all of it's options when configuring the breaker: 123 | 124 | ```js 125 | const {ServiceClient} = require('perron'); 126 | 127 | const catWatch = new ServiceClient({ 128 | hostname: 'catwatch.opensource.zalan.do', 129 | // If the "circuitBreaker" settings are passed (non-falsy), they will be merged 130 | // with the default options below. Otherwise, circuit breaking will be disabled 131 | circuitBreaker: { 132 | windowDuration: 10000, 133 | numBuckets: 10, 134 | timeoutDuration: 2000, 135 | errorThreshold: 50, 136 | volumeThreshold: 10 137 | } 138 | }); 139 | ``` 140 | 141 | Optionally the `onCircuitOpen` and `onCircuitClose` functions can be passed to the circuitBreaker object in order to track the state of the circuit breaker via metrics or logging: 142 | 143 | ```js 144 | const catWatch = new ServiceClient({ 145 | hostname: 'catwatch.opensource.zalan.do', 146 | circuitBreaker: { 147 | windowDuration: 10000, 148 | numBuckets: 10, 149 | timeoutDuration: 2000, 150 | errorThreshold: 50, 151 | volumeThreshold: 10, 152 | onCircuitOpen: (metrics) => { 153 | console.log('Circuit breaker open', metrics); 154 | }, 155 | onCircuitClose: (metrics) => { 156 | console.log('Circuit breaker closed', metrics); 157 | } 158 | } 159 | }); 160 | ``` 161 | 162 | Circuit breaker will count all errors, including the ones coming from filters, so it's generally better to do pre- and post- validation of your request outside of filter chain. 163 | 164 | If this is not the desired behavior, or you are already using a circuit breaker, it's always possible to disable the built-in one: 165 | 166 | ```js 167 | const catWatch = new ServiceClient({ 168 | hostname: 'catwatch.opensource.zalan.do', 169 | circuitBreaker: false 170 | }); 171 | ``` 172 | 173 | In case if you want perron to still use a circuit breaker but it has to be provided dynamically by your code on-demand you can pass `circuitBreaker` option as a function (make sure to _not_ create a circuit breaker for every request): 174 | 175 | ```js 176 | const catWatch = new ServiceClient({ 177 | hostname: 'catwatch.opensource.zalan.do', 178 | circuitBreaker: function (request) { 179 | return somePreviouslyConstructedCB; 180 | } 181 | }); 182 | ``` 183 | 184 | ## Retry Logic 185 | 186 | For application critical requests it can be a good idea to retry failed requests to the responsible services. 187 | 188 | Occasionally target server can have high latency for a short period of time, or in the case of a stack of servers, one server can be having issues 189 | and retrying the request will allow perron to attempt to access one of the other servers that currently aren't facing issues. 190 | 191 | By default `perron` has retry logic implemented, but configured to perform 0 retries. Internally `perron` uses [node-retry](https://github.com/tim-kos/node-retry) to handle the retry logic and configuration. All of the existing options provided by `node-retry` can be passed via configuration options through `perron`. 192 | 193 | There is a `shouldRetry` function which can be defined in any way by the consumer and is used in the try logic to determine whether to attempt the retries or not depending on the type of error and the original request object. 194 | If the function returns true and the number of retries hasn't been exceeded, the request can be retried. 195 | 196 | There is also an `onRetry` function which can be defined by the user of `perron`. This function is called every time a retry request will be triggered. 197 | It is provided the current attempt index, the error that is causing the retry and the original request params. 198 | 199 | The first time `onRetry` gets called, the value of currentAttempt will be 2. This is because the first initial request is counted as the first attempt, and the first retry attempted will then be the second request. 200 | 201 | ```js 202 | const {ServiceClient} = require('perron'); 203 | 204 | const catWatch = new ServiceClient({ 205 | hostname: 'catwatch.opensource.zalan.do', 206 | retryOptions: { 207 | retries: 1, 208 | factor: 2, 209 | minTimeout: 200, 210 | maxTimeout: 400, 211 | randomize: true, 212 | shouldRetry(err, req) { 213 | return (err && err.response && err.response.statusCode >= 500); 214 | }, 215 | onRetry(currentAttempt, err, req) { 216 | console.log('Retry attempt #' + currentAttempt + ' for ' + req.path + ' due to ' + err); 217 | } 218 | } 219 | }); 220 | ``` 221 | 222 | ## Filters 223 | 224 | It's quite often necessary to do some pre- or post-processing of the request. For this purpose `perron` implements a concept of filters, that are just an object with 2 optional methods: `request` and `response`. 225 | 226 | By default, every instance of `perron` includes a `treat5xxAsError` filter, but you can specify which filters should be use by providing a `filters` options when constructing an instance. This options expects an array of filter object and is *not* automatically merged with the default ones, so be sure to use `concat` if you want to keep the default filters as well. 227 | 228 | There aren't separate request and response filter chains, so given that we have filters `A`, `B` and `C` the request flow will look like this: 229 | 230 | ``` 231 | A.request ---> B.request ---> C.request ---| 232 | V 233 | HTTP Request 234 | | 235 | A.response <-- B.response <-- C.response <-- 236 | ``` 237 | 238 | If corresponding `request` or `response` method is missing in the filter, it is skipped, and the flow goes to the next one. 239 | 240 | ### Modifying the Request 241 | 242 | Let's say that we want to inject a custom header of the request. This is really easy to do in a request filter: 243 | 244 | ```js 245 | const {ServiceClient} = require('perron'); 246 | 247 | // A separate instance of ServiceClient is required per host 248 | const catWatch = new ServiceClient({ 249 | hostname: 'catwatch.opensource.zalan.do', 250 | filters: [{ 251 | request(request) { 252 | // let's pretend to be AJAX 253 | request.headers['x-requested-with'] = 'XMLHttpRequest'; 254 | return request; 255 | } 256 | }, ServiceClient.treat5xxAsError] 257 | }); 258 | ``` 259 | 260 | ### Resolving Request in a Filter 261 | 262 | Sometimes it is necessary to pretend to have called the service without actually doing it. This could be useful for caching, and is also very easy to implement: 263 | 264 | ```js 265 | const {ServiceClient} = require('perron'); 266 | 267 | const getCache = require('./your-module-with-cache'); 268 | 269 | // A separate instance of ServiceClient is required per host 270 | const catWatch = new ServiceClient({ 271 | hostname: 'catwatch.opensource.zalan.do', 272 | filters: [{ 273 | request(request) { 274 | const body = getCache(request); 275 | if (body) { 276 | const headers = {}; 277 | const statusCode = 200; 278 | return new ServiceClient.Response( 279 | statusCode, headers, body 280 | ); 281 | } 282 | return request; 283 | } 284 | }, ServiceClient.treat5xxAsError] 285 | }); 286 | ``` 287 | 288 | If the request is resolved in such a way, all of the pending filter in the request and response chain will be skipped, so the flow diagram will look like this: 289 | 290 | ``` 291 | called | not called 292 | --------------------------------- 293 | cacheFilter | B.treat5xxAsError 294 | | | 295 | | | HTTP Request 296 | V | 297 | cacheFilter | B.treat5xxAsError 298 | ``` 299 | 300 | ### Rejecting Request in a Filter 301 | 302 | It is possible to reject the request both in request and response filters by throwing, or by returning a rejected Promise. Doing so will be picked up by the circuit breaker, so this behavior should be reserved by the cases where the service returns `5xx` error, or the response is completely invalid (e.g. invalid JSON). 303 | 304 | ### JSON Parsing 305 | 306 | By default Perron will try to parse JSON body if the `content-type` header is not set or 307 | it is specified as `application/json`. If you wish to disable this behavior you can use 308 | `autoParseJson: false` option when constructing `ServiceClient` object. 309 | 310 | ### UTF-8 Decoding 311 | By default Perron will try to decode JSON body to UTF-8 string. 312 | If you wish to disable this behaviour, you can use `autoDecodeUtf8: false` option 313 | when calling `request` method. 314 | 315 | ### Opentracing 316 | 317 | Perron accepts a Span like object where it will log the network and request related events. 318 | 319 | ## License 320 | 321 | The MIT License 322 | 323 | Copyright (c) 2016 Zalando SE 324 | 325 | Permission is hereby granted, free of charge, to any person obtaining a copy 326 | of this software and associated documentation files (the "Software"), to deal 327 | in the Software without restriction, including without limitation the rights 328 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 329 | copies of the Software, and to permit persons to whom the Software is 330 | furnished to do so, subject to the following conditions: 331 | 332 | The above copyright notice and this permission notice shall be included in 333 | all copies or substantial portions of the Software. 334 | 335 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 336 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 337 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 338 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 339 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 340 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 341 | THE SOFTWARE. 342 | 343 | 344 | -------------------------------------------------------------------------------- /test/request.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const proxyquire = require("proxyquire").noCallThru(); 6 | const EventEmitter = require("events"); 7 | const zlib = require("zlib"); 8 | const stream = require("stream"); 9 | 10 | class ResponseStub extends EventEmitter {} 11 | 12 | class RequestStub extends stream.Writable { 13 | constructor() { 14 | super(); 15 | this.setTimeout = sinon.stub(); 16 | this.writtenChunks = []; 17 | } 18 | _write(chunk, _encoding, callback) { 19 | this.writtenChunks.push(chunk); 20 | callback(); 21 | } 22 | } 23 | 24 | class SocketStub extends EventEmitter { 25 | constructor(connecting) { 26 | super(); 27 | this.connecting = connecting; 28 | this.setTimeout = sinon.stub(); 29 | this.destroy = sinon.stub(); 30 | } 31 | } 32 | 33 | class BufferStream extends stream.Readable { 34 | constructor(buffer) { 35 | super(); 36 | this.index = 0; 37 | this.buffer = buffer; 38 | } 39 | 40 | _read() { 41 | if (this.index >= this.buffer.length) { 42 | this.push(null); 43 | return; 44 | } 45 | this.push(this.buffer.slice(this.index, this.index + 1)); 46 | this.index += 1; 47 | } 48 | } 49 | const fail = result => 50 | assert.fail(`expected promise to be rejected, got resolved with ${result}`); 51 | 52 | describe("request", () => { 53 | const httpStub = {}; 54 | const httpsStub = {}; 55 | 56 | let request = proxyquire("../dist/request", { 57 | http: httpStub, 58 | https: httpsStub 59 | }).request; 60 | let requestStub; 61 | let clock; 62 | 63 | beforeEach(() => { 64 | httpStub.request = sinon.stub(); 65 | httpsStub.request = sinon.stub(); 66 | requestStub = new RequestStub(); 67 | httpsStub.request.returns(requestStub); 68 | clock = sinon.useFakeTimers(); 69 | }); 70 | 71 | afterEach(() => { 72 | clock.restore(); 73 | }); 74 | 75 | it("should call https if protocol is not specified", () => { 76 | request(); 77 | assert.equal(httpsStub.request.callCount, 1); 78 | }); 79 | 80 | it("should allow to call http if it is specified as protocol", () => { 81 | httpsStub.request.returns(undefined); 82 | httpStub.request.returns(requestStub); 83 | request({ protocol: "http:" }); 84 | assert.equal(httpStub.request.callCount, 1); 85 | }); 86 | 87 | it("should use pathname as path if none specified", () => { 88 | request({ pathname: "/foo" }); 89 | assert.equal(httpsStub.request.firstCall.args[0].path, "/foo"); 90 | }); 91 | 92 | it("should prefer fully resolved path even if pathname is specified", () => { 93 | request({ 94 | pathname: "/foo", 95 | path: "/bar" 96 | }); 97 | assert.equal(httpsStub.request.firstCall.args[0].path, "/bar"); 98 | }); 99 | 100 | it("should allow to specify query params as an object", () => { 101 | request({ 102 | query: { 103 | foo: "bar", 104 | buz: 42 105 | }, 106 | pathname: "/" 107 | }); 108 | assert.equal(httpsStub.request.firstCall.args[0].path, "/?foo=bar&buz=42"); 109 | }); 110 | 111 | it("should not add a question mark with empty query", () => { 112 | request({ 113 | query: {}, 114 | pathname: "/foo" 115 | }); 116 | assert.equal(httpsStub.request.firstCall.args[0].path, "/foo"); 117 | }); 118 | 119 | it("should return a promise", () => { 120 | assert(typeof request().then, "function"); 121 | }); 122 | 123 | it("should reject a promise if request errors out", () => { 124 | const requestPromise = request(); 125 | requestStub.emit("error", new Error("foo")); 126 | return requestPromise.then(fail, err => assert.equal(err.message, "foo")); 127 | }); 128 | 129 | it("should use the body of the request if one is provided", () => { 130 | requestStub.write = sinon.spy(); 131 | request({ 132 | body: "foobar" 133 | }); 134 | assert.equal(requestStub.write.firstCall.args[0], "foobar"); 135 | }); 136 | 137 | it("should pipe the body stream of the request if one is provied", done => { 138 | const body = new stream.PassThrough(); 139 | request({ 140 | body 141 | }); 142 | requestStub.on("finish", () => { 143 | assert.deepEqual(requestStub.writtenChunks, [ 144 | Buffer.from("foo"), 145 | Buffer.from("bar") 146 | ]); 147 | done(); 148 | }); 149 | body.write("foo"); 150 | body.end("bar"); 151 | }); 152 | 153 | it("should abort the request if the request body stream emits an error", () => { 154 | requestStub.abort = sinon.spy(); 155 | const body = new stream.PassThrough(); 156 | const promise = request({ 157 | body 158 | }); 159 | body.write("foo"); 160 | body.emit("error", new Error("body stream error")); 161 | return promise.then(fail, error => { 162 | assert.equal(error.message, "body stream error"); 163 | assert(requestStub.abort.calledOnce); 164 | }); 165 | }); 166 | 167 | it("should return body of type Buffer when autoDecodeUtf8 is set to false ", () => { 168 | const promise = request({ 169 | autoDecodeUtf8: false 170 | }); 171 | const responseStub = new ResponseStub(); 172 | requestStub.emit("response", responseStub); 173 | responseStub.emit("data", Buffer.from("foo")); 174 | responseStub.emit("data", Buffer.from("bar")); 175 | responseStub.emit("end"); 176 | return promise.then(response => { 177 | assert.deepEqual(response.body, Buffer.from("foobar")); 178 | }); 179 | }); 180 | 181 | it("should resolve the promise with full response on success", () => { 182 | const promise = request(); 183 | const responseStub = new ResponseStub(); 184 | requestStub.emit("response", responseStub); 185 | responseStub.emit("data", Buffer.from("foo")); 186 | responseStub.emit("data", Buffer.from("bar")); 187 | responseStub.emit("end"); 188 | return promise.then(response => { 189 | assert.equal(response.body, "foobar"); 190 | }); 191 | }); 192 | 193 | it("should reject the promise on response error", () => { 194 | const promise = request(); 195 | const responseStub = new ResponseStub(); 196 | requestStub.emit("response", responseStub); 197 | responseStub.emit("data", Buffer.from("foo")); 198 | responseStub.emit("error", new Error("test")); 199 | return promise.then(fail, error => { 200 | assert.equal(error.message, "test"); 201 | }); 202 | }); 203 | 204 | it("should support responses chunked between utf8 boundaries", () => { 205 | const promise = request(); 206 | const responseStub = new ResponseStub(); 207 | requestStub.emit("response", responseStub); 208 | const data = Buffer.from("я"); 209 | responseStub.emit("data", Buffer.from([data[0]])); 210 | responseStub.emit("data", Buffer.from([data[1]])); 211 | responseStub.emit("end"); 212 | return promise.then(response => { 213 | assert.equal(response.body, "я"); 214 | }); 215 | }); 216 | 217 | ["gzip", "deflate"].forEach(encoding => { 218 | it(`should inflate response body with ${encoding} encoding`, () => { 219 | const promise = request(); 220 | const responseStub = new BufferStream(zlib.gzipSync("foobar")); 221 | responseStub.statusCode = 200; 222 | responseStub.headers = { 223 | "content-encoding": "gzip" 224 | }; 225 | requestStub.emit("response", responseStub); 226 | return promise.then(response => { 227 | assert.equal(response.body, "foobar"); 228 | assert.equal(response.statusCode, 200); 229 | assert.equal(response.headers["content-encoding"], "gzip"); 230 | }); 231 | }); 232 | }); 233 | 234 | it("should reject the promise on unzip error", () => { 235 | const promise = request(); 236 | const responseStub = new BufferStream(Buffer.from("not gzipped!")); 237 | responseStub.headers = { 238 | "content-encoding": "gzip" 239 | }; 240 | requestStub.emit("response", responseStub); 241 | return promise.then(fail, error => { 242 | assert.equal(error.message, "incorrect header check"); 243 | }); 244 | }); 245 | 246 | it("should reject the promise on connection timeout", done => { 247 | const timeout = 100; 248 | const host = "example.org"; 249 | 250 | request({ timeout, host }) 251 | .then(fail, error => { 252 | assert.strictEqual(error.message, "socket timeout"); 253 | sinon.assert.calledWith(socketStub.setTimeout.firstCall, timeout); 254 | sinon.assert.calledOnce(socketStub.setTimeout); 255 | sinon.assert.calledOnce(socketStub.destroy); 256 | done(); 257 | }) 258 | .catch(done); 259 | 260 | const socketStub = new SocketStub(true); 261 | requestStub.emit("socket", socketStub); 262 | socketStub.setTimeout.invokeCallback(); 263 | }); 264 | 265 | it("should reject the promise on read timeout", done => { 266 | const readTimeout = 100; 267 | const host = "example.org"; 268 | 269 | request({ readTimeout, host }) 270 | .then(fail, error => { 271 | assert.strictEqual(error.message, "read timeout"); 272 | sinon.assert.calledWith(requestStub.setTimeout.firstCall, readTimeout); 273 | sinon.assert.calledOnce(requestStub.setTimeout); 274 | sinon.assert.calledOnce(requestStub.socket.destroy); 275 | done(); 276 | }) 277 | .catch(done); 278 | 279 | const socketStub = new SocketStub(false); 280 | requestStub.socket = socketStub; 281 | requestStub.emit("socket", socketStub); 282 | requestStub.setTimeout.invokeCallback(); 283 | }); 284 | 285 | it("should resolve the promise when response finishes in time", () => { 286 | requestStub.abort = sinon.stub(); 287 | const responseStub = new ResponseStub(); 288 | const promise = request({ dropRequestAfter: 500 }); 289 | clock.tick(100); 290 | requestStub.emit("response", responseStub); 291 | clock.tick(100); 292 | responseStub.emit("data", Buffer.from("hello")); 293 | clock.tick(100); 294 | responseStub.emit("end"); 295 | return promise.then(response => { 296 | assert.equal(response.body, "hello"); 297 | assert(!requestStub.abort.called); 298 | }); 299 | }); 300 | 301 | it("should resolve the promise when response finishes in time without data", () => { 302 | requestStub.abort = sinon.stub(); 303 | const responseStub = new ResponseStub(); 304 | const promise = request({ dropRequestAfter: 500 }); 305 | clock.tick(100); 306 | requestStub.emit("response", responseStub); 307 | clock.tick(100); 308 | responseStub.emit("end"); 309 | return promise.then(response => { 310 | assert.equal(response.body, ""); 311 | assert(!requestStub.abort.called); 312 | }); 313 | }); 314 | 315 | it("should attach the request options to the response", () => { 316 | requestStub.abort = sinon.stub(); 317 | const responseStub = new ResponseStub(); 318 | const requestOptions = { 319 | test: "item" 320 | }; 321 | const promise = request(requestOptions); 322 | clock.tick(100); 323 | requestStub.emit("response", responseStub); 324 | clock.tick(100); 325 | responseStub.emit("end"); 326 | return promise.then(response => { 327 | assert.equal(response.request.test, requestOptions.test); 328 | assert(!requestStub.abort.called); 329 | }); 330 | }); 331 | 332 | it("should reject the promise when response arrives but does not finish in time", () => { 333 | requestStub.abort = sinon.stub(); 334 | const responseStub = new ResponseStub(); 335 | responseStub.destroy = sinon.stub(); 336 | const promise = request({ dropRequestAfter: 500 }); 337 | clock.tick(100); 338 | requestStub.emit("response", responseStub); 339 | clock.tick(100); 340 | responseStub.emit("data", "hello"); 341 | clock.tick(300); 342 | return promise.then(fail, error => { 343 | assert.equal(error.message, "request timeout"); 344 | assert(requestStub.abort.called); 345 | }); 346 | }); 347 | 348 | it("should reject the promise when response does not arrive in time", () => { 349 | requestStub.abort = sinon.stub(); 350 | httpsStub.request.returns(requestStub); 351 | const promise = request({ dropRequestAfter: 500 }); 352 | clock.tick(500); 353 | return promise.then(fail, error => { 354 | assert.equal(error.message, "request timeout"); 355 | assert(requestStub.abort.calledOnce); 356 | }); 357 | }); 358 | 359 | it("should not abort the request on request error", () => { 360 | requestStub.abort = sinon.stub(); 361 | httpsStub.request.returns(requestStub); 362 | const promise = request({ dropRequestAfter: 500 }); 363 | requestStub.emit("error", new Error("request failed")); 364 | clock.tick(500); 365 | return promise.then(fail, () => { 366 | assert(requestStub.abort.notCalled); 367 | }); 368 | }); 369 | 370 | it("should record timings for non-keep-alive connection", () => { 371 | const promise = request({ timing: true }); 372 | const socketStub = new SocketStub(true); 373 | clock.tick(10); 374 | requestStub.emit("socket", socketStub); 375 | clock.tick(20); 376 | socketStub.emit("lookup"); 377 | clock.tick(30); 378 | socketStub.emit("connect"); 379 | clock.tick(40); 380 | socketStub.emit("secureConnect"); 381 | clock.tick(50); 382 | const responseStub = new ResponseStub(); 383 | requestStub.emit("response", responseStub); 384 | clock.tick(60); 385 | responseStub.emit("data", Buffer.from("hello")); 386 | responseStub.emit("end"); 387 | return promise.then(response => { 388 | assert.deepEqual(response.timings, { 389 | socket: 10, 390 | lookup: 30, 391 | connect: 60, 392 | secureConnect: 100, 393 | response: 150, 394 | end: 210 395 | }); 396 | assert.deepEqual(response.timingPhases, { 397 | wait: 10, 398 | dns: 20, 399 | tcp: 30, 400 | tls: 40, 401 | firstByte: 50, 402 | download: 60, 403 | total: 210 404 | }); 405 | }); 406 | }); 407 | 408 | it("should record timings for keep-alive connection", () => { 409 | const promise = request({ timing: true }); 410 | const socketStub = new SocketStub(false); 411 | clock.tick(10); 412 | requestStub.emit("socket", socketStub); 413 | clock.tick(20); 414 | const responseStub = new ResponseStub(); 415 | requestStub.emit("response", responseStub); 416 | clock.tick(30); 417 | responseStub.emit("data", Buffer.from("hello")); 418 | responseStub.emit("end"); 419 | return promise.then(response => { 420 | assert.deepEqual(response.timings, { 421 | socket: 10, 422 | lookup: 10, 423 | connect: 10, 424 | secureConnect: 10, 425 | response: 30, 426 | end: 60 427 | }); 428 | assert.deepEqual(response.timingPhases, { 429 | wait: 10, 430 | dns: 0, 431 | tcp: 0, 432 | tls: 0, 433 | firstByte: 20, 434 | download: 30, 435 | total: 60 436 | }); 437 | }); 438 | }); 439 | 440 | it("should record timings for timeout", () => { 441 | requestStub.abort = sinon.stub(); 442 | const responseStub = new ResponseStub(); 443 | responseStub.destroy = sinon.stub(); 444 | const promise = request({ timing: true, dropRequestAfter: 500 }); 445 | const socketStub = new SocketStub(false); 446 | clock.tick(10); 447 | requestStub.emit("socket", socketStub); 448 | clock.tick(90); 449 | requestStub.emit("response", responseStub); 450 | clock.tick(100); 451 | responseStub.emit("data", "hello"); 452 | clock.tick(300); 453 | return promise.then(fail, error => { 454 | assert.equal(error.message, "request timeout"); 455 | assert(requestStub.abort.called); 456 | assert.deepEqual(error.timings, { 457 | lookup: 10, 458 | socket: 10, 459 | connect: 10, 460 | secureConnect: 10, 461 | response: 100, 462 | end: undefined 463 | }); 464 | assert.deepEqual(error.timingPhases, { 465 | wait: 10, 466 | dns: 0, 467 | tcp: 0, 468 | tls: 0, 469 | firstByte: 90, 470 | download: undefined, 471 | total: undefined 472 | }); 473 | }); 474 | }); 475 | 476 | it("should log in the span object", () => { 477 | const logSpy = sinon.spy(); 478 | request({ span: { log: logSpy } }); 479 | const socketStub = new SocketStub(true); 480 | requestStub.emit("socket", socketStub); 481 | socketStub.emit("lookup"); 482 | socketStub.emit("connect"); 483 | const responseStub = new ResponseStub(); 484 | requestStub.emit("response", responseStub); 485 | responseStub.emit("data", Buffer.from("hello")); 486 | sinon.assert.calledWith(logSpy, sinon.match.has("socket")); 487 | sinon.assert.calledWith(logSpy, sinon.match.has("http_response")); 488 | sinon.assert.calledWith( 489 | logSpy, 490 | sinon.match.has("http_response_body_stream") 491 | ); 492 | }); 493 | }); 494 | -------------------------------------------------------------------------------- /lib/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CircuitBreaker, 3 | CircuitBreakerOptions, 4 | Metrics as CircuitBreakerMetrics, 5 | CircuitBreakerPublicApi 6 | } from "./circuit-breaker"; 7 | import { operation } from "./retry"; 8 | import * as url from "url"; 9 | import { 10 | ConnectionTimeoutError, 11 | NetworkError, 12 | ReadTimeoutError, 13 | request, 14 | RequestError, 15 | ServiceClientRequestOptions, 16 | ServiceClientResponse, 17 | TimingPhases, 18 | Timings, 19 | UserTimeoutError, 20 | BodyStreamError 21 | } from "./request"; 22 | 23 | export { 24 | CircuitBreaker, 25 | CircuitBreakerOptions, 26 | CircuitBreakerMetrics, 27 | CircuitBreakerPublicApi, 28 | ServiceClientResponse, 29 | ServiceClientRequestOptions 30 | }; 31 | 32 | /** 33 | * This interface defines factory function for getting a circuit breaker 34 | */ 35 | export type CircuitBreakerFactory = ( 36 | params: ServiceClientRequestOptions 37 | ) => CircuitBreaker; 38 | 39 | /** 40 | * A request filter may introduce one or both functions in this interface. For more information 41 | * regarding request filters, refer to the readme of this project. 42 | */ 43 | export interface ServiceClientRequestFilter { 44 | /** 45 | * This callback is called before the requests is done. 46 | * You can short-circuit the request by both returning 47 | * a ServiceClient.Response object which is helpful for 48 | * implementing caching or mocking. You could also 49 | * fail the request by throwing an Error. 50 | * @throws {Error} 51 | */ 52 | request?: ( 53 | requestOptions: ServiceClientRequestOptions 54 | ) => 55 | | ServiceClientResponse 56 | | ServiceClientRequestOptions 57 | | Promise; 58 | /** 59 | * This callback is called after the response has arrived. 60 | * @throws {Error} 61 | */ 62 | response?: ( 63 | response: ServiceClientResponse 64 | ) => ServiceClientResponse | Promise; 65 | } 66 | 67 | /** 68 | * This interface describes all the options that may be passed to the service client at construction time. 69 | */ 70 | export class ServiceClientOptions { 71 | /** 72 | * This is the only mandatory option when creating a service client. All other properties have 73 | * reasonable defaults. 74 | */ 75 | public hostname = ""; 76 | /** 77 | * Name of the client. Primarily used in errors. Defaults to hostname. 78 | */ 79 | public name?: string; 80 | /** 81 | * If this property is not provided, the {@link ServiceClient.DEFAULT_FILTERS} will be used. 82 | */ 83 | public filters?: ServiceClientRequestFilter[]; 84 | /** 85 | * should the service client record request timings? 86 | */ 87 | public timing?: boolean; 88 | public autoParseJson?: boolean; 89 | public retryOptions?: { 90 | retries?: number; 91 | factor?: number; 92 | minTimeout?: number; 93 | maxTimeout?: number; 94 | randomize?: boolean; 95 | shouldRetry?: ( 96 | err?: ServiceClientError, 97 | req?: ServiceClientRequestOptions 98 | ) => boolean; 99 | onRetry?: ( 100 | currentAttempt?: number, 101 | err?: ServiceClientError, 102 | req?: ServiceClientRequestOptions 103 | ) => void; 104 | }; 105 | public circuitBreaker?: false | CircuitBreakerOptions | CircuitBreakerFactory; 106 | public defaultRequestOptions?: Partial; 107 | } 108 | 109 | /** 110 | * Internal only, this interface guarantees, that the service client has all options available at runtime. 111 | */ 112 | class ServiceClientStrictOptions { 113 | public hostname: string; 114 | public filters: ServiceClientRequestFilter[]; 115 | public timing: boolean; 116 | public autoParseJson: boolean; 117 | public retryOptions: { 118 | retries: number; 119 | factor: number; 120 | minTimeout: number; 121 | maxTimeout: number; 122 | randomize: boolean; 123 | shouldRetry: ( 124 | err?: ServiceClientError, 125 | req?: ServiceClientRequestOptions 126 | ) => boolean; 127 | onRetry: ( 128 | currentAttempt?: number, 129 | err?: ServiceClientError, 130 | req?: ServiceClientRequestOptions 131 | ) => void; 132 | }; 133 | public defaultRequestOptions: ServiceClientRequestOptions; 134 | 135 | constructor(options: ServiceClientOptions) { 136 | if (!options.hostname) { 137 | throw new Error("Please provide a `hostname` for this client"); 138 | } 139 | 140 | this.hostname = options.hostname; 141 | this.filters = Array.isArray(options.filters) 142 | ? options.filters 143 | : [...ServiceClient.DEFAULT_FILTERS]; 144 | this.timing = Boolean(options.timing); 145 | const autoParseJson = options.autoParseJson; 146 | this.autoParseJson = autoParseJson === undefined ? true : autoParseJson; 147 | this.retryOptions = { 148 | factor: 2, 149 | maxTimeout: 400, 150 | minTimeout: 200, 151 | randomize: true, 152 | retries: 0, 153 | shouldRetry() { 154 | return true; 155 | }, 156 | onRetry() { 157 | /* do nothing */ 158 | }, 159 | ...options.retryOptions 160 | }; 161 | 162 | if ( 163 | (this.retryOptions.minTimeout || 0) > (this.retryOptions.maxTimeout || 0) 164 | ) { 165 | throw new TypeError( 166 | "The `maxTimeout` must be equal to or greater than the `minTimeout`" 167 | ); 168 | } 169 | 170 | this.defaultRequestOptions = { 171 | pathname: "/", 172 | protocol: "https:", 173 | timeout: 2000, 174 | ...options.defaultRequestOptions 175 | }; 176 | } 177 | } 178 | 179 | /** 180 | * A custom error returned in case something goes wrong. 181 | */ 182 | export abstract class ServiceClientError extends Error { 183 | public timings?: Timings; 184 | public timingPhases?: TimingPhases; 185 | public retryErrors: ServiceClientError[]; 186 | /** 187 | * Use `instanceof` checks instead. 188 | * @deprecated since 0.9.0 189 | */ 190 | public type: string; 191 | 192 | protected constructor( 193 | originalError: Error, 194 | type: string, 195 | public response?: ServiceClientResponse, 196 | name = "ServiceClient" 197 | ) { 198 | super(`${name}: ${type}. ${originalError.message || ""}`); 199 | this.type = type; 200 | this.retryErrors = []; 201 | // Does not copy `message` from the original error 202 | Object.assign(this, originalError); 203 | const timingSource: { 204 | timings?: Timings; 205 | timingPhases?: TimingPhases; 206 | // This is necessary to shut up TypeScript as otherwise it treats 207 | // types with all optional properties differently 208 | // eslint-disable-next-line @typescript-eslint/ban-types 209 | constructor: Function; 210 | } = response || originalError; 211 | this.timings = timingSource.timings; 212 | this.timingPhases = timingSource.timingPhases; 213 | } 214 | } 215 | 216 | export class CircuitOpenError extends ServiceClientError { 217 | constructor(originalError: Error, name: string) { 218 | super(originalError, ServiceClient.CIRCUIT_OPEN, undefined, name); 219 | } 220 | } 221 | 222 | export class BodyParseError extends ServiceClientError { 223 | constructor( 224 | originalError: Error, 225 | public response: ServiceClientResponse, 226 | name: string 227 | ) { 228 | super(originalError, ServiceClient.BODY_PARSE_FAILED, response, name); 229 | } 230 | } 231 | 232 | export class RequestFilterError extends ServiceClientError { 233 | constructor(originalError: Error, name: string) { 234 | super(originalError, ServiceClient.REQUEST_FILTER_FAILED, undefined, name); 235 | } 236 | } 237 | 238 | export class ResponseFilterError extends ServiceClientError { 239 | constructor( 240 | originalError: Error, 241 | public response: ServiceClientResponse, 242 | name: string 243 | ) { 244 | super(originalError, ServiceClient.RESPONSE_FILTER_FAILED, response, name); 245 | } 246 | } 247 | 248 | export class RequestNetworkError extends ServiceClientError { 249 | public requestOptions: ServiceClientRequestOptions; 250 | 251 | constructor(originalError: RequestError, name: string) { 252 | super(originalError, ServiceClient.REQUEST_FAILED, undefined, name); 253 | this.requestOptions = originalError.requestOptions; 254 | } 255 | } 256 | 257 | export class RequestConnectionTimeoutError extends ServiceClientError { 258 | public requestOptions: ServiceClientRequestOptions; 259 | 260 | constructor(originalError: RequestError, name: string) { 261 | super(originalError, ServiceClient.REQUEST_FAILED, undefined, name); 262 | this.requestOptions = originalError.requestOptions; 263 | } 264 | } 265 | 266 | export class RequestReadTimeoutError extends ServiceClientError { 267 | public requestOptions: ServiceClientRequestOptions; 268 | 269 | constructor(originalError: RequestError, name: string) { 270 | super(originalError, ServiceClient.REQUEST_FAILED, undefined, name); 271 | this.requestOptions = originalError.requestOptions; 272 | } 273 | } 274 | 275 | export class RequestUserTimeoutError extends ServiceClientError { 276 | public requestOptions: ServiceClientRequestOptions; 277 | 278 | constructor(originalError: RequestError, name: string) { 279 | super(originalError, ServiceClient.REQUEST_FAILED, undefined, name); 280 | this.requestOptions = originalError.requestOptions; 281 | } 282 | } 283 | 284 | export class RequestBodyStreamError extends ServiceClientError { 285 | public requestOptions: ServiceClientRequestOptions; 286 | 287 | constructor(originalError: RequestError, name: string) { 288 | super(originalError, ServiceClient.REQUEST_FAILED, undefined, name); 289 | this.requestOptions = originalError.requestOptions; 290 | } 291 | } 292 | 293 | export class ShouldRetryRejectedError extends ServiceClientError { 294 | constructor(originalError: Error, type: string, name: string) { 295 | super(originalError, type, undefined, name); 296 | } 297 | } 298 | 299 | export class MaximumRetriesReachedError extends ServiceClientError { 300 | constructor(originalError: Error, type: string, name: string) { 301 | super(originalError, type, undefined, name); 302 | } 303 | } 304 | 305 | export class InternalError extends ServiceClientError { 306 | constructor(originalError: Error, name: string) { 307 | super(originalError, ServiceClient.INTERNAL_ERROR, undefined, name); 308 | } 309 | } 310 | 311 | const JSON_CONTENT_TYPE_REGEX = /application\/(.*?[+])?json/i; 312 | 313 | /** 314 | * This function takes a response and if it is of type json, tries to parse the body. 315 | */ 316 | const decodeResponse = ( 317 | client: ServiceClient, 318 | response: ServiceClientResponse 319 | ): ServiceClientResponse => { 320 | const contentType = 321 | response.headers["content-type"] || 322 | (response.body ? "application/json" : ""); 323 | if ( 324 | typeof response.body === "string" && 325 | JSON_CONTENT_TYPE_REGEX.test(contentType) 326 | ) { 327 | try { 328 | response.body = JSON.parse(response.body); 329 | } catch (error) { 330 | throw new BodyParseError(error, response, client.name); 331 | } 332 | } 333 | return response; 334 | }; 335 | 336 | /** 337 | * Reducer function to unwind response filters. 338 | */ 339 | const unwindResponseFilters = ( 340 | promise: Promise, 341 | filter: ServiceClientRequestFilter 342 | ): Promise => { 343 | return promise.then(params => 344 | filter.response ? filter.response(params) : params 345 | ); 346 | }; 347 | 348 | /** 349 | * Actually performs the request and applies the available filters in their respective phases. 350 | */ 351 | const requestWithFilters = ( 352 | client: ServiceClient, 353 | requestOptions: ServiceClientRequestOptions, 354 | filters: ServiceClientRequestFilter[], 355 | autoParseJson: boolean 356 | ): Promise => { 357 | const pendingResponseFilters: ServiceClientRequestFilter[] = []; 358 | 359 | const requestFilterPromise = filters.reduce( 360 | ( 361 | promise: Promise, 362 | filter 363 | ) => { 364 | return promise.then(params => { 365 | if (params instanceof ServiceClientResponse) { 366 | return params; 367 | } 368 | const filtered = filter.request ? filter.request(params) : params; 369 | // also apply this filter when unwinding the chain 370 | pendingResponseFilters.unshift(filter); 371 | return filtered; 372 | }); 373 | }, 374 | Promise.resolve(requestOptions) 375 | ); 376 | 377 | return requestFilterPromise 378 | .catch((error: Error) => { 379 | throw new RequestFilterError(error, client.name); 380 | }) 381 | .then(paramsOrResponse => 382 | paramsOrResponse instanceof ServiceClientResponse 383 | ? paramsOrResponse 384 | : request(paramsOrResponse).catch((error: RequestError) => { 385 | if (error instanceof ConnectionTimeoutError) { 386 | throw new RequestConnectionTimeoutError(error, client.name); 387 | } else if (error instanceof UserTimeoutError) { 388 | throw new RequestUserTimeoutError(error, client.name); 389 | } else if (error instanceof BodyStreamError) { 390 | throw new RequestBodyStreamError(error, client.name); 391 | } else if (error instanceof ReadTimeoutError) { 392 | throw new RequestReadTimeoutError(error, client.name); 393 | } else if (error instanceof NetworkError) { 394 | throw new RequestNetworkError(error, client.name); 395 | } else { 396 | throw error; 397 | } 398 | }) 399 | ) 400 | .then(rawResponse => 401 | autoParseJson ? decodeResponse(client, rawResponse) : rawResponse 402 | ) 403 | .then(resp => 404 | pendingResponseFilters 405 | .reduce(unwindResponseFilters, Promise.resolve(resp)) 406 | .catch(error => { 407 | throw new ResponseFilterError(error, resp, client.name); 408 | }) 409 | ); 410 | }; 411 | 412 | const noop = () => { 413 | /* do nothing */ 414 | }; 415 | const noopBreaker: CircuitBreakerPublicApi = { 416 | run(command) { 417 | command(noop, noop); 418 | }, 419 | forceClose: () => null, 420 | forceOpen: () => null, 421 | unforce: () => null, 422 | isOpen: () => false 423 | }; 424 | 425 | const buildStatusCodeFilter = ( 426 | isError: (statusCode: number) => boolean 427 | ): ServiceClientRequestFilter => { 428 | return { 429 | response(response: ServiceClientResponse) { 430 | if (isError(response.statusCode)) { 431 | return Promise.reject( 432 | new Error(`Response status ${response.statusCode}`) 433 | ); 434 | } 435 | return Promise.resolve(response); 436 | } 437 | }; 438 | }; 439 | 440 | export class ServiceClient { 441 | /** 442 | * This filter will mark 5xx responses as failures. This is relevant for the circuit breaker. 443 | */ 444 | public static treat5xxAsError: ServiceClientRequestFilter = buildStatusCodeFilter( 445 | statusCode => statusCode >= 500 446 | ); 447 | 448 | /** 449 | * This filter will mark 4xx responses as failures. This is relevant for the circuit breaker. 450 | * 451 | * This is not the default behaviour! 452 | */ 453 | public static treat4xxAsError: ServiceClientRequestFilter = buildStatusCodeFilter( 454 | statusCode => statusCode >= 400 && statusCode < 500 455 | ); 456 | 457 | /** 458 | * Use `instanceof BodyParseError` check instead 459 | * @deprecated since 0.9.0 460 | */ 461 | public static BODY_PARSE_FAILED = "Parsing of the response body failed"; 462 | 463 | /** 464 | * Use `instanceof RequestFailedError` check instead 465 | * @deprecated since 0.9.0 466 | */ 467 | public static REQUEST_FAILED = "HTTP Request failed"; 468 | 469 | /** 470 | * Use `instanceof RequestFilterError` check instead 471 | * @deprecated since 0.9.0 472 | */ 473 | public static REQUEST_FILTER_FAILED = 474 | "Request filter marked request as failed"; 475 | 476 | /** 477 | * Use `instanceof ResponseFilterError` check instead 478 | * @deprecated since 0.9.0 479 | */ 480 | public static RESPONSE_FILTER_FAILED = 481 | "Response filter marked request as failed"; 482 | 483 | /** 484 | * Use `instanceof CircuitOpenError` check instead 485 | * @deprecated since 0.9.0 486 | */ 487 | public static CIRCUIT_OPEN = 488 | "Circuit breaker is open and prevented the request"; 489 | 490 | /** 491 | * Use `instanceof CircuitOpenError` check instead 492 | * @deprecated since 0.9.0 493 | */ 494 | public static INTERNAL_ERROR = 495 | "Perron internal error due to a bug or misconfiguration"; 496 | 497 | /** 498 | * Default list of post-filters which includes 499 | * `ServiceClient.treat5xxAsError` 500 | */ 501 | public static DEFAULT_FILTERS: ReadonlyArray< 502 | ServiceClientRequestFilter 503 | > = Object.freeze([ServiceClient.treat5xxAsError]); 504 | 505 | /** 506 | * Interface that the response will implement. 507 | * Deprecated: Use ServiceClientResponse instead. 508 | * @see ServiceClientResponse 509 | * @deprecated 510 | */ 511 | public static Response = ServiceClientResponse; 512 | 513 | /** 514 | * Interface, that errors will implement. 515 | * Deprecated: Use ServiceClientError instead. 516 | * @see ServiceClientError 517 | * @deprecated 518 | */ 519 | public static Error = ServiceClientError; 520 | public name: string; 521 | 522 | private breaker?: CircuitBreaker; 523 | private breakerFactory?: CircuitBreakerFactory; 524 | private options: ServiceClientStrictOptions; 525 | 526 | /** 527 | * A ServiceClient can be constructed with all defaults by simply providing a URL, that can be parsed 528 | * by nodes url parser. Alternatively, provide actual ServiceClientOptions, that implement the 529 | * @{link ServiceClientOptions} interface. 530 | */ 531 | constructor(optionsOrUrl: ServiceClientOptions | string) { 532 | let options: ServiceClientOptions; 533 | if (typeof optionsOrUrl === "string") { 534 | const { 535 | port, 536 | protocol, 537 | query, 538 | hostname = "", 539 | pathname = "/" 540 | } = url.parse(optionsOrUrl, true); 541 | options = { 542 | hostname, 543 | defaultRequestOptions: { 544 | port, 545 | protocol, 546 | query, 547 | // pathname will be overwritten in actual usage, we just guarantee a sane default 548 | pathname 549 | } 550 | }; 551 | } else { 552 | options = optionsOrUrl; 553 | } 554 | 555 | if (typeof options.circuitBreaker === "object") { 556 | const breakerOptions = { 557 | windowDuration: 10000, 558 | numBuckets: 10, 559 | timeoutDuration: 2000, 560 | errorThreshold: 50, 561 | volumeThreshold: 10, 562 | ...options.circuitBreaker 563 | }; 564 | this.breaker = new CircuitBreaker(breakerOptions); 565 | } 566 | 567 | if (typeof options.circuitBreaker === "function") { 568 | this.breakerFactory = options.circuitBreaker; 569 | } 570 | 571 | this.options = new ServiceClientStrictOptions(options); 572 | this.name = options.name || options.hostname; 573 | } 574 | 575 | /** 576 | * Return an instance of a CircuitBreaker based on params. 577 | * Choses between a factory and a single static breaker 578 | */ 579 | public getCircuitBreaker( 580 | params: ServiceClientRequestOptions 581 | ): CircuitBreakerPublicApi { 582 | if (this.breaker) { 583 | return this.breaker; 584 | } 585 | 586 | if (this.breakerFactory) { 587 | return this.breakerFactory(params); 588 | } 589 | 590 | return noopBreaker; 591 | } 592 | 593 | /** 594 | * Perform a request to the service using given @{link ServiceClientRequestOptions}, returning the result in a promise. 595 | */ 596 | public request( 597 | userParams: ServiceClientRequestOptions 598 | ): Promise { 599 | const params = { ...this.options.defaultRequestOptions, ...userParams }; 600 | 601 | params.hostname = this.options.hostname; 602 | params.port = params.port || (params.protocol === "https:" ? 443 : 80); 603 | params.timing = 604 | params.timing !== undefined ? params.timing : this.options.timing; 605 | 606 | params.headers = { 607 | accept: "application/json", 608 | ...params.headers 609 | }; 610 | 611 | const { 612 | retries, 613 | factor, 614 | minTimeout, 615 | maxTimeout, 616 | randomize, 617 | shouldRetry, 618 | onRetry 619 | } = this.options.retryOptions; 620 | 621 | const opts = { 622 | retries, 623 | factor, 624 | minTimeout, 625 | maxTimeout, 626 | randomize 627 | }; 628 | 629 | const retryErrors: ServiceClientError[] = []; 630 | return new Promise((resolve, reject) => { 631 | const breaker = this.getCircuitBreaker(params); 632 | const retryOperation = operation(opts, (currentAttempt: number) => { 633 | breaker.run( 634 | (success: () => void, failure: () => void) => { 635 | return requestWithFilters( 636 | this, 637 | params, 638 | this.options.filters || [], 639 | this.options.autoParseJson 640 | ) 641 | .then((result: ServiceClientResponse) => { 642 | success(); 643 | result.retryErrors = retryErrors; 644 | resolve(result); 645 | }) 646 | .catch((error: ServiceClientError) => { 647 | retryErrors.push(error); 648 | failure(); 649 | if (!shouldRetry(error, params)) { 650 | reject( 651 | new ShouldRetryRejectedError(error, error.type, this.name) 652 | ); 653 | return; 654 | } 655 | if (!retryOperation.retry()) { 656 | // Wrapping error when user does not want retries would result 657 | // in bad developer experience where you always have to unwrap it 658 | // knowing there is only one error inside, so we do not do that. 659 | if (retries === 0) { 660 | reject(error); 661 | } else { 662 | reject( 663 | new MaximumRetriesReachedError( 664 | error, 665 | error.type, 666 | this.name 667 | ) 668 | ); 669 | } 670 | return; 671 | } 672 | onRetry(currentAttempt + 1, error, params); 673 | }); 674 | }, 675 | () => { 676 | reject(new CircuitOpenError(new Error(), this.name)); 677 | } 678 | ); 679 | }); 680 | retryOperation.attempt(); 681 | }).catch((error: unknown) => { 682 | const rawError = 683 | error instanceof Error ? error : new Error(String(error)); 684 | const wrappedError = 685 | rawError instanceof ServiceClientError 686 | ? rawError 687 | : new InternalError(rawError, this.name); 688 | wrappedError.retryErrors = retryErrors; 689 | throw wrappedError; 690 | }); 691 | } 692 | } 693 | 694 | Object.freeze(ServiceClient); 695 | 696 | export default ServiceClient; 697 | -------------------------------------------------------------------------------- /test/client.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const nock = require("nock"); 4 | const util = require("util"); 5 | const assert = require("assert"); 6 | const proxyquire = require("proxyquire").noCallThru(); 7 | const sinon = require("sinon"); 8 | const realRequest = require("../dist/request"); 9 | 10 | const fail = result => 11 | assert.fail( 12 | `expected promise to be rejected, got resolved with ${util.inspect(result)}` 13 | ); 14 | 15 | describe("ServiceClient with stub request", () => { 16 | /** 17 | * @type ServiceClientOptions 18 | */ 19 | let clientOptions; 20 | 21 | const requestStub = sinon.stub(); 22 | const emptySuccessResponse = Promise.resolve({ 23 | statusCode: 200, 24 | headers: {}, 25 | body: "{}" 26 | }); 27 | const fakeRequest = Object.assign({}, realRequest, { 28 | request: requestStub 29 | }); 30 | const { 31 | ServiceClient, 32 | BodyParseError, 33 | CircuitOpenError, 34 | RequestFilterError, 35 | ResponseFilterError, 36 | RequestNetworkError, 37 | RequestConnectionTimeoutError, 38 | RequestUserTimeoutError, 39 | MaximumRetriesReachedError, 40 | ShouldRetryRejectedError, 41 | InternalError 42 | } = proxyquire("../dist/client", { 43 | "./request": fakeRequest 44 | }); 45 | const { 46 | NetworkError, 47 | ConnectionTimeoutError, 48 | UserTimeoutError 49 | } = fakeRequest; 50 | const timings = { 51 | socket: 1, 52 | lookup: 2, 53 | connect: 3, 54 | secureConnect: 4, 55 | response: 5, 56 | end: 6 57 | }; 58 | const timingPhases = { 59 | wait: 1, 60 | dns: 1, 61 | tcp: 1, 62 | tls: 1, 63 | firstByte: 1, 64 | download: 1, 65 | total: 6 66 | }; 67 | 68 | beforeEach(() => { 69 | clientOptions = { 70 | hostname: "catwatch.opensource.zalan.do" 71 | }; 72 | requestStub.reset(); 73 | requestStub.returns(emptySuccessResponse); 74 | }); 75 | 76 | it("should throw if the service is not provided", () => { 77 | assert.throws(() => { 78 | new ServiceClient({}); // eslint-disable-line no-new 79 | }); 80 | }); 81 | 82 | it("should by default send an `accept` application/json header", () => { 83 | const client = new ServiceClient(clientOptions); 84 | return client.request().then(() => { 85 | assert.equal( 86 | requestStub.firstCall.args[0].headers.accept, 87 | "application/json" 88 | ); 89 | }); 90 | }); 91 | 92 | it("should not add authorization header if there is no token provider", () => { 93 | const client = new ServiceClient(clientOptions); 94 | return client.request().then(() => { 95 | assert.strictEqual( 96 | requestStub.firstCall.args[0].headers.authorization, 97 | undefined 98 | ); 99 | }); 100 | }); 101 | 102 | it("should allow not parsing json body response", () => { 103 | const client = new ServiceClient( 104 | Object.assign( 105 | { 106 | autoParseJson: false 107 | }, 108 | clientOptions 109 | ) 110 | ); 111 | const originalBody = JSON.stringify({ foo: "bar" }); 112 | requestStub.resolves({ 113 | headers: { 114 | "content-type": "application/x.problem+json" 115 | }, 116 | body: originalBody 117 | }); 118 | return client.request().then(({ body }) => { 119 | assert.strictEqual(body, originalBody); 120 | }); 121 | }); 122 | 123 | it("should automatically parse response as JSON if content type is set correctly", () => { 124 | const client = new ServiceClient(clientOptions); 125 | const originalBody = { foo: "bar" }; 126 | requestStub.resolves({ 127 | headers: { 128 | "content-type": "application/x.problem+json" 129 | }, 130 | body: JSON.stringify(originalBody) 131 | }); 132 | return client.request().then(({ body }) => { 133 | assert.deepStrictEqual(body, originalBody); 134 | }); 135 | }); 136 | 137 | it("should automatically parse response as JSON if content type is not set", () => { 138 | const client = new ServiceClient(clientOptions); 139 | const originalBody = { foo: "bar" }; 140 | requestStub.returns( 141 | Promise.resolve({ 142 | headers: {}, 143 | body: JSON.stringify(originalBody) 144 | }) 145 | ); 146 | return client.request().then(({ body }) => { 147 | assert.deepStrictEqual(body, originalBody); 148 | }); 149 | }); 150 | 151 | it("should not throw an error if body or content-type is not set", () => { 152 | const client = new ServiceClient(clientOptions); 153 | requestStub.returns( 154 | Promise.resolve({ 155 | headers: {}, 156 | body: "" 157 | }) 158 | ); 159 | return client.request().then(({ body }) => { 160 | assert.equal(body, ""); 161 | }); 162 | }); 163 | 164 | it("should throw an error if body is not set for application/json content type", () => { 165 | const client = new ServiceClient(clientOptions); 166 | const response = { 167 | headers: { "content-type": "application/json" }, 168 | body: "" 169 | }; 170 | requestStub.returns(Promise.resolve(response)); 171 | return client.request().then(fail, err => { 172 | assert(err instanceof ServiceClient.Error); 173 | assert.equal(err.type, ServiceClient.BODY_PARSE_FAILED); 174 | assert(err instanceof BodyParseError); 175 | assert.deepStrictEqual(err.response, response); 176 | }); 177 | }); 178 | 179 | it("should give a custom error object when the parsing of the body fails", () => { 180 | const client = new ServiceClient(clientOptions); 181 | const response = { 182 | headers: {}, 183 | body: "/not a JSON" 184 | }; 185 | requestStub.returns(Promise.resolve(response)); 186 | return client.request().then(fail, err => { 187 | assert(err instanceof ServiceClient.Error); 188 | assert.equal(err.type, ServiceClient.BODY_PARSE_FAILED); 189 | assert(err instanceof BodyParseError); 190 | assert.deepStrictEqual(err.response, response); 191 | }); 192 | }); 193 | 194 | it("should give a custom error object when request fails", () => { 195 | const client = new ServiceClient(clientOptions); 196 | const requestError = new NetworkError(new Error("foobar")); 197 | requestStub.returns(Promise.reject(requestError)); 198 | return client.request().then(fail, err => { 199 | assert(err instanceof ServiceClient.Error); 200 | assert.equal(err.type, ServiceClient.REQUEST_FAILED); 201 | assert(err instanceof RequestNetworkError); 202 | }); 203 | }); 204 | 205 | it("should give a custom error when request timeouts", () => { 206 | const client = new ServiceClient(clientOptions); 207 | requestStub.rejects(new ConnectionTimeoutError("foobar")); 208 | return client.request().then(fail, err => { 209 | assert(err instanceof ServiceClient.Error); 210 | assert(err instanceof RequestConnectionTimeoutError); 211 | }); 212 | }); 213 | 214 | it("should give a custom error when request is dropped", () => { 215 | const client = new ServiceClient(clientOptions); 216 | requestStub.rejects(new UserTimeoutError("foobar")); 217 | return client.request().then(fail, err => { 218 | assert(err instanceof ServiceClient.Error); 219 | assert(err instanceof RequestUserTimeoutError); 220 | }); 221 | }); 222 | 223 | it("should give a custom error when there is an internal error", () => { 224 | const client = new ServiceClient(clientOptions); 225 | requestStub.rejects(new TypeError("foobar")); 226 | return client.request().then(fail, err => { 227 | assert(err instanceof ServiceClient.Error); 228 | assert(err instanceof InternalError); 229 | }); 230 | }); 231 | 232 | it("should copy timings to custom error when request fails", () => { 233 | const client = new ServiceClient(clientOptions); 234 | const requestError = new NetworkError(new Error("foobar"), {}, timings); 235 | requestStub.returns(Promise.reject(requestError)); 236 | return client.request().then(fail, err => { 237 | assert(err instanceof ServiceClient.Error); 238 | assert.equal(err.type, ServiceClient.REQUEST_FAILED); 239 | assert(err instanceof RequestNetworkError); 240 | assert.deepEqual(err.timings, timings); 241 | assert.deepEqual(err.timingPhases, timingPhases); 242 | }); 243 | }); 244 | 245 | it("should copy timings to custom error when request fails", () => { 246 | const client = new ServiceClient(clientOptions); 247 | const requestOptions = { hostname: "foo" }; 248 | const requestError = new NetworkError( 249 | new Error("foobar"), 250 | requestOptions, 251 | timings 252 | ); 253 | requestStub.returns(Promise.reject(requestError)); 254 | return client.request().then(fail, err => { 255 | assert(err instanceof RequestNetworkError); 256 | assert.deepStrictEqual(err.requestOptions, requestOptions); 257 | }); 258 | }); 259 | 260 | it("should allow to mark request as failed in the request filter", () => { 261 | clientOptions.filters = [ 262 | { 263 | request() { 264 | throw new Error("Failed!"); 265 | } 266 | } 267 | ]; 268 | const client = new ServiceClient(clientOptions); 269 | return client.request().then(fail, err => { 270 | assert(err instanceof ServiceClient.Error); 271 | assert.equal(err.type, "Request filter marked request as failed"); 272 | assert(err instanceof RequestFilterError); 273 | }); 274 | }); 275 | 276 | it("should by default handle 5xx code in a response-filter", () => { 277 | const client = new ServiceClient(clientOptions); 278 | requestStub.returns( 279 | Promise.resolve({ 280 | statusCode: 501, 281 | headers: {}, 282 | body: "{}" 283 | }) 284 | ); 285 | return client.request().then(fail, err => { 286 | assert(err instanceof ServiceClient.Error); 287 | assert.equal(err.type, "Response filter marked request as failed"); 288 | assert(err instanceof ResponseFilterError); 289 | }); 290 | }); 291 | 292 | it("should be able to handle 4xx code as a response-filter", () => { 293 | clientOptions.filters = [ 294 | ServiceClient.treat4xxAsError, 295 | ServiceClient.treat5xxAsError 296 | ]; 297 | const client = new ServiceClient(clientOptions); 298 | requestStub.returns( 299 | Promise.resolve({ 300 | statusCode: 403, 301 | headers: {}, 302 | body: "{}", 303 | timings, 304 | timingPhases 305 | }) 306 | ); 307 | return client.request().then(fail, err => { 308 | assert(err instanceof ServiceClient.Error); 309 | assert.equal(err.type, ServiceClient.RESPONSE_FILTER_FAILED); 310 | assert(err instanceof ResponseFilterError); 311 | assert.deepEqual(err.timings, timings); 312 | assert.deepEqual(err.timingPhases, timingPhases); 313 | }); 314 | }); 315 | 316 | it("should be possible to define your own response-filters", () => { 317 | clientOptions.filters = [ 318 | { 319 | response(response) { 320 | if (response.body.error) { 321 | throw new Error(response.body.error); 322 | } 323 | return response; 324 | } 325 | } 326 | ]; 327 | const client = new ServiceClient(clientOptions); 328 | requestStub.returns( 329 | Promise.resolve({ 330 | statusCode: 200, 331 | headers: {}, 332 | body: '{ "error": "non-REST-error" }' 333 | }) 334 | ); 335 | return client.request().then(fail, err => { 336 | assert(err instanceof ServiceClient.Error); 337 | assert.equal(err.type, ServiceClient.RESPONSE_FILTER_FAILED); 338 | assert(err instanceof ResponseFilterError); 339 | assert(err.message.includes("non-REST-error")); 340 | }); 341 | }); 342 | 343 | it("should have the original response in a response filter error", () => { 344 | clientOptions.filters = [ 345 | { 346 | response() { 347 | throw new Error(); 348 | } 349 | } 350 | ]; 351 | const client = new ServiceClient(clientOptions); 352 | const response = { 353 | statusCode: 200, 354 | headers: {}, 355 | body: '{ "error": "non-REST-error" }' 356 | }; 357 | requestStub.returns(Promise.resolve(response)); 358 | return client.request().then(fail, err => { 359 | assert.deepStrictEqual(err.response, response); 360 | }); 361 | }); 362 | 363 | it("should allow to specify request-filters to augment the request", () => { 364 | clientOptions.filters = [ 365 | { 366 | request(request) { 367 | request.path = "foo-bar-buzz"; 368 | return request; 369 | } 370 | } 371 | ]; 372 | const client = new ServiceClient(clientOptions); 373 | return client.request().then(() => { 374 | assert.equal(requestStub.firstCall.args[0].path, "foo-bar-buzz"); 375 | }); 376 | }); 377 | 378 | it("should allow to specify a request-filters to short-circuit a response", () => { 379 | const headers = { 380 | "x-my-custom-header": "foobar" 381 | }; 382 | const body = { 383 | foo: "bar" 384 | }; 385 | clientOptions.filters = [ 386 | { 387 | request() { 388 | return new ServiceClient.Response(404, headers, body); 389 | } 390 | } 391 | ]; 392 | const client = new ServiceClient(clientOptions); 393 | return client.request().then(response => { 394 | assert.deepStrictEqual(response.headers, headers); 395 | assert.deepStrictEqual(response.body, body); 396 | }); 397 | }); 398 | 399 | it("should open the circuit after 50% from 11 requests failed", () => { 400 | const httpErrorResponse = Promise.resolve({ 401 | statusCode: 500, 402 | headers: {}, 403 | body: "{}" 404 | }); 405 | const errorResponse = Promise.resolve( 406 | Promise.reject(new ConnectionTimeoutError("timeout")) 407 | ); 408 | 409 | const responses = [ 410 | emptySuccessResponse, 411 | emptySuccessResponse, 412 | httpErrorResponse, 413 | emptySuccessResponse, 414 | errorResponse, 415 | errorResponse, 416 | httpErrorResponse, 417 | emptySuccessResponse, 418 | httpErrorResponse, 419 | errorResponse, 420 | emptySuccessResponse 421 | ]; 422 | 423 | responses.forEach((response, index) => { 424 | requestStub.onCall(index).returns(response); 425 | }); 426 | 427 | clientOptions.circuitBreaker = {}; 428 | const client = new ServiceClient(clientOptions); 429 | return responses 430 | .reduce(promise => { 431 | const tick = () => { 432 | return client.request(); 433 | }; 434 | return promise.then(tick, tick); 435 | }, Promise.resolve()) 436 | .then(() => { 437 | return client.request(); 438 | }) 439 | .then(fail, err => { 440 | assert(err instanceof ServiceClient.Error); 441 | assert(err.type, ServiceClient.CIRCUIT_OPEN); 442 | assert(err instanceof CircuitOpenError); 443 | }); 444 | }); 445 | 446 | describe("built-in filter", () => { 447 | it("should return original response if all ok", () => { 448 | return Promise.all( 449 | [ServiceClient.treat4xxAsError, ServiceClient.treat5xxAsError].map( 450 | filter => { 451 | const response = { statusCode: 200 }; 452 | return filter.response(response).then(actual => { 453 | assert.deepStrictEqual(actual, response); 454 | }); 455 | } 456 | ) 457 | ); 458 | }); 459 | }); 460 | 461 | describe("request params", () => { 462 | const expectedDefaultRequestOptions = { 463 | hostname: "catwatch.opensource.zalan.do", 464 | protocol: "https:", 465 | port: 443, 466 | headers: { 467 | accept: "application/json" 468 | }, 469 | pathname: "/", 470 | timeout: 2000, 471 | timing: false 472 | }; 473 | it("should pass reasonable request params by default", () => { 474 | const client = new ServiceClient(clientOptions); 475 | return client.request().then(() => { 476 | assert.deepStrictEqual( 477 | requestStub.firstCall.args[0], 478 | expectedDefaultRequestOptions 479 | ); 480 | }); 481 | }); 482 | it("should allow to pass additional params to the request", () => { 483 | const client = new ServiceClient(clientOptions); 484 | return client.request({ foo: "bar" }).then(() => { 485 | assert.deepStrictEqual( 486 | requestStub.firstCall.args[0], 487 | Object.assign({ foo: "bar" }, expectedDefaultRequestOptions) 488 | ); 489 | }); 490 | }); 491 | it("should allow to override params of the request", () => { 492 | const client = new ServiceClient(clientOptions); 493 | return client.request({ pathname: "/foo" }).then(() => { 494 | assert.deepStrictEqual( 495 | requestStub.firstCall.args[0], 496 | Object.assign({}, expectedDefaultRequestOptions, { pathname: "/foo" }) 497 | ); 498 | }); 499 | }); 500 | it("should allow to specify query params of the request", () => { 501 | const client = new ServiceClient(clientOptions); 502 | return client 503 | .request({ 504 | pathname: "/foo", 505 | query: { param: 1 } 506 | }) 507 | .then(() => { 508 | assert.deepStrictEqual( 509 | requestStub.firstCall.args[0], 510 | Object.assign({}, expectedDefaultRequestOptions, { 511 | pathname: "/foo", 512 | query: { param: 1 } 513 | }) 514 | ); 515 | }); 516 | }); 517 | it("should allow to specify default params of the request", () => { 518 | const userDefaultRequestOptions = { 519 | pathname: "/foo", 520 | protocol: "http:", 521 | query: { param: 42 } 522 | }; 523 | const client = new ServiceClient( 524 | Object.assign({}, clientOptions, { 525 | defaultRequestOptions: userDefaultRequestOptions 526 | }) 527 | ); 528 | return client.request().then(() => { 529 | assert.deepStrictEqual( 530 | requestStub.firstCall.args[0], 531 | Object.assign( 532 | {}, 533 | expectedDefaultRequestOptions, 534 | userDefaultRequestOptions, 535 | { port: 80 } 536 | ) 537 | ); 538 | }); 539 | }); 540 | it("should not allow to override hostname", () => { 541 | const client = new ServiceClient( 542 | Object.assign({}, clientOptions, { 543 | defaultRequestOptions: { hostname: "zalando.de" } 544 | }) 545 | ); 546 | return client.request().then(() => { 547 | assert.deepStrictEqual( 548 | requestStub.firstCall.args[0], 549 | Object.assign({}, expectedDefaultRequestOptions) 550 | ); 551 | }); 552 | }); 553 | it("should support taking hostname and default params from a URL instead of an object", () => { 554 | const client = new ServiceClient("http://localhost:9999/foo?param=42"); 555 | return client.request().then(() => { 556 | assert.deepEqual( 557 | requestStub.firstCall.args[0], 558 | Object.assign({}, expectedDefaultRequestOptions, { 559 | port: 9999, 560 | hostname: "localhost", 561 | pathname: "/foo", 562 | protocol: "http:", 563 | query: { 564 | param: "42" 565 | }, 566 | timing: false 567 | }) 568 | ); 569 | }); 570 | }); 571 | }); 572 | 573 | it("should correctly return response if one of the retries succeeds", () => { 574 | const retrySpy = sinon.spy(); 575 | clientOptions.retryOptions = { 576 | retries: 3, 577 | onRetry: retrySpy 578 | }; 579 | const client = new ServiceClient(clientOptions); 580 | requestStub.onFirstCall().resolves({ 581 | statusCode: 501, 582 | headers: {}, 583 | body: "{}" 584 | }); 585 | requestStub.onSecondCall().resolves({ 586 | statusCode: 200, 587 | headers: {}, 588 | body: `{"foo":"bar"}` 589 | }); 590 | return client.request().then(response => { 591 | assert.equal(retrySpy.callCount, 1); 592 | assert.deepEqual(response.body, { foo: "bar" }); 593 | assert.equal(response.retryErrors.length, 1); 594 | assert(response.retryErrors[0] instanceof ResponseFilterError); 595 | }); 596 | }); 597 | 598 | it("should perform the desired number of retries based on the configuration", () => { 599 | const retrySpy = sinon.spy(); 600 | clientOptions.retryOptions = { 601 | retries: 3, 602 | onRetry: retrySpy 603 | }; 604 | const client = new ServiceClient(clientOptions); 605 | requestStub.returns( 606 | Promise.resolve({ 607 | statusCode: 501, 608 | headers: {}, 609 | body: "{}" 610 | }) 611 | ); 612 | return client.request().then(fail, err => { 613 | assert.equal(retrySpy.callCount, 3); 614 | assert( 615 | retrySpy.alwaysCalledWithMatch( 616 | sinon.match.number, 617 | sinon.match.hasOwn("type"), 618 | sinon.match.hasOwn("pathname") 619 | ) 620 | ); 621 | assert.equal(err instanceof ServiceClient.Error, true); 622 | assert.equal(err.type, "Response filter marked request as failed"); 623 | assert(err instanceof MaximumRetriesReachedError); 624 | assert.equal(err.retryErrors.length, 4); 625 | for (const originalError of err.retryErrors) { 626 | assert(originalError instanceof ResponseFilterError); 627 | } 628 | }); 629 | }); 630 | 631 | it("should open the circuit after 50% from 11 requests failed and correct number of retries were performed", () => { 632 | const httpErrorResponse = Promise.resolve({ 633 | statusCode: 500, 634 | headers: {}, 635 | body: "{}" 636 | }); 637 | const retrySpy = sinon.spy(); 638 | clientOptions.circuitBreaker = {}; 639 | clientOptions.retryOptions = { 640 | retries: 1, 641 | onRetry: retrySpy 642 | }; 643 | const errorResponse = Promise.resolve( 644 | Promise.reject(new ConnectionTimeoutError("timeout")) 645 | ); 646 | 647 | const responses = [ 648 | emptySuccessResponse, 649 | emptySuccessResponse, 650 | errorResponse, 651 | emptySuccessResponse, 652 | httpErrorResponse, 653 | errorResponse, 654 | httpErrorResponse, 655 | emptySuccessResponse, 656 | errorResponse, 657 | httpErrorResponse, 658 | emptySuccessResponse 659 | ]; 660 | 661 | responses.forEach((response, index) => { 662 | requestStub.onCall(index).returns(response); 663 | }); 664 | 665 | const client = new ServiceClient(clientOptions); 666 | return responses 667 | .reduce(promise => { 668 | const tick = () => { 669 | return client.request(); 670 | }; 671 | return promise.then(tick, tick); 672 | }, Promise.resolve()) 673 | .then(() => { 674 | return client.request(); 675 | }) 676 | .then(fail, err => { 677 | assert.equal(retrySpy.callCount, 4); 678 | assert( 679 | retrySpy.alwaysCalledWithMatch( 680 | sinon.match.number, 681 | sinon.match.hasOwn("type"), 682 | sinon.match.hasOwn("pathname") 683 | ) 684 | ); 685 | assert.equal(err instanceof ServiceClient.Error, true); 686 | assert.equal(err.type, ServiceClient.CIRCUIT_OPEN); 687 | assert(err instanceof CircuitOpenError); 688 | }); 689 | }); 690 | 691 | it("should not retry if the shouldRetry function returns false", () => { 692 | const retrySpy = sinon.spy(); 693 | clientOptions.retryOptions = { 694 | retries: 1, 695 | shouldRetry(err) { 696 | return err.response.statusCode !== 501; 697 | }, 698 | onRetry: retrySpy 699 | }; 700 | const client = new ServiceClient(clientOptions); 701 | requestStub.returns( 702 | Promise.resolve({ 703 | statusCode: 501, 704 | headers: {}, 705 | body: "{}" 706 | }) 707 | ); 708 | return client.request().then(fail, err => { 709 | assert.equal(retrySpy.callCount, 0); 710 | assert.equal(err instanceof ServiceClient.Error, true); 711 | assert.equal(err.type, "Response filter marked request as failed"); 712 | assert(err instanceof ShouldRetryRejectedError); 713 | }); 714 | }); 715 | 716 | it("should prepend the ServiceClient name to errors", () => { 717 | clientOptions.name = "TestClient"; 718 | const client = new ServiceClient(clientOptions); 719 | const requestError = new NetworkError(new Error("foobar")); 720 | requestStub.returns(Promise.reject(requestError)); 721 | return client.request().then(fail, err => { 722 | assert.equal(err.message, "TestClient: HTTP Request failed. foobar"); 723 | }); 724 | }); 725 | 726 | it("should default to hostname in errors if no name is specified", () => { 727 | const client = new ServiceClient(clientOptions); 728 | const requestError = new NetworkError(new Error("foobar")); 729 | requestStub.returns(Promise.reject(requestError)); 730 | return client.request().then(fail, err => { 731 | assert.equal( 732 | err.message, 733 | "catwatch.opensource.zalan.do: HTTP Request failed. foobar" 734 | ); 735 | }); 736 | }); 737 | 738 | it("accepts and uses circuit breaker factory", () => { 739 | const noop = () => null; 740 | const breaker = { 741 | run: sinon.spy(command => command(noop, noop)) 742 | }; 743 | 744 | const client = new ServiceClient( 745 | Object.assign({}, clientOptions, { 746 | circuitBreaker: () => breaker 747 | }) 748 | ); 749 | 750 | assert(client.getCircuitBreaker({}) === breaker); 751 | }); 752 | 753 | it("uses circuit breaker factory while making requests", () => { 754 | const noop = () => null; 755 | const breaker = { 756 | run: sinon.spy(command => command(noop, noop)) 757 | }; 758 | const breakerFactory = sinon.spy(() => breaker); 759 | 760 | const client = new ServiceClient( 761 | Object.assign({}, clientOptions, { 762 | circuitBreaker: breakerFactory 763 | }) 764 | ); 765 | 766 | return client.request().then(() => { 767 | assert(breaker.run.calledOnce); 768 | assert(breakerFactory.calledWithMatch(clientOptions)); 769 | }); 770 | }); 771 | }); 772 | 773 | describe("ServiceClient with nock response", () => { 774 | const { ServiceClient } = require("../dist/client"); 775 | /** 776 | * @type ServiceClientOptions 777 | */ 778 | let clientOptions; 779 | beforeEach(() => { 780 | clientOptions = { 781 | hostname: "catwatch.opensource.zalan.do" 782 | }; 783 | }); 784 | describe("metrics", async () => { 785 | it("should return the timing metrics when timing enabled in client", async () => { 786 | nock("https://catwatch.opensource.zalan.do") 787 | .get("/") 788 | .reply(200); 789 | const optsWithTimingEnabled = Object.assign({}, clientOptions, { 790 | timing: true 791 | }); 792 | const client = new ServiceClient(optsWithTimingEnabled); 793 | 794 | const response = await client.request(); 795 | 796 | assert(response.timings != null); 797 | assert(response.timingPhases != null); 798 | }); 799 | 800 | it("should not return the timing metrics when timing disabled in client", async () => { 801 | nock("https://catwatch.opensource.zalan.do") 802 | .get("/") 803 | .reply(200); 804 | const optsWithTimingEnabled = Object.assign({}, clientOptions, { 805 | timing: false 806 | }); 807 | const client = new ServiceClient(optsWithTimingEnabled); 808 | 809 | const response = await client.request(); 810 | 811 | assert(response.timings == null); 812 | assert(response.timingPhases == null); 813 | }); 814 | }); 815 | }); 816 | --------------------------------------------------------------------------------