├── .npmignore ├── .npmrc ├── .gitattributes ├── .prettierrc.js ├── .eslintignore ├── .gitignore ├── tsconfig.json ├── babel.config.js ├── .editorconfig ├── .vscode ├── tasks.json └── launch.json ├── .register.js ├── tsconfig.declarations.json ├── src ├── retriers.ts ├── get_request_options │ ├── index.ts │ └── index.test.ts ├── index.ts └── index.test.ts ├── nyc.config.js ├── renovate.json ├── .eslintrc.js ├── LICENSE.md ├── CHANGELOG.md ├── .travis.yml ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | flow-typed/npm/** linguist-generated 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@goodeggs/toolkit/config/prettier'); 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | npm-debug.log* 4 | flow-typed 5 | .DS_Store 6 | coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /node_modules 3 | /npm-debug.log 4 | .DS_Store 5 | /coverage 6 | yarn-error.log 7 | .eslintcache 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@goodeggs/tsconfig/base", 3 | "compilerOptions": { 4 | "downlevelIteration": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/typescript'], 3 | plugins: ['@babel/plugin-transform-runtime'], 4 | }; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "test:mocha", 9 | "group": { 10 | "kind": "test", 11 | "isDefault": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.register.js: -------------------------------------------------------------------------------- 1 | // Temporary entrypoint wrapper for various `yarn` scripts to configure @babel/register for TypeScript 2 | // TODO(murkey) Remove this file and: 3 | // - Use this wrapper: https://github.com/deepsweet/babel-register-ts 4 | // - Get a new version of @babel/register with built-in support: https://github.com/babel/babel/pull/6027 5 | // - Get a version of babel that allows extensions to be configured via .babelrc/package.json: https://github.com/babel/babel/issues/3741 6 | require('@babel/register')({ 7 | extensions: ['.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.declarations.json: -------------------------------------------------------------------------------- 1 | // Config to be used when building type declarations (.d.ts) to be packaged with this library 2 | { 3 | "extends": "./tsconfig.json", 4 | "include": [ 5 | "src/**/*.ts" 6 | ], 7 | "exclude": [ 8 | "**/*.test.ts", 9 | "**/test.ts" 10 | ], 11 | "compilerOptions": { 12 | "noEmit": false, 13 | "declaration": true, 14 | "emitDeclarationOnly": true, 15 | "declarationMap": true, 16 | "rootDir": "src/", 17 | "outDir": "lib/", 18 | "declarationDir": "lib/" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/retriers.ts: -------------------------------------------------------------------------------- 1 | const isNonEmptyResponse = (candidate?: Response | Error): candidate is Response => 2 | candidate != null && !(candidate instanceof Error); 3 | 4 | export default { 5 | is5xx(response: Response | Error): boolean { 6 | if ( 7 | isNonEmptyResponse(response) && 8 | response.status != null && 9 | (response.status === 503 || response.status === 504) 10 | ) 11 | return true; 12 | return false; 13 | }, 14 | 15 | isNetworkError(response: Response | Error): boolean { 16 | return response instanceof Error; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // lcov is used by Codecov on Travis, but can also be opened in browser: `open coverage/lcov-report/index.html` 3 | reporter: ['lcov', 'text-summary'], 4 | extension: ['.js', '.jsx', '.ts', '.tsx'], 5 | all: true, 6 | exclude: [ 7 | // Exclude generated/third-party files. NOTE: node_modules/ always excluded. 8 | 'coverage/**', 9 | 'lib/**', 10 | 11 | // Tests don't need to be tested 🙃 12 | '**/*test.{js,jsx,ts,tsx}', 13 | '**/test/**', 14 | '**/factory.ts', 15 | 16 | // Exclude config files 17 | '*.config.{js,ts}', 18 | '.*rc.js', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":automergeMinor", ":renovatePrefix", ":prHourlyLimitNone"], 4 | "dependencyDashboard": true, 5 | "labels": ["dependencies", "renovate"], 6 | "timezone": "America/Los_Angeles", 7 | 8 | "packageRules": [ 9 | { 10 | "matchDepTypes": ["engines"], 11 | "enabled": false 12 | } 13 | ], 14 | 15 | "rangeStrategy": "bump", 16 | "recreateClosed": true, 17 | "schedule": ["after 9am", "before 5pm", "on Monday through Thursday"], 18 | "stabilityDays": 3, 19 | "transitiveRemediation": true 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Current File", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "args": ["--opts", "mocha.opts", "-t", "60s", "${file}"], 13 | "internalConsoleOptions": "openOnSessionStart", 14 | "env": {"NODE_ENV": "test"} 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | }, 6 | extends: ['plugin:goodeggs/recommended', 'plugin:goodeggs/typescript'], 7 | env: { 8 | node: true, 9 | browser: true, 10 | }, 11 | rules: {}, 12 | overrides: [ 13 | { 14 | files: ['**/{*.,}test{.*,}.{js,jsx,ts,tsx}'], 15 | extends: ['plugin:goodeggs/mocha'], 16 | env: { 17 | mocha: true, 18 | }, 19 | }, 20 | // Project configuration files 21 | { 22 | files: ['*.config{.babel,}.js', '.*rc.js'], 23 | env: { 24 | node: true, 25 | }, 26 | rules: { 27 | 'import/no-commonjs': 'off', 28 | }, 29 | }, 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /src/get_request_options/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import {JsonFetchOptions} from '..'; 4 | 5 | export default function getRequestOptions(jsonFetchOptions: JsonFetchOptions): RequestInit { 6 | const parsedOptions: RequestInit = {}; 7 | parsedOptions.headers = {}; 8 | 9 | if (jsonFetchOptions.body !== undefined) { 10 | parsedOptions.body = JSON.stringify(jsonFetchOptions.body); 11 | parsedOptions.headers['Content-Type'] = 'application/json'; 12 | } 13 | 14 | if (jsonFetchOptions.credentials === undefined) { 15 | parsedOptions.credentials = 'include'; 16 | } 17 | 18 | parsedOptions.headers = { 19 | accept: 'application/json', 20 | ...jsonFetchOptions.headers, 21 | ...parsedOptions.headers, 22 | }; 23 | 24 | const pickedOptions = _.pick(jsonFetchOptions, [ 25 | 'cache', 26 | 'credentials', 27 | 'headers', 28 | 'integrity', 29 | 'method', 30 | 'mode', 31 | 'redirect', 32 | 'referrer', 33 | 'referrerPolicy', 34 | 'timeout', 35 | ]); 36 | 37 | return {...pickedOptions, ...parsedOptions}; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Good Eggs Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Changes since last deploy](https://github.com/goodeggs/json-fetch/compare/v8.0.0...master) 2 | 3 | # [9.0.8](https://github.com/goodeggs/json-fetch/compare/v9.0.9...v9.0.10) 4 | 5 | - Check if incoming Fetch error is of type `null` before setting the code property. 6 | 7 | # [9.0.8](https://github.com/goodeggs/json-fetch/compare/v9.0.8...v9.0.9) 8 | 9 | - Replaced `responseOrError` prop of `OnRequestEnd` by `error` and `status` 10 | 11 | # [9.0.8](https://github.com/goodeggs/json-fetch/compare/v9.0.3...v9.0.8) 12 | 13 | - Added `OnRequestStart` callback function 14 | - Added `OnRequestEnd` callback function 15 | 16 | # [9.0.4](https://github.com/goodeggs/json-fetch/compare/v9.0.3...v9.0.4) 17 | 18 | - Migrated to Typescript 19 | - Removed Husky & Lint-staged 20 | 21 | # [9.0.3](https://github.com/goodeggs/json-fetch/compare/v8.0.0...v9.0.3) 22 | 23 | ## Breaking changes 24 | 25 | - Only support [Node 12+](https://github.com/nodejs/Release#release-schedule) due to requirements from updated dependencies. 26 | 27 | # [8.0.0](https://github.com/goodeggs/json-fetch/compare/v7.5.1...v8.0.0) 28 | 29 | ## Breaking changes 30 | 31 | - Only support [Node 8+](https://github.com/nodejs/Release#release-schedule) due to requirements from updated dependencies. 32 | 33 | ## Adopt Good Eggs developer tooling best practices 34 | 35 | - Babel 7 for build & modern js features 36 | - Eslint for code correctness 37 | - Prettier for formatting 38 | - leasot for todos 39 | - Husky & Lint-staged for auto format & lint on commit 40 | - Package scripts to run all the above 41 | - Deploy from CI 42 | - Use a changelog ;) 43 | - Update dependencies 44 | -------------------------------------------------------------------------------- /src/get_request_options/index.test.ts: -------------------------------------------------------------------------------- 1 | import fake from 'fake-eggs'; 2 | import {describe, it} from 'mocha'; 3 | import {expect} from 'goodeggs-test-helpers'; 4 | 5 | import getRequestOptions from '.'; 6 | 7 | describe('getRequestOptions', function () { 8 | it('populates an options object without undefined keys', function () { 9 | const expected = { 10 | credentials: 'include', 11 | headers: { 12 | accept: 'application/json', 13 | }, 14 | }; 15 | const actual = getRequestOptions({}); 16 | expect(actual).to.deep.equal(expected); 17 | }); 18 | 19 | it('sets content type header only when there is a body', function () { 20 | const expected = { 21 | credentials: 'include', 22 | headers: { 23 | accept: 'application/json', 24 | 'Content-Type': 'application/json', 25 | }, 26 | body: '{"hi":"hello"}', 27 | }; 28 | const actual = getRequestOptions({ 29 | body: { 30 | hi: 'hello', 31 | }, 32 | }); 33 | expect(actual).to.deep.equal(expected); 34 | }); 35 | it('includes whitelisted options', function () { 36 | const options = getRequestOptions({ 37 | timeout: 123, 38 | }); 39 | expect(options).to.have.property('timeout', 123); 40 | }); 41 | 42 | it('excluded non-whitelisted options', function () { 43 | const options = getRequestOptions({foo: 'bar'} as never); 44 | 45 | expect(options).not.to.contain.keys('foo'); 46 | }); 47 | 48 | it('forwards `credentials` option when provided', function () { 49 | const credentials: RequestCredentials = fake.sample(['omit', 'same-origin']); 50 | 51 | const expected = { 52 | credentials, 53 | headers: { 54 | accept: 'application/json', 55 | }, 56 | }; 57 | 58 | const actual = getRequestOptions({credentials}); 59 | expect(actual).to.deep.equal(expected); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: node_js 3 | node_js: 4 | - 14.21.3 5 | env: 6 | global: 7 | - PATH="/tmp/ci-tools:./node_modules/.bin/:$PATH" 8 | # NPM_TOKEN: 9 | - secure: 'VYRtwgXf8Ez7O2R8b6ppy6L/ZiJXGkog3Llx515VXU5/Cw2LK21ddVKDR4+mqcJ9jwSBRkS7SSHM8M9GKYILgo/h444ccX3ynnFqN8Vi57ikLWCTotiKtVrijXAaZVF+TV/l60Gyx05Nn6CytyyQc++2obx4u9VgDvuwldpO8y5K7pJeEjvJyxxb+/vvdrZlmgMTxyw7+oMzRiqRsDUqQBdUzItiiMqmZGdtGDoJXG2S4HHPT3MXfqZBaz0DeSs9z7yZ0sThG1p5jWEu64rWRzlDNN71ZNdwBvP8uneWqXsf+srZKLJKcF9XRe0q7i2RasM5pK6UoWoRg4x3LL91WxU5zi6aH6Dx66Ey14XjjL8EGDL+xM5DNBQ78ONV9ktTfBYIbKVTqlpN/umyrB+ZzLSW+ALnlxCacmR99KhQST9tFuEAKT517xGn4GM9Ywa9OBWvf2i0/OEsRpPIZGfAbg/bT0/Bf3etx6xuV1d7RrsALSSgHwwiI6rJvWEUvoRZT9o2O1mM4Wg+7k95JAk7q6J6n0SqaIzpwuhx2C9T1B/ZVmoMyhRDeyYuAOHD0QwQCzrRCd/KqSsm5dNgumthspq7mczEl3iUXn6EAp++gHSzj58M5+gePAvu3vo28HTz6IOUA9u9UOLLCh9gX3sEqumITaEIBcFVrRLAYn4VYM4=' 10 | cache: 11 | yarn: true 12 | directories: 13 | - '/tmp/ci-tools' 14 | before_install: 15 | - curl -sSL https://github.com/goodeggs/travis-utils/raw/master/install-ci-tools.sh 16 | | CACHE_DIR=/tmp/ci-tools sh -s -- yarn=1.9.4 codecov 17 | after_script: 18 | - codecov 19 | install: yarn install 20 | script: yarn run test 21 | deploy: 22 | provider: npm 23 | email: open-source@goodeggs.com 24 | api_token: 25 | secure: 'mclmG0+zHQnQ3HlOCUEY3ioec8NT0jhOpk5fgN+vWnIlepFJ7cqD0LjhaXytnv4/WEQic25ym0mSD6OxFunJ+0h9CzMg3GCIIXAq7lrdQyxwFGn40kNSLlWyW+SniOE0zjb+8ne3zzMdXyquV0Lg43VflSn+5TzCRSsNjAQIXH6LySWqT/rVOzXiYvnV64A1XUCIWofCeDvrSbMmtXHqAQPXdd+N0zMN7y7jmQHjdoI4ZGcaec6Q+ygo/HfTST4anmyoe8EKfv9NFEBZx8rPlxoGJBl1HWoADl9OJdmUIaQon2bdn1oIyyhz1Jloxvp6uxZ/rdhkVaJ9qykPSbZd/du1W4itivPUcSRqIN95WxPgNXVieDLm02v5OD4F8pJV2FsvLbS+Zr8uBe5l+HgFdsFVcZ11XtimZD0lZtb9amfvfuaAn46GqpiygPANVDHt7+3wuw9rr8VlZ575MuPvz9N74fJ5hmcb/UGCiHBD4mQowLrpCvBAWPjDjli532XzXBu9Z3tHgeD6/leggDNiNlwrxL5sTUbF/UUngKD6k4I00oys6DJtwpPu02KYGMeY4sMPNQ7SlxoiRU/uAgld1iDUm5jAZTUAB8N59Hxp9Ib027v1B+xdSTOfcMW8XOxATkofEG8paAW1WBTgJBZLNSI6OxEPiZjxo9NQnAblnnU=' 26 | skip_cleanup: true 27 | on: 28 | tags: true 29 | all_branches: true 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-fetch", 3 | "version": "9.0.10", 4 | "description": "A wrapper around ES6 fetch to simplify interacting with JSON APIs.", 5 | "author": "Good Eggs ", 6 | "contributors": [ 7 | "dannynelson ", 8 | "Arlo Armstrong " 9 | ], 10 | "license": "MIT", 11 | "keywords": [ 12 | "fetch", 13 | "api", 14 | "json", 15 | "es6" 16 | ], 17 | "main": "lib/index.js", 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/goodeggs/json-fetch.git" 21 | }, 22 | "publishConfig": { 23 | "registry": "https://registry.npmjs.org/", 24 | "access": "public" 25 | }, 26 | "homepage": "https://github.com/goodeggs/json-fetch", 27 | "bugs": "https://github.com/goodeggs/json-fetch/issues", 28 | "engines": { 29 | "node": ">=12" 30 | }, 31 | "dependencies": { 32 | "@babel/runtime": "^7.24.7", 33 | "@types/promise-retry": "^1.1.6", 34 | "isomorphic-fetch": "2.2.1", 35 | "lodash": "^4.17.21", 36 | "promise-retry": "1.1.1" 37 | }, 38 | "devDependencies": { 39 | "@babel/cli": "^7.24.7", 40 | "@babel/core": "^7.24.7", 41 | "@babel/plugin-transform-runtime": "^7.24.7", 42 | "@babel/polyfill": "^7.12.1", 43 | "@babel/preset-env": "^7.24.7", 44 | "@babel/preset-typescript": "^7.24.7", 45 | "@babel/register": "^7.24.6", 46 | "@goodeggs/toolkit": "^7.0.1", 47 | "@goodeggs/tsconfig": "^1.0.0", 48 | "@types/isomorphic-fetch": "^0.0.39", 49 | "@types/lodash": "^4.17.5", 50 | "@types/mocha": "^10.0.7", 51 | "@types/node-fetch": "^2.6.11", 52 | "fake-eggs": "^6.5.3", 53 | "flow-bin": "^0.238.2", 54 | "goodeggs-test-helpers": "^8.3.2", 55 | "leasot": "^13.3.0", 56 | "mocha": "10.5.2", 57 | "nock": "13.5.4", 58 | "nyc": "^15.1.0", 59 | "typescript": "^4.3.5" 60 | }, 61 | "scripts": { 62 | "build": "yarn run build:clean && yarn run build:transpile && yarn run build:types", 63 | "build:clean": "rm -rf lib", 64 | "build:types": "tsc --project tsconfig.declarations.json", 65 | "build:transpile": "babel --extensions=.ts,.js,.jsx,.tsx src --out-dir lib --copy-files", 66 | "lint": "yarn run lint:es", 67 | "lint:es": "getk run lint-es", 68 | "lint:fix": "yarn run lint:fix:es", 69 | "lint:fix:es": "getk run fix-es", 70 | "prepublishOnly": "yarn run build", 71 | "postversion": "git push --follow-tags", 72 | "tdd": "yarn run test:mocha --watch", 73 | "test": "yarn run todos && yarn run lint && yarn run typecheck && yarn run test:mocha:coverage", 74 | "test:mocha": "yarn run test:mocha:glob 'src/**/{,*.}test.ts'", 75 | "test:mocha:coverage": "yarn run nyc --report-dir=coverage --temp-directory=coverage/.nyc_output --reporter=lcov --reporter=text-summary yarn run test:mocha", 76 | "test:mocha:glob": "NODE_ENV=test yarn run mocha --require @babel/polyfill --require .register.js --extension ts", 77 | "todos": "yarn run todos:glob '**/*.{js,jsx,ts,tsx}'", 78 | "todos:glob": "leasot --exit-nicely --ignore='node_modules/**','lib/**'", 79 | "typecheck": "tsc" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Good Eggs Code of Conduct v0.1.0 2 | 3 | This Code of Conduct governs how we behave in any forum and whenever we will be judged by our actions. We expect it to be honored by everyone who represents the [Good Eggs Open Source](https://bites.goodeggs.com) community officially or informally, claims affiliation with the project or participates directly. 4 | 5 | We strive to: 6 | 7 | - **Be open**: We invite anybody, from any company, to participate in any aspect of our projects. Our community is open, and any responsibility can be carried by any contributor who demonstrates the required capacity and competence. 8 | - **Be empathetic**: We work together to resolve conflict, assume good intentions and do our best to act in an empathic fashion. We don't allow frustration to turn into a personal attack. A community where people feel uncomfortable or threatened is not a productive one. 9 | - **Be collaborative**: Collaboration reduces redundancy and improves the quality of our work. We prefer to work transparently and involve interested parties as early as possible. Wherever possible, we work closely with upstream projects and others in the free software community to coordinate our efforts. 10 | - **Be pragmatic**: Nobody knows everything, Asking questions early avoids many problems later, so questions are encouraged, though they may be directed to the appropriate forum. Those who are asked should be responsive and helpful. 11 | - **Step down considerately**: Members of every project come and go. When somebody leaves or disengages from the project they should tell people they are leaving and take the proper steps to ensure that others can pick up where they left off. 12 | 13 | This code is not exhaustive or complete. It serves to distill our common understanding of a collaborative, shared environment and goals. We expect it to be followed in spirit as much as in the letter. 14 | 15 | ## Diversity Statement 16 | 17 | We encourage participation by everyone. We are committed to being a community that everyone feels good about joining. Although we may not be able to satisfy everyone, we will always work to treat everyone well. 18 | 19 | Standards for behavior in the Good Eggs Open Source community are detailed in the Code of Conduct above. We expect participants in our community to meet these standards in all their interactions and to help others to do so as well. 20 | 21 | Whenever any participant has made a mistake, we expect them to take responsibility for it. If someone has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do our best to right the wrong. 22 | 23 | Although this list cannot be exhaustive, we explicitly honor diversity in age, culture, ethnicity, genotype, gender identity or expression, language, national origin, neurotype, phenotype, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, subculture and technical ability. 24 | 25 | ## Reporting Issues 26 | 27 | It's first recommend you speak with respective project leads and committers about the issue. 28 | 29 | If that doesn't work: 30 | 31 | - You can report any code of conduct compliance issues by opening an issue in this repository 32 | - If you prefer a more discreet route, please email [open-source@goodeggs.com](open-source@goodeggs.com) 33 | 34 | ## Credit (and copyright information) 35 | 36 | The wording of this code of conduct and diversity statement was heavily borrowed from work by the [Twitter](https://github.com/twitter/code-of-conduct), [Python](http://www.python.org/community/diversity), [Ubuntu](http://www.ubuntu.com/about/about-ubuntu/conduct) and [Mozilla](https://wiki.mozilla.org/Code_of_Conduct/Draft) communities. It is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | import {Agent} from 'http'; 4 | import promiseRetry from 'promise-retry'; 5 | 6 | import getRequestOptions from './get_request_options'; 7 | 8 | export type ShouldRetry = (responseOrError: Response | Error) => boolean; 9 | 10 | export interface OnRequestOptions extends RequestInit { 11 | url: string; 12 | retryCount: number; 13 | } 14 | 15 | export interface OnRequestEndOptions extends OnRequestOptions { 16 | error?: Error; 17 | status?: Response['status']; 18 | } 19 | export interface JsonFetchOptions extends Omit { 20 | // node-fetch extensions (not available in browsers, i.e. whatwg-fetch) – 21 | // see https://github.com/node-fetch/node-fetch/blob/8721d79208ad52c44fffb4b5b5cfa13b936022c3/%40types/index.d.ts#L76: 22 | agent?: Agent | ((parsedUrl: URL) => Agent); 23 | 24 | // json-fetch options: 25 | body?: Record; 26 | shouldRetry?: (responseOrError: Response | Error) => boolean; 27 | retry?: Parameters[0]; 28 | timeout?: number; 29 | expectedStatuses?: number[]; 30 | onRequestStart?: (opts: OnRequestOptions) => void; 31 | onRequestEnd?: (opts: OnRequestEndOptions) => void; 32 | } 33 | 34 | export interface JsonFetchResponse { 35 | status: number; 36 | statusText: string; 37 | headers: Headers; 38 | text: string; 39 | body: T; 40 | } 41 | 42 | export class FetchUnexpectedStatusError< 43 | T extends { 44 | readonly status: number; 45 | }, 46 | > extends Error { 47 | response?: T; 48 | } 49 | 50 | export {default as retriers} from './retriers'; 51 | 52 | const DEFAULT_RETRY_OPTIONS = { 53 | retries: 0, 54 | }; 55 | 56 | const DEFAULT_SHOULD_RETRY: ShouldRetry = () => false; 57 | 58 | export default async function jsonFetch( 59 | requestUrl: string, 60 | jsonFetchOptions: JsonFetchOptions = {}, 61 | ): Promise { 62 | const {expectedStatuses} = jsonFetchOptions; 63 | 64 | try { 65 | const response = await retryFetch(requestUrl, jsonFetchOptions); 66 | const jsonFetchResponse = await createJsonFetchResponse(response); 67 | assertExpectedStatus(expectedStatuses, jsonFetchResponse); 68 | return jsonFetchResponse; 69 | } catch (error) { 70 | error.request = getErrorRequestData({ 71 | requestUrl, 72 | requestOptions: jsonFetchOptions, 73 | }); 74 | throw error; 75 | } 76 | } 77 | 78 | async function retryFetch( 79 | requestUrl: string, 80 | jsonFetchOptions: JsonFetchOptions, 81 | ): Promise { 82 | const shouldRetry = jsonFetchOptions.shouldRetry ?? DEFAULT_SHOULD_RETRY; 83 | const retryOptions = {...DEFAULT_RETRY_OPTIONS, ...jsonFetchOptions.retry}; 84 | const requestOptions = getRequestOptions(jsonFetchOptions); 85 | 86 | try { 87 | const response = await promiseRetry(async (throwRetryError, retryCount) => { 88 | jsonFetchOptions.onRequestStart?.({url: requestUrl, retryCount, ...requestOptions}); 89 | try { 90 | const res = await fetch(requestUrl, requestOptions); 91 | if (shouldRetry(res)) throwRetryError(null); 92 | jsonFetchOptions.onRequestEnd?.({ 93 | status: res.status, 94 | url: requestUrl, 95 | retryCount, 96 | ...requestOptions, 97 | }); 98 | return res; 99 | } catch (err) { 100 | err.retryCount = retryCount - 1; 101 | jsonFetchOptions.onRequestEnd?.({ 102 | error: err, 103 | url: requestUrl, 104 | retryCount, 105 | ...requestOptions, 106 | }); 107 | if (err.code !== 'EPROMISERETRY' && shouldRetry(err)) throwRetryError(err); 108 | throw err; 109 | } 110 | }, retryOptions); 111 | return response; 112 | } catch (err) { 113 | if (err != null) { 114 | err.name = 'FetchError'; 115 | } 116 | throw err; 117 | } 118 | } 119 | 120 | async function createJsonFetchResponse(response: Response): Promise { 121 | const responseText = await response.text(); 122 | return { 123 | status: response.status, 124 | statusText: response.statusText, 125 | headers: response.headers, 126 | text: responseText, 127 | body: getResponseBody(response, responseText), 128 | }; 129 | } 130 | 131 | interface ErrorResponse { 132 | status: number; 133 | statusText: string; 134 | text: string; 135 | } 136 | function createErrorResponse(response: Response, responseText: string): ErrorResponse { 137 | // do not include headers as they potentially contain sensitive information 138 | return { 139 | status: response.status, 140 | statusText: response.statusText, 141 | text: responseText, 142 | }; 143 | } 144 | 145 | function getResponseBody(response: Response, responseText: string): JSON | null | undefined { 146 | if (isApplicationJson(response.headers)) 147 | try { 148 | return JSON.parse(responseText); 149 | } catch (err) { 150 | err.response = createErrorResponse(response, responseText); 151 | throw err; 152 | } 153 | return undefined; 154 | } 155 | 156 | function isApplicationJson(headers: Headers): boolean { 157 | const responseContentType = headers.get('Content-Type') ?? ''; 158 | return responseContentType.includes('application/json'); 159 | } 160 | 161 | function assertExpectedStatus< 162 | T extends { 163 | readonly status: number; 164 | }, 165 | >(expectedStatuses: number[] | null | undefined, jsonFetchResponse: T): void { 166 | if (Array.isArray(expectedStatuses) && !expectedStatuses.includes(jsonFetchResponse.status)) { 167 | const err = new FetchUnexpectedStatusError( 168 | `Unexpected fetch response status ${jsonFetchResponse.status}`, 169 | ); 170 | 171 | err.name = 'FetchUnexpectedStatusError'; 172 | err.response = jsonFetchResponse; 173 | 174 | throw err; 175 | } 176 | } 177 | 178 | interface ErrorRequestData extends Omit { 179 | url: string; 180 | } 181 | function getErrorRequestData({ 182 | requestUrl, 183 | requestOptions, 184 | }: { 185 | requestUrl: string; 186 | requestOptions: JsonFetchOptions; 187 | }): ErrorRequestData { 188 | const data = {...requestOptions, url: requestUrl}; 189 | // do not include headers as they potentially contain sensitive information 190 | delete data.headers; 191 | 192 | return data; 193 | } 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Fetch 2 | 3 | [![codecov badge](https://codecov.io/gh/goodeggs/json-fetch/branch/master/graph/badge.svg)](https://codecov.io/gh/goodeggs/json-fetch) 4 | 5 | A wrapper around ES6 fetch to simplify interacting with JSON APIs. 6 | 7 | - automatically JSON stringify request body 8 | - set JSON request headers 9 | - resolve with json for any response with the `Content-Type`: `application/json` header 10 | - include request credentials by default 11 | - configurable retry option for requests 12 | 13 | [![build status][travis-badge]][travis-link] 14 | [![MIT license][license-badge]][license-link] 15 | 16 | ## Usage 17 | 18 | ```sh 19 | yarn add json-fetch 20 | # or... 21 | npm install json-fetch 22 | ``` 23 | 24 | ```js 25 | import jsonFetch from 'json-fetch'; 26 | 27 | jsonFetch('http://www.test.com/products/1234', { 28 | body: {name: 'apple'}, // content to be JSON stringified 29 | credentials: 'omit', // "include" by default 30 | expectedStatuses: [201], // rejects "FetchUnexpectedStatusError" on unexpected status (optional) 31 | // supports all normal json-fetch options: 32 | method: 'POST', 33 | }) 34 | .then((response) => { 35 | // handle response with expected status: 36 | console.log(response.body); // json response here 37 | console.log(response.status); 38 | console.log(response.statusText); 39 | console.log(response.headers); 40 | }) 41 | .catch((err) => { 42 | // handle response with unexpected status: 43 | console.log(err.name); 44 | console.log(err.message); 45 | console.log(err.response.status); 46 | console.log(err.response.statusText); 47 | console.log(err.response.body); 48 | console.log(err.response.text); 49 | console.log(err.response.headers); 50 | }); 51 | ``` 52 | 53 | ### TypeScript 54 | 55 | This library comes with built-in TypeScript type declarations. 56 | 57 | Due to complexities in dealing with isomorphic-fetch - which uses whatwg-fetch in browsers and node-fetch 58 | in node.js, which are subtly different - these type declarations only work if you include the `DOM` built-in 59 | TypeScript lib in your `tsconfig.json`. For example: 60 | 61 | ```json 62 | { 63 | "lib": ["DOM", "ES2020"] 64 | } 65 | ``` 66 | 67 | This happens implicitly if you don't set a `lib`. 68 | 69 | This may be fixed in the future. 70 | 71 | ### Retry Behavior 72 | 73 | By default, jsonFetch doesn't retry requests. However, you may opt in to jsonFetch's very flexible retry behavior, provided by the excellent [`promise-retry`](https://github.com/IndigoUnited/node-promise-retry) library. Here's a quick example: 74 | 75 | ```js 76 | import jsonFetch, {retriers} from 'json-fetch' 77 | 78 | jsonFetch('http://www.test.com/products/1234', { 79 | method: 'POST', 80 | body: {name: 'apple'}, 81 | shouldRetry: retriers.isNetworkError // after every request, retry if a network error is thrown 82 | retry: { 83 | // Retry 5 times, in addition to the original request 84 | retries: 5, 85 | } 86 | }).then(response => { 87 | // handle responses 88 | }); 89 | ``` 90 | 91 | Any option that `promise-retry` accepts will be passed through from `options.retry`. See [the promise-retry documentation](https://github.com/IndigoUnited/node-promise-retry#promiseretryfn-options) for all options. 92 | 93 | ### Custom Retry Logic 94 | 95 | We've provided two default "retrier" functions that decide to retry 503/504 status code responses and network errors (`jsonFetch.retriers.is5xx` and `jsonFetch.retriers.isNetworkError` respectively). You can easily provide your own custom retrier function to `options.shouldRetry`. 96 | 97 | The contract for a retrier function is: 98 | 99 | ```js 100 | shouldRetry([Error || FetchResponse]) returns bool 101 | ``` 102 | 103 | You can use any attribute of the [FetchResponse](https://developer.mozilla.org/en-US/docs/Web/API/Response) or Error to determine whether to retry or not. Your function _must_ handle both errors (such as network errors) and FetchResponse objects without blowing up. We recommend stateless, side-effect free functions. You do not need to worry about the maximum number of retries -- promise-retry will stop retrying after the maximum you specify. See the tests and `src/retriers.js` file for examples. 104 | 105 | ### On Request callbacks 106 | 107 | Two callback functions can be passed as options to do something `onRequestStart` and `onRequestEnd`. This may be especially helpful to log request and response data on each request. 108 | If you have retries enabled, these will trigger before and after each _actual, individual request_. 109 | 110 | #### `onRequestStart` 111 | 112 | If given, `onRequestStart` is called with: 113 | 114 | ```typescript 115 | { 116 | // ... all the original json-fetch options, plus: 117 | url: string; 118 | retryCount: number; 119 | } 120 | ``` 121 | 122 | #### `onRequestEnd` 123 | 124 | If given, `onRequestEnd` is called with: 125 | 126 | ```typescript 127 | { 128 | // ... all the original json-fetch options, plus: 129 | url: string; 130 | retryCount: number; 131 | status?: Response['status']; 132 | error?: Error; 133 | } 134 | ``` 135 | 136 | For example, to log before and after each request: 137 | 138 | ```typescript 139 | const requestUrl = 'http://www.test.com/products/1234'; 140 | await jsonFetch(requestUrl, { 141 | onRequestStart: ({url, timeout, retryCount}) => 142 | console.log(`Requesting ${url} with timeout ${timeout}, attempt ${retryCount}`), 143 | onRequestEnd: ({url, retryCount, status}) => 144 | console.log(`Requested ${url}, attempt ${retryCount}, got status ${status}`), 145 | }); 146 | ``` 147 | 148 | ## Contributing 149 | 150 | Please follow our [Code of Conduct](CODE_OF_CONDUCT.md) when contributing to this project. 151 | 152 | ``` 153 | yarn install 154 | yarn test 155 | ``` 156 | 157 | ## Deploying a new version 158 | 159 | This module is automatically deployed when a version tag bump is detected by Travis. 160 | Remember to update the [changelog](CHANGELOG.md)! 161 | 162 | ``` 163 | yarn version 164 | ``` 165 | 166 | ## License 167 | 168 | [MIT](License.md) 169 | 170 | _Module scaffold generated by [generator-goodeggs-npm](https://github.com/goodeggs/generator-goodeggs-npm)._ 171 | 172 | [travis-badge]: http://img.shields.io/travis/goodeggs/json-fetch.svg?style=flat-square 173 | [travis-link]: https://travis-ci.org/goodeggs/json-fetch 174 | [npm-badge]: http://img.shields.io/npm/v/json-fetch.svg?style=flat-square 175 | [npm-link]: https://www.npmjs.org/package/json-fetch 176 | [license-badge]: http://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 177 | [license-link]: LICENSE.md 178 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | import {describe, it, beforeEach, afterEach} from 'mocha'; 4 | import {expect, useSinonSandbox} from 'goodeggs-test-helpers'; 5 | import fake from 'fake-eggs'; 6 | import nock from 'nock'; 7 | 8 | import jsonFetch, {retriers} from '.'; 9 | 10 | declare global { 11 | // We are just extending this existing NodeJS namespace in order to test and create stubs with 12 | // Sinon, actually, `isomorphic-fetch` already populates the global scope with a `fetch` instance 13 | // but Typescript definitions doesn't match this behavior by now. 14 | // @see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/f7ec78508c6797e42f87a4390735bc2c650a1bfd/types/isomorphic-fetch/index.d.ts 15 | // eslint-disable-next-line @typescript-eslint/no-namespace 16 | namespace NodeJS { 17 | interface Global { 18 | fetch: typeof fetch; 19 | } 20 | } 21 | } 22 | 23 | describe('jsonFetch', () => { 24 | const {sandbox} = useSinonSandbox(); 25 | describe('single request with no retry', () => { 26 | it('resolves with json body for 200 status codes', async () => { 27 | nock('http://www.test.com').get('/products/1234').reply(200, { 28 | name: 'apple', 29 | }); 30 | const response = await jsonFetch('http://www.test.com/products/1234'); 31 | expect(response.body).to.deep.equal({ 32 | name: 'apple', 33 | }); 34 | expect(response.status).to.equal(200); 35 | expect(response.statusText).to.equal('OK'); 36 | expect(response.headers).to.be.ok(); 37 | }); 38 | 39 | it('resolves with JSON body for 500 status codes', async () => { 40 | nock('http://www.test.com').get('/products/1234').reply(500, '"Something went wrong"', { 41 | 'Content-Type': 'application/json', 42 | }); 43 | const response = await jsonFetch('http://www.test.com/products/1234'); 44 | expect(response.body).to.deep.equal('Something went wrong'); 45 | expect(response.status).to.equal(500); 46 | expect(response.statusText).to.equal('Internal Server Error'); 47 | expect(response.headers).to.be.ok(); 48 | }); 49 | it('resolves with JSON body when content-type contains other values but includes application/json', async () => { 50 | nock('http://www.test.com').get('/products/1234').reply(204, '[{}]', { 51 | 'Content-Type': 'application/json; charset=utf-8', 52 | }); 53 | const response = await jsonFetch('http://www.test.com/products/1234'); 54 | expect(response.body).to.deep.equal([{}]); 55 | }); 56 | 57 | it('resolves with non-JSON body', async () => { 58 | nock('http://www.test.com').get('/products/1234').reply(200, 'This is not JSON', { 59 | 'Content-Type': 'text/plain', 60 | }); 61 | const response = await jsonFetch('http://www.test.com/products/1234'); 62 | expect(response.body).to.equal(undefined); 63 | }); 64 | it('rejects when there is a connection error', async () => { 65 | sandbox.stub(global, 'fetch').callsFake(async () => { 66 | throw new Error('Something is broken!'); 67 | }); 68 | let errorThrown = false; 69 | 70 | try { 71 | await jsonFetch('http://www.test.com/products/1234'); 72 | } catch (err) { 73 | errorThrown = true; 74 | expect(err.name).to.deep.equal('FetchError'); 75 | expect(err.message).to.deep.equal('Something is broken!'); 76 | expect(err.request.url).to.deep.equal('http://www.test.com/products/1234'); 77 | } 78 | 79 | expect(errorThrown).to.be.true(); 80 | }); 81 | 82 | it('rejects with responseText when there is a json parse error', async () => { 83 | nock('http://www.test.com').get('/products/1234').reply(200, 'foo', { 84 | 'Content-Type': 'application/json; charset=utf-8', 85 | }); 86 | let errorThrown = false; 87 | 88 | try { 89 | await jsonFetch('http://www.test.com/products/1234'); 90 | } catch (err) { 91 | errorThrown = true; 92 | expect(err.name).to.deep.equal('SyntaxError'); 93 | expect(err.message).to.match(/Unexpected token/); 94 | expect(err.response.text).to.deep.equal('foo'); 95 | expect(err.response.status).to.deep.equal(200); 96 | expect(err.request.url).to.deep.equal('http://www.test.com/products/1234'); 97 | } 98 | 99 | expect(errorThrown).to.be.true(); 100 | }); 101 | 102 | it('sends json request body', async () => { 103 | nock('http://www.test.com') 104 | .post('/products/1234', { 105 | name: 'apple', 106 | }) 107 | .reply(201, { 108 | _id: '1234', 109 | name: 'apple', 110 | }); 111 | const response = await jsonFetch('http://www.test.com/products/1234', { 112 | method: 'POST', 113 | body: { 114 | name: 'apple', 115 | }, 116 | }); 117 | expect(response.body).to.deep.equal({ 118 | _id: '1234', 119 | name: 'apple', 120 | }); 121 | expect(response.status).to.equal(201); 122 | expect(response.statusText).to.equal('Created'); 123 | expect(response.headers).to.be.ok(); 124 | }); 125 | 126 | it('calls onRequestStart when is passed to jsonFetch in JsonFetchOptions', async () => { 127 | const onRequestStart = sandbox.stub(); 128 | nock('http://www.test.com').get('/products/1234').reply(200); 129 | await jsonFetch('http://www.test.com/products/1234', { 130 | onRequestStart, 131 | }); 132 | expect(onRequestStart).to.have.been.calledOnce(); 133 | expect(onRequestStart).to.have.been.calledWithMatch({ 134 | url: 'http://www.test.com/products/1234', 135 | retryCount: 1, 136 | headers: {accept: 'application/json'}, 137 | credentials: 'include', 138 | }); 139 | }); 140 | 141 | it('calls onRequestEnd when is passed to jsonFetch in JsonFetchOptions', async () => { 142 | const onRequestEnd = sandbox.stub(); 143 | nock('http://www.test.com').get('/products/1234').reply(200); 144 | await jsonFetch('http://www.test.com/products/1234', { 145 | onRequestEnd, 146 | }); 147 | expect(onRequestEnd).to.have.been.calledOnce(); 148 | expect(onRequestEnd).to.have.been.calledWithMatch({ 149 | url: 'http://www.test.com/products/1234', 150 | retryCount: 1, 151 | headers: {accept: 'application/json'}, 152 | status: 200, 153 | }); 154 | }); 155 | 156 | it('onRequestEnd returns error object when request fails', async () => { 157 | sandbox.stub(global, 'fetch').callsFake(async () => { 158 | throw new Error('Something is broken!'); 159 | }); 160 | const onRequestEnd = sandbox.stub(); 161 | try { 162 | await jsonFetch('http://www.test.com/products/1234', { 163 | onRequestEnd, 164 | }); 165 | } catch { 166 | expect(onRequestEnd).to.have.been.calledOnce(); 167 | expect(onRequestEnd).to.have.been.calledWithMatch({ 168 | error: { 169 | request: { 170 | url: 'http://www.test.com/products/1234', 171 | }, 172 | }, 173 | }); 174 | } 175 | }); 176 | }); 177 | 178 | describe('expected statuses', () => { 179 | it('errors with FetchUnexpectedStatus if the response has an unexpected status code', async () => { 180 | nock('http://www.test.com').get('/products/1234').reply(400, 'not found'); 181 | 182 | try { 183 | await jsonFetch('http://www.test.com/products/1234', { 184 | expectedStatuses: [201], 185 | }); 186 | } catch (err) { 187 | expect(err.name).to.equal('FetchUnexpectedStatusError'); 188 | expect(err.message).to.equal('Unexpected fetch response status 400'); 189 | expect(err.request.url).to.equal('http://www.test.com/products/1234'); 190 | expect(err.response).to.have.property('status', 400); 191 | expect(err.response).to.have.property('text', 'not found'); 192 | return; 193 | } 194 | 195 | throw new Error('expected to throw'); 196 | }); 197 | 198 | it('returns a response with an expected status code', async () => { 199 | nock('http://www.test.com').get('/products/1234').reply(201, 'not found'); 200 | const response = await jsonFetch('http://www.test.com/products/1234', { 201 | expectedStatuses: [201], 202 | }); 203 | expect(response).to.have.property('status', 201); 204 | }); 205 | it('returns a response without an expected status code', async () => { 206 | nock('http://www.test.com').get('/products/1234').reply(404, 'not found'); 207 | const response = await jsonFetch('http://www.test.com/products/1234'); 208 | expect(response).to.have.property('status', 404); 209 | }); 210 | }); 211 | 212 | describe('retry', () => { 213 | let fetchSpy: sinon.SinonSpy< 214 | [input: RequestInfo, init?: RequestInit | undefined], 215 | Promise 216 | >; 217 | 218 | beforeEach(() => { 219 | fetchSpy = sandbox.spy(global, 'fetch'); 220 | }); 221 | 222 | afterEach(() => { 223 | fetchSpy.restore(); 224 | }); 225 | 226 | it('does not retry by default', async () => { 227 | nock('http://www.test.com').get('/').reply(200, {}); 228 | await jsonFetch('http://www.test.com/'); 229 | expect(fetchSpy.callCount).to.equal(1); 230 | }); 231 | 232 | it('does not retry and calls OnRequest callbacks one single time each by default', async () => { 233 | const onRequestStart = sandbox.stub(); 234 | const onRequestEnd = sandbox.stub(); 235 | nock('http://www.test.com').get('/').reply(200, {}); 236 | await jsonFetch('http://www.test.com/', {onRequestStart, onRequestEnd}); 237 | expect(fetchSpy).to.have.been.calledOnce(); 238 | expect(onRequestStart).to.have.been.calledWithMatch({retryCount: 1}); 239 | expect(onRequestEnd).to.have.been.calledWithMatch({retryCount: 1}); 240 | expect(onRequestStart).to.have.been.calledOnce(); 241 | expect(onRequestEnd).to.have.been.calledOnce(); 242 | }); 243 | 244 | it('does specified number of retries', async () => { 245 | nock('http://www.test.com').get('/').reply(200, {}); 246 | 247 | try { 248 | await jsonFetch('http://www.test.com/', { 249 | shouldRetry: () => true, 250 | retry: { 251 | retries: 5, 252 | factor: 0, 253 | }, 254 | }); 255 | } catch (err) { 256 | expect(err.request.url).to.equal('http://www.test.com/'); 257 | expect(err.request.retry.retries).to.equal(5); 258 | expect(fetchSpy.callCount).to.equal(6); // 5 retries + 1 original = 6 259 | 260 | return; 261 | } 262 | 263 | throw new Error('Should have failed'); 264 | }); 265 | 266 | it('respects the shouldRetry() function', async () => { 267 | nock('http://www.test.com').get('/').times(6).reply(200, {}); 268 | 269 | try { 270 | await jsonFetch('http://www.test.com/', { 271 | shouldRetry: () => fetchSpy.callCount < 3, 272 | retry: { 273 | retries: 5, 274 | factor: 0, 275 | }, 276 | }); 277 | } catch (_err) { 278 | throw new Error('Should not fail'); 279 | } 280 | 281 | expect(fetchSpy.callCount).to.equal(3); // 2 retries + 1 original = 3 282 | }); 283 | }); 284 | 285 | describe('retry network errors', () => { 286 | let fetchStub: sinon.SinonStub< 287 | [input: RequestInfo, init?: RequestInit | undefined], 288 | Promise 289 | >; 290 | 291 | beforeEach(() => { 292 | fetchStub = sandbox.stub(global, 'fetch'); 293 | }); 294 | 295 | afterEach(() => { 296 | fetchStub.restore(); 297 | }); 298 | 299 | it('respects the should retry function for a network error', async () => { 300 | fetchStub.rejects(new Error('ECONRST')); 301 | 302 | try { 303 | await jsonFetch('foo.bar', { 304 | shouldRetry: () => true, 305 | retry: { 306 | retries: 5, 307 | factor: 0, 308 | }, 309 | }); 310 | } catch (err) { 311 | expect(fetchStub.callCount).to.equal(6); 312 | expect(err.message).to.equal('ECONRST'); 313 | return; 314 | } 315 | 316 | throw new Error('Should have failed'); 317 | }); 318 | 319 | it('adds the retryCount to the error', async () => { 320 | fetchStub.rejects(new Error('ECONRST')); 321 | 322 | try { 323 | await jsonFetch('foo.bar', { 324 | shouldRetry: () => true, 325 | retry: { 326 | retries: 5, 327 | factor: 0, 328 | }, 329 | }); 330 | } catch (err) { 331 | expect(fetchStub.callCount).to.equal(6); 332 | expect(err.message).to.equal('ECONRST'); 333 | expect(err.retryCount).to.equal(5); 334 | return; 335 | } 336 | 337 | throw new Error('Should have failed'); 338 | }); 339 | 340 | it('calls the onRequestStart and onRequestEnd functions in each retry', async () => { 341 | const onRequestStart = sandbox.stub(); 342 | const onRequestEnd = sandbox.stub(); 343 | 344 | try { 345 | await jsonFetch('foo.bar', { 346 | shouldRetry: () => true, 347 | retry: { 348 | retries: 3, 349 | factor: 0, 350 | }, 351 | onRequestStart, 352 | onRequestEnd, 353 | }); 354 | } catch { 355 | expect(onRequestStart).to.have.been.calledWithMatch({retryCount: 1}); 356 | expect(onRequestStart).to.have.been.calledWithMatch({retryCount: 2}); 357 | expect(onRequestStart).to.have.been.calledWithMatch({retryCount: 3}); 358 | expect(onRequestStart).to.have.been.calledWithMatch({retryCount: 4}); 359 | expect(onRequestEnd).to.have.been.calledWithMatch({retryCount: 1}); 360 | expect(onRequestEnd).to.have.been.calledWithMatch({retryCount: 2}); 361 | expect(onRequestEnd).to.have.been.calledWithMatch({retryCount: 3}); 362 | expect(onRequestEnd).to.have.been.calledWithMatch({retryCount: 4}); 363 | expect(onRequestStart).to.have.callCount(4); 364 | expect(onRequestEnd).to.have.callCount(4); 365 | return; 366 | } 367 | 368 | throw new Error('Should have failed'); 369 | }); 370 | 371 | it('call the onRequestStart and onRequestEnd functions when non-retryable setup is passed', async () => { 372 | const onRequestStart = sandbox.stub(); 373 | const onRequestEnd = sandbox.stub(); 374 | 375 | try { 376 | await jsonFetch('foo.bar', { 377 | shouldRetry: () => false, 378 | onRequestStart, 379 | onRequestEnd, 380 | }); 381 | } catch { 382 | expect(onRequestStart).to.have.been.calledOnce(); 383 | expect(onRequestEnd).to.have.been.calledOnce(); 384 | return; 385 | } 386 | 387 | throw new Error('Should have failed'); 388 | }); 389 | }); 390 | 391 | describe('retriers', () => { 392 | describe('.is5xx', () => { 393 | it('accepts a 503 and 504 status codes', async () => { 394 | expect( 395 | retriers.is5xx( 396 | new Response('', { 397 | status: 503, 398 | }), 399 | ), 400 | ).to.equal(true); 401 | expect( 402 | retriers.is5xx( 403 | new Response('', { 404 | status: 504, 405 | }), 406 | ), 407 | ).to.equal(true); 408 | }); 409 | 410 | it('rejects all other inputs', async () => { 411 | expect(retriers.is5xx(new Error(fake.sentence()))).to.equal(false); 412 | expect( 413 | retriers.is5xx( 414 | new Response('', { 415 | status: 200, 416 | }), 417 | ), 418 | ).to.equal(false); 419 | expect( 420 | retriers.is5xx( 421 | new Response('', { 422 | status: 400, 423 | }), 424 | ), 425 | ).to.equal(false); 426 | expect( 427 | retriers.is5xx( 428 | new Response('', { 429 | status: 404, 430 | }), 431 | ), 432 | ).to.equal(false); 433 | expect( 434 | retriers.is5xx( 435 | new Response('', { 436 | status: 499, 437 | }), 438 | ), 439 | ).to.equal(false); 440 | expect( 441 | retriers.is5xx( 442 | new Response('', { 443 | status: 500, 444 | }), 445 | ), 446 | ).to.equal(false); 447 | expect( 448 | retriers.is5xx( 449 | new Response('', { 450 | status: 501, 451 | }), 452 | ), 453 | ).to.equal(false); 454 | expect( 455 | retriers.is5xx( 456 | new Response('', { 457 | status: 502, 458 | }), 459 | ), 460 | ).to.equal(false); 461 | }); 462 | 463 | describe('used within jsonFetch', () => { 464 | let fetchStub: sinon.SinonStub< 465 | [input: RequestInfo, init?: RequestInit | undefined], 466 | Promise<{status: number}> 467 | >; 468 | 469 | beforeEach(() => { 470 | fetchStub = sandbox.stub(global, 'fetch'); 471 | }); 472 | 473 | afterEach(() => { 474 | fetchStub.restore(); 475 | }); 476 | 477 | it('attempts to retry on a 5xx error code', async () => { 478 | const is5xxSpy = sandbox.spy(retriers, 'is5xx'); 479 | 480 | fetchStub.resolves({status: 503}); 481 | 482 | try { 483 | await jsonFetch('http://www.test.com/', { 484 | shouldRetry: retriers.is5xx, 485 | retry: { 486 | retries: 3, 487 | factor: 0, 488 | }, 489 | }); 490 | } catch (_err) { 491 | expect(fetchStub.callCount).to.equal(4); 492 | expect(is5xxSpy.callCount).to.equal(4); 493 | return; 494 | } 495 | 496 | throw new Error('Should have failed'); 497 | }); 498 | }); 499 | }); 500 | 501 | describe('.isNetworkError', () => { 502 | it('accepts any errors', async () => { 503 | expect(retriers.isNetworkError(new Error(fake.sentence()))).to.equal(true); 504 | }); 505 | 506 | it('rejects any non errors', async () => { 507 | expect(retriers.isNetworkError(new Response('foo'))).to.equal(false); 508 | expect(retriers.isNetworkError(new Response(''))).to.equal(false); 509 | expect( 510 | retriers.isNetworkError( 511 | new Response('', { 512 | status: 200, 513 | }), 514 | ), 515 | ).to.equal(false); 516 | expect( 517 | retriers.isNetworkError( 518 | new Response('', { 519 | status: 500, 520 | }), 521 | ), 522 | ).to.equal(false); 523 | }); 524 | 525 | describe('used within jsonFetch', () => { 526 | let fetchStub: sinon.SinonStub< 527 | [input: RequestInfo, init?: RequestInit | undefined], 528 | Promise<{status: number}> 529 | >; 530 | 531 | beforeEach(() => { 532 | fetchStub = sandbox.stub(global, 'fetch'); 533 | }); 534 | 535 | afterEach(() => { 536 | fetchStub.restore(); 537 | }); 538 | 539 | it('attempts to retry on a network error', async () => { 540 | const isNetworkErrorSpy = sandbox.spy(retriers, 'isNetworkError'); 541 | fetchStub.rejects(new Error('ECONRST')); 542 | 543 | try { 544 | await jsonFetch('foo.bar', { 545 | shouldRetry: retriers.isNetworkError, 546 | retry: { 547 | retries: 5, 548 | factor: 0, 549 | }, 550 | }); 551 | } catch (err) { 552 | expect(fetchStub.callCount).to.equal(6); 553 | expect(isNetworkErrorSpy.callCount).to.equal(6); 554 | expect(err.message).to.equal('ECONRST'); 555 | return; 556 | } 557 | 558 | throw new Error('Should have failed'); 559 | }); 560 | }); 561 | }); 562 | }); 563 | describe('malformed json', () => { 564 | it('throws error with malformed text', async () => { 565 | nock('http://www.test.com').get('/products/1234').reply(200, '{"name": "apple""}', { 566 | 'Content-Type': 'application/json', 567 | }); 568 | 569 | try { 570 | await jsonFetch('http://www.test.com/products/1234'); 571 | } catch (err) { 572 | expect(err.message).to.contain('Unexpected string'); 573 | return; 574 | } 575 | 576 | throw new Error('expected to throw'); 577 | }); 578 | }); 579 | describe('missing content type', () => { 580 | it('handles it gracefully', async () => { 581 | nock('http://www.test.com').get('/products/1234').reply(200, 'test', {}); 582 | const response = await jsonFetch('http://www.test.com/products/1234'); 583 | expect(response.body).to.equal(undefined); 584 | }); 585 | }); 586 | describe('thrown errors', () => { 587 | it('does not include request headers', async () => { 588 | nock('http://www.test.com').get('/products/1234').reply(200, '{""}', { 589 | 'Content-Type': 'application/json', 590 | }); 591 | 592 | try { 593 | await jsonFetch('http://www.test.com/products/1234', { 594 | headers: { 595 | secret: 'foo', 596 | }, 597 | }); 598 | } catch (err) { 599 | expect(err.request.url).to.equal('http://www.test.com/products/1234'); 600 | expect(err.request.headers).not.to.exist(); 601 | return; 602 | } 603 | 604 | throw new Error('expected to throw'); 605 | }); 606 | }); 607 | }); 608 | --------------------------------------------------------------------------------