├── .gitattributes ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .mocharc.json ├── .release-please-manifest.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── examples ├── jest-example │ ├── .gitignore │ ├── jest.config.js │ ├── package.json │ └── retry-axios.test.js └── jest-testing.md ├── package-lock.json ├── package.json ├── release-please-config.json ├── renovate.json ├── site └── retry-axios.webp ├── src └── index.ts ├── test ├── cjs-import.test.cjs └── index.ts ├── tsconfig.json └── vitest.config.mjs /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts eol=lf 2 | *.json eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | name: ci 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | permissions: 11 | contents: read 12 | jobs: 13 | linkinator: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 18 | - uses: JustinBeckwith/linkinator-action@v2 19 | test: 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 15 22 | permissions: 23 | id-token: write 24 | contents: read 25 | strategy: 26 | matrix: 27 | node: [20, 22, 24] 28 | steps: 29 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 30 | - uses: actions/setup-node@v6 31 | with: 32 | node-version: ${{ matrix.node }} 33 | - run: node --version 34 | - run: npm ci 35 | - run: npm test 36 | - name: coverage 37 | uses: codecov/codecov-action@v5 38 | with: 39 | name: actions ${{ matrix.node }} 40 | use_oidc: true 41 | lint: 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 44 | steps: 45 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 46 | - uses: actions/setup-node@v6 47 | with: 48 | node-version: 24 49 | - run: npm ci 50 | - run: npm run lint 51 | license_check: 52 | runs-on: ubuntu-latest 53 | timeout-minutes: 10 54 | steps: 55 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 56 | - uses: actions/setup-node@v6 57 | with: 58 | node-version: 24 59 | - run: npm ci 60 | - run: npm run license-check 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: false 9 | permissions: {} 10 | jobs: 11 | release-please: 12 | if: github.repository == 'JustinBeckwith/retry-axios' 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | outputs: 19 | release_created: ${{ steps.release.outputs.release_created }} 20 | tag_name: ${{ steps.release.outputs.tag_name }} 21 | steps: 22 | - uses: googleapis/release-please-action@v4 23 | id: release 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | publish: 27 | if: needs.release-please.outputs.release_created 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 15 30 | needs: release-please 31 | permissions: 32 | contents: read 33 | id-token: write 34 | steps: 35 | - uses: actions/checkout@v5 36 | - uses: actions/setup-node@v6 37 | with: 38 | node-version: 24 39 | cache: npm 40 | registry-url: 'https://registry.npmjs.org' 41 | - run: npm ci 42 | - run: npm run compile 43 | - run: npm publish --provenance --access public 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/ 3 | umd/ 4 | coverage/ 5 | .nyc_output/ 6 | .vscode/ 7 | .rts2* 8 | dist/ 9 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "check-leaks": true, 3 | "timeout": 60000, 4 | "throw-deprecation": true, 5 | "enable-source-maps": true 6 | } 7 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "4.0.0" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [4.0.0](https://github.com/JustinBeckwith/retry-axios/compare/v3.2.1...f4e641f) (2025-10-20) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | This major release includes several breaking changes that simplify the API and improve consistency. Please review the migration guide below for each change. 9 | 10 | #### 1. Node.js Version Requirements 11 | 12 | **This library now requires Node.js 20 or higher.** Previous versions supported Node.js 6, 8, 12, and 14, which are all now end-of-life. 13 | 14 | **Migration Required:** Upgrade your Node.js version to 20 or higher before upgrading to retry-axios 4.0. 15 | 16 | ```bash 17 | # Check your Node.js version 18 | node --version 19 | 20 | # If below v20, upgrade Node.js first 21 | # Visit https://nodejs.org or use a version manager like nvm 22 | ``` 23 | 24 | #### 2. Removal of `config.instance` Option 25 | 26 | **The `config.instance` option has been removed.** The axios instance is now automatically used from the interceptor attachment point. 27 | 28 | This was confusing because users had to specify the instance twice - once in `raxConfig` and once in `rax.attach()`. Now you only specify it once in `rax.attach()`. 29 | 30 | **Before (v3.x):** 31 | ```js 32 | const myAxiosInstance = axios.create(); 33 | myAxiosInstance.defaults.raxConfig = { 34 | instance: myAxiosInstance, // ❌ Remove this 35 | retry: 3 36 | }; 37 | rax.attach(myAxiosInstance); 38 | ``` 39 | 40 | **After (v4.0):** 41 | ```js 42 | const myAxiosInstance = axios.create(); 43 | myAxiosInstance.defaults.raxConfig = { 44 | retry: 3 // ✅ Instance is automatically used from rax.attach() 45 | }; 46 | rax.attach(myAxiosInstance); 47 | ``` 48 | 49 | **Migration Required:** Remove the `instance` property from your `raxConfig` objects. 50 | 51 | #### 3. Simplified Retry Configuration - Removal of `noResponseRetries` 52 | 53 | **The `noResponseRetries` configuration option has been removed.** The `retry` option now controls the maximum number of retries for ALL error types (both response errors like 5xx and network errors like timeouts). 54 | 55 | This simplifies the API to match industry standards. Popular libraries like axios-retry, Got, and Ky all use a single retry count. 56 | 57 | **Before (v3.x):** 58 | ```js 59 | raxConfig: { 60 | retry: 3, // For 5xx response errors 61 | noResponseRetries: 2 // For network/timeout errors 62 | } 63 | ``` 64 | 65 | **After (v4.0):** 66 | ```js 67 | raxConfig: { 68 | retry: 3 // For ALL errors (network + response errors) 69 | } 70 | ``` 71 | 72 | **If you need different behavior** for network errors vs response errors, use the `shouldRetry` callback: 73 | 74 | ```js 75 | raxConfig: { 76 | retry: 5, 77 | shouldRetry: (err) => { 78 | const cfg = rax.getConfig(err); 79 | 80 | // Network error (no response) - allow up to 5 retries 81 | if (!err.response) { 82 | return cfg.currentRetryAttempt < 5; 83 | } 84 | 85 | // Response error (5xx, 429, etc) - limit to 2 retries 86 | return cfg.currentRetryAttempt < 2; 87 | } 88 | } 89 | ``` 90 | 91 | **Migration Required:** 92 | - If you used `noResponseRetries`, remove it and adjust your `retry` value as needed 93 | - If you need different retry counts for different error types, implement a `shouldRetry` function 94 | 95 | #### 4. `onRetryAttempt` Now Requires Async Functions 96 | 97 | **The `onRetryAttempt` callback must now return a Promise.** It will be awaited before the retry attempt proceeds. If the Promise is rejected, the retry will be aborted. 98 | 99 | Additionally, the **timing has changed**: `onRetryAttempt` is now called AFTER the backoff delay (right before the retry), not before. A new `onError` callback has been added that fires immediately when an error occurs. 100 | 101 | **Before (v3.x):** 102 | ```js 103 | raxConfig: { 104 | onRetryAttempt: (err) => { 105 | // Synchronous callback, called before backoff delay 106 | console.log('About to retry'); 107 | } 108 | } 109 | ``` 110 | 111 | **After (v4.0):** 112 | ```js 113 | raxConfig: { 114 | // Called immediately when error occurs, before backoff delay 115 | onError: async (err) => { 116 | console.log('Error occurred, will retry after backoff'); 117 | }, 118 | 119 | // Called after backoff delay, before retry attempt 120 | onRetryAttempt: async (err) => { 121 | console.log('About to retry now'); 122 | // Can perform async operations like refreshing tokens 123 | const token = await refreshAuthToken(); 124 | // If this throws, the retry is aborted 125 | } 126 | } 127 | ``` 128 | 129 | **Common use case - Refreshing authentication tokens:** 130 | ```js 131 | raxConfig: { 132 | retry: 3, 133 | onRetryAttempt: async (err) => { 134 | // Refresh expired token before retrying 135 | if (err.response?.status === 401) { 136 | const newToken = await refreshToken(); 137 | // Update the authorization header for the retry 138 | err.config.headers.Authorization = `Bearer ${newToken}`; 139 | } 140 | } 141 | } 142 | ``` 143 | 144 | **Migration Required:** 145 | - Change `onRetryAttempt` to be an async function or return a Promise 146 | - If you need immediate error notification (old `onRetryAttempt` timing), use the new `onError` callback instead 147 | - If your callback throws or rejects, be aware this will now abort the retry 148 | 149 | #### Summary of All Breaking Changes 150 | 151 | 1. **Node.js 20+ required** - Drops support for Node.js 6, 8, 12, and 14 152 | 2. **Remove `config.instance`** - Axios instance is now automatically used from `rax.attach()` 153 | 3. **Remove `noResponseRetries`** - Use `retry` for all error types, or implement `shouldRetry` for custom logic 154 | 4. **`onRetryAttempt` must be async** - Must return a Promise, called after backoff delay (use `onError` for immediate notification) 155 | 156 | ### Features 157 | 158 | * accept promises on config.onRetryAttempt ([#23](https://github.com/JustinBeckwith/retry-axios/issues/23)) ([acfbe39](https://github.com/JustinBeckwith/retry-axios/commit/acfbe399f7017a607c4f49c578250a82834c448c)) 159 | * add configurable backoffType ([#76](https://github.com/JustinBeckwith/retry-axios/issues/76)) ([6794d85](https://github.com/JustinBeckwith/retry-axios/commit/6794d85c6cdd8e27bc59f613392caff8ddada985)) 160 | * Add jitter support and use retryDelay as base for exponential backoff ([#314](https://github.com/JustinBeckwith/retry-axios/issues/314)) ([7436b59](https://github.com/JustinBeckwith/retry-axios/commit/7436b59ff9a06011b47796afd6d2e3ade954ad5c)) 161 | * Add retriesRemaining property to track remaining retry attempts ([#316](https://github.com/JustinBeckwith/retry-axios/issues/316)) ([2d1f46b](https://github.com/JustinBeckwith/retry-axios/commit/2d1f46ba33cd4fdde0fd50c56a65746a178b67f2)) 162 | * add support for cjs ([#291](https://github.com/JustinBeckwith/retry-axios/issues/291)) ([38244be](https://github.com/JustinBeckwith/retry-axios/commit/38244be3b67c3316eea467f10ed3c4d8027b9fb5)) 163 | * add support for configurable http methods ([819855c](https://github.com/JustinBeckwith/retry-axios/commit/819855c99e3fda19615e9f0d704988d985df5036)) 164 | * add support for noResponseRetries ([d2cfde7](https://github.com/JustinBeckwith/retry-axios/commit/d2cfde70e6314bc298dcf9291bacb3385c130cdf)) 165 | * add support for onRetryAttempt handler ([fa17de4](https://github.com/JustinBeckwith/retry-axios/commit/fa17de46bd6c6c33db99aca5668a3d58a81c761e)) 166 | * add support for overriding shouldRetry ([76fcff5](https://github.com/JustinBeckwith/retry-axios/commit/76fcff59849b7b9de428f9638ff5057c159a1a3d)) 167 | * add support for statusCodesToRetry ([9283c9e](https://github.com/JustinBeckwith/retry-axios/commit/9283c9e40970eaf359ac664ce5ac6517087c3257)) 168 | * allow retryDelay to be 0 ([#132](https://github.com/JustinBeckwith/retry-axios/issues/132)) ([57ba46f](https://github.com/JustinBeckwith/retry-axios/commit/57ba46f563561e6b9e4f1e2ca39daefe2993d399)) 169 | * Collect all errors in errors array during retry attempts ([#315](https://github.com/JustinBeckwith/retry-axios/issues/315)) ([a7ae9e1](https://github.com/JustinBeckwith/retry-axios/commit/a7ae9e1df42f7af3448cf3eca00d95035c37ecf4)) 170 | * configurable maxRetryDelay ([#165](https://github.com/JustinBeckwith/retry-axios/issues/165)) ([b8842d7](https://github.com/JustinBeckwith/retry-axios/commit/b8842d751482caf31bc1c090cda3c7923d1f23fa)) 171 | * drop support for node.js 6, add 12 ([78ea044](https://github.com/JustinBeckwith/retry-axios/commit/78ea044f17b0d1900509994b36bda31da17ea360)) 172 | * export the shouldRetryRequest method ([#74](https://github.com/JustinBeckwith/retry-axios/issues/74)) ([694d638](https://github.com/JustinBeckwith/retry-axios/commit/694d638ccc0d91727cbcd9990690a9b29815353c)) 173 | * produce es, common, and umd bundles ([#107](https://github.com/JustinBeckwith/retry-axios/issues/107)) ([62cabf5](https://github.com/JustinBeckwith/retry-axios/commit/62cabf58c86ffc8169b74b8912f2aac94a703733)) 174 | * Remove redundant config.instance option ([#312](https://github.com/JustinBeckwith/retry-axios/issues/312)) ([402723d](https://github.com/JustinBeckwith/retry-axios/commit/402723d264b6429c6c1fa1f20163a10c9b1e8091)) 175 | * ship source maps ([#223](https://github.com/JustinBeckwith/retry-axios/issues/223)) ([247fae0](https://github.com/JustinBeckwith/retry-axios/commit/247fae0434fb3e495f7ec3518da19a25a3be1704)) 176 | * Simplify retry configuration API ([#311](https://github.com/JustinBeckwith/retry-axios/issues/311)) ([cb447b3](https://github.com/JustinBeckwith/retry-axios/commit/cb447b3b4a6db5461dd46bc8149e4660de4c9a81)) 177 | * support retry-after header ([#142](https://github.com/JustinBeckwith/retry-axios/issues/142)) ([5c6cace](https://github.com/JustinBeckwith/retry-axios/commit/5c6cace7fbf418285c3b0f114f5806cb573b0a64)) 178 | * support the latest versions of node.js ([#188](https://github.com/JustinBeckwith/retry-axios/issues/188)) ([ef74217](https://github.com/JustinBeckwith/retry-axios/commit/ef74217113cf611af420564421d844858d70701b)) 179 | * umd compatibility with babel 7.x ([#21](https://github.com/JustinBeckwith/retry-axios/issues/21)) ([f1b336c](https://github.com/JustinBeckwith/retry-axios/commit/f1b336c00a03f56ba7874a9565955c89c32b1d68)) 180 | 181 | 182 | ### Bug Fixes 183 | 184 | * added check for cancel tokens ([#99](https://github.com/JustinBeckwith/retry-axios/issues/99)) ([734a93f](https://github.com/JustinBeckwith/retry-axios/commit/734a93ff45de7dfd827f17ec7e7545636a3e8add)) 185 | * Call onRetryAttempt *after* backoff timeout ([#307](https://github.com/JustinBeckwith/retry-axios/issues/307)) ([a5457e4](https://github.com/JustinBeckwith/retry-axios/commit/a5457e44a808dc82aabbd07a2abd3a57de2befe8)) 186 | * cannot set propery raxConfig of undefined ([#114](https://github.com/JustinBeckwith/retry-axios/issues/114)) ([0be8578](https://github.com/JustinBeckwith/retry-axios/commit/0be857823f4e4845e72891ba63cef08d006cefc8)) 187 | * **deps:** update dependency gts to v1 ([#45](https://github.com/JustinBeckwith/retry-axios/issues/45)) ([1dc0f2f](https://github.com/JustinBeckwith/retry-axios/commit/1dc0f2f77b52cd6fcbec69f31c70fc5f2e0f084e)) 188 | * Don't store counter on input config object ([#98](https://github.com/JustinBeckwith/retry-axios/issues/98)) ([c8ceec0](https://github.com/JustinBeckwith/retry-axios/commit/c8ceec0e16e57297edbc0739f40b99a836e3254e)), closes [#61](https://github.com/JustinBeckwith/retry-axios/issues/61) 189 | * ensure config is set ([#81](https://github.com/JustinBeckwith/retry-axios/issues/81)) ([88ffd00](https://github.com/JustinBeckwith/retry-axios/commit/88ffd005a9a659ee75f545d3b1b4df8d00b78ceb)) 190 | * fix instructions and test for non-static instnaces ([544c2a6](https://github.com/JustinBeckwith/retry-axios/commit/544c2a6563c3c4df69d5d441bbaf872a0a59d83f)) 191 | * Fix potential exception when there is no response ([#258](https://github.com/JustinBeckwith/retry-axios/issues/258)) ([a58cd1d](https://github.com/JustinBeckwith/retry-axios/commit/a58cd1d013dc86385d75bb83a8798fb41d1a89f1)) 192 | * Fix workaround for arrays that are passed as objects ([#238](https://github.com/JustinBeckwith/retry-axios/issues/238)) ([6e2454a](https://github.com/JustinBeckwith/retry-axios/commit/6e2454a139a76a3376ea7e16f4e0566345e683c8)) 193 | * handle arrays that are converted to objects ([#83](https://github.com/JustinBeckwith/retry-axios/issues/83)) ([554fd4c](https://github.com/JustinBeckwith/retry-axios/commit/554fd4ca444a0dd5237bccdc3d156c481cce8f42)) 194 | * include files in the release ([#29](https://github.com/JustinBeckwith/retry-axios/issues/29)) ([30663b3](https://github.com/JustinBeckwith/retry-axios/commit/30663b362bd1eddf33c6390e4df8123fa295d37e)) 195 | * non-zero delay between first attempt and first retry for linear and exp strategy ([#163](https://github.com/JustinBeckwith/retry-axios/issues/163)) ([e63ca08](https://github.com/JustinBeckwith/retry-axios/commit/e63ca084f5372f03debe5c082e6b924684072345)) 196 | * onRetryAttempt does not handle promise rejection ([#306](https://github.com/JustinBeckwith/retry-axios/issues/306)) ([6f5ecc2](https://github.com/JustinBeckwith/retry-axios/commit/6f5ecc274d7ffa85cdcfd52fff9635fabb55a3a7)) 197 | * preserve configuration for custom instance ([#240](https://github.com/JustinBeckwith/retry-axios/issues/240)) ([2e4e702](https://github.com/JustinBeckwith/retry-axios/commit/2e4e702feb38b2b49e8de776c85a85a4599a1b04)) 198 | * Respect retry limit when using custom shouldRetry ([#309](https://github.com/JustinBeckwith/retry-axios/issues/309)) ([58f6fa6](https://github.com/JustinBeckwith/retry-axios/commit/58f6fa6f1e0af47879c954542eecf4daac8cc7b6)) 199 | * **typescript:** include raxConfig in native axios types ([#85](https://github.com/JustinBeckwith/retry-axios/issues/85)) ([b8b0456](https://github.com/JustinBeckwith/retry-axios/commit/b8b04565004b100cc36ac1f5ee32dfde34f0770f)) 200 | * Update tsconfig.json to emit index.d.ts ([#229](https://github.com/JustinBeckwith/retry-axios/issues/229)) ([bff6aa9](https://github.com/JustinBeckwith/retry-axios/commit/bff6aa9f50434d3718f78b68e4de6dab6a14e705)) 201 | 202 | 203 | ### Miscellaneous Chores 204 | 205 | * drop support for nodejs 8.x ([#82](https://github.com/JustinBeckwith/retry-axios/issues/82)) ([d259697](https://github.com/JustinBeckwith/retry-axios/commit/d259697ab5e9931c7ceaddff6c48d43180dda6c6)) 206 | 207 | 208 | ### Build System 209 | 210 | * require node.js 20 and up ([#317](https://github.com/JustinBeckwith/retry-axios/issues/317)) ([4aa6440](https://github.com/JustinBeckwith/retry-axios/commit/4aa644002a0597067ccf8735779fa073d165e7a2)) 211 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2013 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retry-axios 2 | 3 | > Use Axios interceptors to automatically retry failed requests. Super flexible. Built in exponential backoff. 4 | 5 | ![retry-axios](https://raw.githubusercontent.com/justinbeckwith/retry-axios/main/site/retry-axios.webp) 6 | 7 | [![NPM Version][npm-image]][npm-url] 8 | [![GitHub Actions][github-image]][github-url] 9 | [![Known Vulnerabilities][snyk-image]][snyk-url] 10 | [![codecov][codecov-image]][codecov-url] 11 | [![Biome][biome-image]][biome-url] 12 | 13 | ## Installation 14 | 15 | ```sh 16 | npm install retry-axios 17 | ``` 18 | 19 | ## Usage 20 | 21 | To use this library, import it alongside of `axios`: 22 | 23 | ```js 24 | // Just import rax and your favorite version of axios 25 | const rax = require('retry-axios'); 26 | const axios = require('axios'); 27 | ``` 28 | 29 | Or, if you're using TypeScript / es modules: 30 | 31 | ```js 32 | import * as rax from 'retry-axios'; 33 | import axios from 'axios'; 34 | ``` 35 | 36 | You can attach to the global `axios` object, and retry 3 times by default: 37 | 38 | ```js 39 | const interceptorId = rax.attach(); 40 | const res = await axios('https://test.local'); 41 | ``` 42 | 43 | Or you can create your own axios instance to make scoped requests: 44 | 45 | ```js 46 | const myAxiosInstance = axios.create(); 47 | myAxiosInstance.defaults.raxConfig = { 48 | retry: 3 49 | }; 50 | const interceptorId = rax.attach(myAxiosInstance); 51 | const res = await myAxiosInstance.get('https://test.local'); 52 | ``` 53 | 54 | You have a lot of options... 55 | 56 | ```js 57 | const interceptorId = rax.attach(); 58 | const res = await axios({ 59 | url: 'https://test.local', 60 | raxConfig: { 61 | // Retry 3 times before giving up. Applies to all errors (5xx, network errors, timeouts, etc). Defaults to 3. 62 | retry: 3, 63 | 64 | // Milliseconds to delay between retries. Defaults to 100. 65 | // - For 'static': Fixed delay between retries 66 | // - For 'exponential': Base multiplier for exponential calculation 67 | // - For 'linear': Ignored (uses attempt * 1000) 68 | retryDelay: 100, 69 | 70 | // HTTP methods to automatically retry. Defaults to: 71 | // ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT'] 72 | httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT'], 73 | 74 | // The response status codes to retry. Supports a double 75 | // array with a list of ranges. Defaults to: 76 | // [[100, 199], [429, 429], [500, 599]] 77 | statusCodesToRetry: [[100, 199], [429, 429], [500, 599]], 78 | 79 | // You can set the backoff type. 80 | // options are 'exponential' (default), 'static' or 'linear' 81 | backoffType: 'exponential', 82 | 83 | // Jitter strategy for exponential backoff. Defaults to 'none'. 84 | // Options: 'none', 'full', 'equal' 85 | // Helps prevent thundering herd in distributed systems 86 | jitter: 'full', 87 | 88 | // You can detect when an error occurs, before the backoff delay 89 | onError: async (err) => { 90 | const cfg = rax.getConfig(err); 91 | console.log(`Error occurred, retry attempt #${cfg.currentRetryAttempt + 1} will happen after backoff`); 92 | }, 93 | 94 | // You can detect when a retry attempt is about to be made, after the backoff delay 95 | onRetryAttempt: async (err) => { 96 | const cfg = rax.getConfig(err); 97 | console.log(`Retry attempt #${cfg.currentRetryAttempt} is about to start`); 98 | console.log(`Retries remaining: ${cfg.retriesRemaining}`); 99 | 100 | // Check if this is the final retry attempt 101 | if (cfg.retriesRemaining === 0) { 102 | console.log('This is the final retry attempt'); 103 | } 104 | } 105 | } 106 | }); 107 | ``` 108 | 109 | ### Backoff Types and Timing 110 | 111 | The `backoffType` option controls how delays between retry attempts are calculated. There are three strategies available: 112 | 113 | #### Exponential Backoff (default) 114 | 115 | Uses the formula: `((2^attempt - 1) / 2) * retryDelay` milliseconds 116 | 117 | The `retryDelay` parameter (defaults to 100ms) is used as the base multiplier for the exponential calculation. 118 | 119 | Example timing with default `retryDelay: 100`: 120 | - Retry 1: 50ms delay 121 | - Retry 2: 150ms delay 122 | - Retry 3: 350ms delay 123 | - Retry 4: 750ms delay 124 | - Retry 5: 1,550ms delay 125 | 126 | Example timing with `retryDelay: 1000`: 127 | - Retry 1: 500ms delay 128 | - Retry 2: 1,500ms delay 129 | - Retry 3: 3,500ms delay 130 | - Retry 4: 7,500ms delay 131 | - Retry 5: 15,500ms delay 132 | 133 | ```js 134 | raxConfig: { 135 | backoffType: 'exponential', // This is the default 136 | retryDelay: 1000, // Use 1000ms as the base multiplier 137 | retry: 5 138 | } 139 | ``` 140 | 141 | #### Static Backoff 142 | 143 | Uses a fixed delay specified by `retryDelay` (defaults to 100ms if not set). 144 | 145 | Example timing with `retryDelay: 3000`: 146 | - Retry 1: 3,000ms delay 147 | - Retry 2: 3,000ms delay 148 | - Retry 3: 3,000ms delay 149 | 150 | ```js 151 | raxConfig: { 152 | backoffType: 'static', 153 | retryDelay: 3000, // 3 seconds between each retry 154 | retry: 3 155 | } 156 | ``` 157 | 158 | #### Linear Backoff 159 | 160 | Delay increases linearly: `attempt * 1000` milliseconds 161 | 162 | **The `retryDelay` option is ignored when using linear backoff.** 163 | 164 | Example timing for the first 5 retries: 165 | - Retry 1: 1,000ms delay 166 | - Retry 2: 2,000ms delay 167 | - Retry 3: 3,000ms delay 168 | - Retry 4: 4,000ms delay 169 | - Retry 5: 5,000ms delay 170 | 171 | ```js 172 | raxConfig: { 173 | backoffType: 'linear', 174 | retry: 5 175 | } 176 | ``` 177 | 178 | #### Maximum Retry Delay 179 | 180 | You can cap the maximum delay for any backoff type using `maxRetryDelay`: 181 | 182 | ```js 183 | raxConfig: { 184 | backoffType: 'exponential', 185 | maxRetryDelay: 5000, // Never wait more than 5 seconds 186 | retry: 10 187 | } 188 | ``` 189 | 190 | #### Jitter 191 | 192 | Jitter adds randomness to exponential backoff delays to prevent the "thundering herd" problem where many clients retry at the same time. This is especially useful in distributed systems. 193 | 194 | Available jitter strategies (only applies to exponential backoff): 195 | 196 | **No Jitter (default)** 197 | ```js 198 | raxConfig: { 199 | backoffType: 'exponential', 200 | jitter: 'none', // or omit this option 201 | retryDelay: 1000 202 | } 203 | // Retry 1: exactly 500ms 204 | // Retry 2: exactly 1,500ms 205 | // Retry 3: exactly 3,500ms 206 | ``` 207 | 208 | **Full Jitter** 209 | 210 | Randomizes the delay between 0 and the calculated exponential backoff: 211 | 212 | ```js 213 | raxConfig: { 214 | backoffType: 'exponential', 215 | jitter: 'full', 216 | retryDelay: 1000 217 | } 218 | // Retry 1: random between 0-500ms 219 | // Retry 2: random between 0-1,500ms 220 | // Retry 3: random between 0-3,500ms 221 | ``` 222 | 223 | **Equal Jitter** 224 | 225 | Uses half fixed delay, half random: 226 | 227 | ```js 228 | raxConfig: { 229 | backoffType: 'exponential', 230 | jitter: 'equal', 231 | retryDelay: 1000 232 | } 233 | // Retry 1: 250ms + random(0-250ms) = 250-500ms 234 | // Retry 2: 750ms + random(0-750ms) = 750-1,500ms 235 | // Retry 3: 1,750ms + random(0-1,750ms) = 1,750-3,500ms 236 | ``` 237 | 238 | **Recommendation:** Use `'full'` jitter for most distributed systems to minimize collision probability while maintaining good retry timing. 239 | 240 | ### Callback Timing 241 | 242 | There are two callbacks you can use to hook into the retry lifecycle: 243 | 244 | - **`onError`**: Called immediately when an error occurs, before the backoff delay. Use this for logging errors or performing actions that need to happen right away. 245 | - **`onRetryAttempt`**: Called after the backoff delay, just before the retry request is made. Use this for actions that need to happen right before retrying (like refreshing tokens). 246 | 247 | Both functions are asynchronous and must return a promise. The retry will wait for the promise to resolve before proceeding. If the promise is rejected, the retry will be aborted: 248 | 249 | ```js 250 | const res = await axios({ 251 | url: 'https://test.local', 252 | raxConfig: { 253 | onError: async (err) => { 254 | // Called immediately when error occurs 255 | console.log('An error occurred, will retry after backoff'); 256 | }, 257 | onRetryAttempt: async (err) => { 258 | // Called after backoff delay, before retry 259 | const token = await refreshToken(err); 260 | window.localStorage.setItem('token', token); 261 | // If refreshToken throws or this promise rejects, 262 | // the retry will be aborted 263 | } 264 | } 265 | }); 266 | ``` 267 | 268 | ## Tracking Retry Progress 269 | 270 | You can track the current retry state using properties available in the configuration: 271 | 272 | - **`currentRetryAttempt`**: The number of retries that have been attempted (starts at 0, increments with each retry) 273 | - **`retriesRemaining`**: The number of retries left before giving up (calculated as `retry - currentRetryAttempt`) 274 | 275 | These properties are particularly useful when you want to show different messages or take different actions based on whether this is the final retry attempt: 276 | 277 | ```js 278 | const res = await axios({ 279 | url: 'https://test.local', 280 | raxConfig: { 281 | retry: 3, 282 | onRetryAttempt: async (err) => { 283 | const cfg = rax.getConfig(err); 284 | 285 | console.log(`Retry attempt ${cfg.currentRetryAttempt} of ${cfg.retry}`); 286 | console.log(`${cfg.retriesRemaining} retries remaining`); 287 | 288 | // Show user-facing error only on final retry 289 | if (cfg.retriesRemaining === 0) { 290 | showErrorNotification('Request failed after multiple attempts'); 291 | } 292 | } 293 | } 294 | }); 295 | ``` 296 | 297 | This is especially useful when chaining retry-axios with other error interceptors: 298 | 299 | ```js 300 | // Global error handler that shows notifications 301 | axios.interceptors.response.use(null, async (error) => { 302 | const cfg = rax.getConfig(error); 303 | 304 | // Only show error notification on the final retry attempt 305 | // Don't spam the user with notifications for intermediate failures 306 | if (cfg?.retriesRemaining === 0) { 307 | showUserNotification('An error occurred: ' + error.message); 308 | } 309 | 310 | return Promise.reject(error); 311 | }); 312 | 313 | // Attach retry interceptor 314 | rax.attach(); 315 | ``` 316 | 317 | ## Customizing Retry Logic 318 | 319 | You can customize which errors should trigger a retry using the `shouldRetry` function: 320 | 321 | ```js 322 | const res = await axios({ 323 | url: 'https://test.local', 324 | raxConfig: { 325 | retry: 3, 326 | // Custom logic to decide if a request should be retried 327 | // This is called AFTER checking the retry count limit 328 | shouldRetry: err => { 329 | const cfg = rax.getConfig(err); 330 | 331 | // Don't retry on 4xx errors except 429 332 | if (err.response?.status && err.response.status >= 400 && err.response.status < 500) { 333 | return err.response.status === 429; 334 | } 335 | 336 | // Retry on network errors and 5xx errors 337 | return true; 338 | } 339 | } 340 | }); 341 | ``` 342 | 343 | If you want to add custom retry logic without duplicating too much of the built-in logic, `rax.shouldRetryRequest` will tell you if a request would normally be retried: 344 | 345 | ```js 346 | const res = await axios({ 347 | url: 'https://test.local', 348 | raxConfig: { 349 | // Override the decision making process on if you should retry 350 | shouldRetry: err => { 351 | const cfg = rax.getConfig(err); 352 | if (cfg.currentRetryAttempt >= cfg.retry) return false // ensure max retries is always respected 353 | 354 | // Always retry this status text, regardless of code or request type 355 | if (err.response.statusText.includes('Try again')) return true 356 | 357 | // Handle the request based on your other config options, e.g. `statusCodesToRetry` 358 | return rax.shouldRetryRequest(err) 359 | } 360 | } 361 | }); 362 | ``` 363 | 364 | ## Accessing All Retry Errors 365 | 366 | When retries are exhausted and the request finally fails, you can access the complete history of all errors that occurred during the retry attempts. This is particularly useful for debugging and understanding what went wrong, especially for non-idempotent operations like POST requests where the error may change between attempts. 367 | 368 | ```js 369 | try { 370 | await axios.post('https://test.local/api/endpoint', data, { 371 | raxConfig: { 372 | httpMethodsToRetry: ['POST'], 373 | retry: 3 374 | } 375 | }); 376 | } catch (err) { 377 | const cfg = rax.getConfig(err); 378 | 379 | // Access all errors encountered during retries 380 | if (cfg?.errors) { 381 | console.log(`Total attempts: ${cfg.errors.length}`); 382 | console.log(`First error: ${cfg.errors[0].response?.status}`); 383 | console.log(`Last error: ${err.response?.status}`); 384 | 385 | // Log all error details 386 | cfg.errors.forEach((error, index) => { 387 | console.log(`Attempt ${index + 1}: ${error.response?.status} - ${error.response?.data}`); 388 | }); 389 | } 390 | } 391 | ``` 392 | 393 | The `errors` array is automatically populated and contains: 394 | - **First element**: The initial error that triggered the retry logic 395 | - **Subsequent elements**: Errors from each retry attempt 396 | - **Order**: Errors are in chronological order (oldest to newest) 397 | 398 | This feature is especially valuable when: 399 | - Debugging complex failure scenarios where errors change between attempts 400 | - Implementing custom error handling logic that needs to consider all failures 401 | - Logging and monitoring to understand the full context of request failures 402 | - Working with non-idempotent operations where side effects may occur 403 | 404 | ## What Gets Retried 405 | 406 | By default, retry-axios will retry requests that: 407 | 408 | 1. **Return specific HTTP status codes**: 1xx (informational), 429 (too many requests), and 5xx (server errors) 409 | 2. **Are network errors without a response**: ETIMEDOUT, ENOTFOUND, ECONNABORTED, ECONNRESET, etc. 410 | 3. **Use idempotent HTTP methods**: GET, HEAD, PUT, OPTIONS, DELETE 411 | 412 | The `retry` config option controls the maximum number of retry attempts for **all** error types. If you need different behavior for network errors vs response errors, use the `shouldRetry` function to implement custom logic. 413 | 414 | ## How it works 415 | 416 | This library attaches an `interceptor` to an axios instance you pass to the API. This way you get to choose which version of `axios` you want to run, and you can compose many interceptors on the same request pipeline. 417 | 418 | ## License 419 | 420 | [Apache-2.0](LICENSE) 421 | 422 | [github-image]: https://github.com/JustinBeckwith/retry-axios/workflows/ci/badge.svg 423 | [github-url]: https://github.com/JustinBeckwith/retry-axios/actions/ 424 | [codecov-image]: https://codecov.io/gh/JustinBeckwith/retry-axios/branch/main/graph/badge.svg 425 | [codecov-url]: https://codecov.io/gh/JustinBeckwith/retry-axios 426 | [npm-image]: https://img.shields.io/npm/v/retry-axios.svg 427 | [npm-url]: https://npmjs.org/package/retry-axios 428 | [snyk-image]: https://snyk.io/test/github/JustinBeckwith/retry-axios/badge.svg 429 | [snyk-url]: https://snyk.io/test/github/JustinBeckwith/retry-axios 430 | [biome-image]: https://img.shields.io/badge/Biome-60a5fa?style=flat&logo=biome&logoColor=fff 431 | [biome-url]: https://biomejs.dev 432 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", 3 | "files": { 4 | "includes": ["src/**/*.ts", "test/**/*.ts", "!build/**/*"] 5 | }, 6 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 7 | "linter": { 8 | "enabled": true, 9 | "rules": { 10 | "recommended": true 11 | } 12 | }, 13 | "javascript": { 14 | "formatter": { 15 | "quoteStyle": "single" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/jest-example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | debug.test.js 4 | -------------------------------------------------------------------------------- /examples/jest-example/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testMatch: ['**/*.test.js'], 4 | collectCoverageFrom: ['**/*.js', '!jest.config.js'], 5 | }; 6 | -------------------------------------------------------------------------------- /examples/jest-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retry-axios-jest-example", 3 | "version": "1.0.0", 4 | "description": "Example Jest tests for retry-axios", 5 | "type": "commonjs", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "dependencies": { 10 | "axios": "^1.7.0", 11 | "retry-axios": "file:../.." 12 | }, 13 | "devDependencies": { 14 | "jest": "^29.7.0", 15 | "nock": "^13.5.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/jest-example/retry-axios.test.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const nock = require('nock'); 3 | const rax = require('retry-axios'); 4 | 5 | describe('retry-axios with Jest', () => { 6 | let axiosInstance; 7 | let interceptorId; 8 | 9 | beforeEach(() => { 10 | axiosInstance = axios.create(); 11 | axiosInstance.defaults.raxConfig = { 12 | instance: axiosInstance 13 | }; 14 | interceptorId = rax.attach(axiosInstance); 15 | }); 16 | 17 | afterEach(() => { 18 | nock.cleanAll(); 19 | if (interceptorId !== undefined) { 20 | rax.detach(interceptorId, axiosInstance); 21 | } 22 | }); 23 | 24 | test('should retry failed requests', async () => { 25 | const url = 'https://api.example.com'; 26 | 27 | const scope = nock(url) 28 | .get('/data') 29 | .reply(500, { error: 'Server Error' }) 30 | .get('/data') 31 | .reply(200, { success: true }); 32 | 33 | const response = await axiosInstance.get(`${url}/data`, { 34 | raxConfig: { 35 | retry: 3, 36 | retryDelay: 100, 37 | backoffType: 'static' 38 | } 39 | }); 40 | 41 | expect(response.status).toBe(200); 42 | expect(response.data).toEqual({ success: true }); 43 | 44 | scope.done(); 45 | }); 46 | 47 | test('should respect retry count', async () => { 48 | const url = 'https://api.example.com'; 49 | 50 | const scope = nock(url) 51 | .get('/fail') 52 | .times(3) 53 | .reply(500, { error: 'Server Error' }); 54 | 55 | const config = { 56 | raxConfig: { 57 | retry: 2, 58 | retryDelay: 10, 59 | backoffType: 'static' 60 | } 61 | }; 62 | 63 | await expect( 64 | axiosInstance.get(`${url}/fail`, config) 65 | ).rejects.toThrow(); 66 | 67 | scope.done(); 68 | }); 69 | 70 | test('should call onRetryAttempt callback', async () => { 71 | const url = 'https://api.example.com'; 72 | const onRetryAttempt = jest.fn().mockResolvedValue(undefined); 73 | 74 | const scope = nock(url) 75 | .get('/retry') 76 | .reply(500) 77 | .get('/retry') 78 | .reply(200, { success: true }); 79 | 80 | await axiosInstance.get(`${url}/retry`, { 81 | raxConfig: { 82 | retry: 3, 83 | retryDelay: 10, 84 | backoffType: 'static', 85 | onRetryAttempt 86 | } 87 | }); 88 | 89 | expect(onRetryAttempt).toHaveBeenCalledTimes(1); 90 | 91 | scope.done(); 92 | }); 93 | 94 | test('should call onError callback', async () => { 95 | const url = 'https://api.example.com'; 96 | const onError = jest.fn().mockResolvedValue(undefined); 97 | 98 | const scope = nock(url) 99 | .get('/error') 100 | .reply(500) 101 | .get('/error') 102 | .reply(200, { success: true }); 103 | 104 | await axiosInstance.get(`${url}/error`, { 105 | raxConfig: { 106 | retry: 3, 107 | retryDelay: 10, 108 | backoffType: 'static', 109 | onError 110 | } 111 | }); 112 | 113 | expect(onError).toHaveBeenCalledTimes(1); 114 | 115 | scope.done(); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /examples/jest-testing.md: -------------------------------------------------------------------------------- 1 | # Testing retry-axios with Jest 2 | 3 | This guide demonstrates how to test code that uses `retry-axios` in Jest. 4 | 5 | ## The Challenge 6 | 7 | When testing retry logic, you need to ensure that: 8 | 1. Failed requests are actually retried 9 | 2. The correct number of retries occur 10 | 3. Retry configuration is respected 11 | 12 | ## Recommended Approach: Use `nock` 13 | 14 | The best way to test retry-axios is with [nock](https://github.com/nock/nock), which intercepts HTTP requests at the network level. This works because retry-axios uses axios interceptors, and nock works below that layer. 15 | 16 | ### Installation 17 | 18 | ```bash 19 | npm install --save-dev jest nock 20 | ``` 21 | 22 | ### Example Test 23 | 24 | ```javascript 25 | const axios = require('axios'); 26 | const nock = require('nock'); 27 | const rax = require('retry-axios'); 28 | 29 | describe('retry-axios with Jest', () => { 30 | let axiosInstance; 31 | let interceptorId; 32 | 33 | beforeEach(() => { 34 | // Create a custom axios instance for testing 35 | // This is recommended over using the global axios instance 36 | axiosInstance = axios.create(); 37 | axiosInstance.defaults.raxConfig = { 38 | instance: axiosInstance 39 | }; 40 | interceptorId = rax.attach(axiosInstance); 41 | }); 42 | 43 | afterEach(() => { 44 | // Clean up 45 | nock.cleanAll(); 46 | if (interceptorId !== undefined) { 47 | rax.detach(interceptorId, axiosInstance); 48 | } 49 | }); 50 | 51 | test('should retry failed requests', async () => { 52 | const url = 'https://api.example.com'; 53 | 54 | // Set up nock to return a 500 error first, then success 55 | const scope = nock(url) 56 | .get('/data') 57 | .reply(500, { error: 'Server Error' }) 58 | .get('/data') 59 | .reply(200, { success: true }); 60 | 61 | const response = await axiosInstance.get(`${url}/data`, { 62 | raxConfig: { 63 | retry: 3, 64 | retryDelay: 100, 65 | backoffType: 'static' 66 | } 67 | }); 68 | 69 | expect(response.status).toBe(200); 70 | expect(response.data).toEqual({ success: true }); 71 | 72 | // Verify all nock requests were made 73 | scope.done(); 74 | }); 75 | 76 | test('should respect retry count', async () => { 77 | const url = 'https://api.example.com'; 78 | 79 | // Set up nock to fail 3 times 80 | const scope = nock(url) 81 | .get('/fail') 82 | .times(3) 83 | .reply(500, { error: 'Server Error' }); 84 | 85 | const config = { 86 | raxConfig: { 87 | retry: 2, // Will make initial request + 2 retries = 3 total 88 | retryDelay: 10, 89 | backoffType: 'static' 90 | } 91 | }; 92 | 93 | await expect( 94 | axiosInstance.get(`${url}/fail`, config) 95 | ).rejects.toThrow(); 96 | 97 | // Verify exactly 3 requests were made 98 | scope.done(); 99 | }); 100 | 101 | test('should call onRetryAttempt callback', async () => { 102 | const url = 'https://api.example.com'; 103 | const onRetryAttempt = jest.fn().mockResolvedValue(undefined); 104 | 105 | const scope = nock(url) 106 | .get('/retry') 107 | .reply(500) 108 | .get('/retry') 109 | .reply(200, { success: true }); 110 | 111 | await axiosInstance.get(`${url}/retry`, { 112 | raxConfig: { 113 | retry: 3, 114 | retryDelay: 10, 115 | backoffType: 'static', 116 | onRetryAttempt 117 | } 118 | }); 119 | 120 | // Verify callback was called once (for the retry) 121 | expect(onRetryAttempt).toHaveBeenCalledTimes(1); 122 | expect(onRetryAttempt).toHaveBeenCalledWith( 123 | expect.objectContaining({ 124 | config: expect.any(Object) 125 | }) 126 | ); 127 | 128 | scope.done(); 129 | }); 130 | 131 | test('should call onError callback', async () => { 132 | const url = 'https://api.example.com'; 133 | const onError = jest.fn().mockResolvedValue(undefined); 134 | 135 | const scope = nock(url) 136 | .get('/error') 137 | .reply(500) 138 | .get('/error') 139 | .reply(200, { success: true }); 140 | 141 | await axiosInstance.get(`${url}/error`, { 142 | raxConfig: { 143 | retry: 3, 144 | retryDelay: 10, 145 | backoffType: 'static', 146 | onError 147 | } 148 | }); 149 | 150 | // onError is called immediately when error occurs (before retry) 151 | expect(onError).toHaveBeenCalledTimes(1); 152 | 153 | scope.done(); 154 | }); 155 | }); 156 | ``` 157 | 158 | ## Testing with axios-mock-adapter 159 | 160 | An alternative approach is using [axios-mock-adapter](https://github.com/ctimmerm/axios-mock-adapter): 161 | 162 | ```javascript 163 | const axios = require('axios'); 164 | const MockAdapter = require('axios-mock-adapter'); 165 | const rax = require('retry-axios'); 166 | 167 | describe('retry-axios with axios-mock-adapter', () => { 168 | let mock; 169 | let interceptorId; 170 | 171 | beforeEach(() => { 172 | mock = new MockAdapter(axios); 173 | interceptorId = rax.attach(); 174 | }); 175 | 176 | afterEach(() => { 177 | mock.restore(); 178 | if (interceptorId !== undefined) { 179 | rax.detach(interceptorId); 180 | } 181 | }); 182 | 183 | test('should retry with axios-mock-adapter', async () => { 184 | // Chain responses: fail then succeed 185 | mock.onGet('/api/data').replyOnce(500, { error: 'Server Error' }); 186 | mock.onGet('/api/data').replyOnce(200, { success: true }); 187 | 188 | const response = await axios.get('/api/data', { 189 | raxConfig: { 190 | retry: 3, 191 | retryDelay: 10, 192 | backoffType: 'static' 193 | } 194 | }); 195 | 196 | expect(response.status).toBe(200); 197 | expect(response.data).toEqual({ success: true }); 198 | }); 199 | }); 200 | ``` 201 | 202 | ## Testing Your Own Functions 203 | 204 | Here's how to test a function that uses retry-axios: 205 | 206 | ```javascript 207 | // src/api.js 208 | const axios = require('axios'); 209 | const rax = require('retry-axios'); 210 | 211 | async function fetchData(url) { 212 | rax.attach(); 213 | const response = await axios.get(url, { 214 | raxConfig: { 215 | retry: 3, 216 | noResponseRetries: 3, 217 | retryDelay: 100, 218 | backoffType: 'exponential' 219 | } 220 | }); 221 | return response.data; 222 | } 223 | 224 | module.exports = { fetchData }; 225 | ``` 226 | 227 | ```javascript 228 | // src/api.test.js 229 | const nock = require('nock'); 230 | const { fetchData } = require('./api'); 231 | 232 | describe('fetchData', () => { 233 | afterEach(() => { 234 | nock.cleanAll(); 235 | }); 236 | 237 | test('should retry on failure and return data on success', async () => { 238 | const url = 'https://api.example.com'; 239 | 240 | const scope = nock(url) 241 | .get('/users') 242 | .reply(500) 243 | .get('/users') 244 | .reply(500) 245 | .get('/users') 246 | .reply(200, { users: ['Alice', 'Bob'] }); 247 | 248 | const data = await fetchData(`${url}/users`); 249 | 250 | expect(data).toEqual({ users: ['Alice', 'Bob'] }); 251 | scope.done(); 252 | }); 253 | }); 254 | ``` 255 | 256 | ## CommonJS vs ESM 257 | 258 | retry-axios supports both CommonJS and ES modules: 259 | 260 | ### CommonJS 261 | ```javascript 262 | const rax = require('retry-axios'); 263 | const axios = require('axios'); 264 | ``` 265 | 266 | ### ES Modules 267 | ```javascript 268 | import * as rax from 'retry-axios'; 269 | import axios from 'axios'; 270 | ``` 271 | 272 | Jest should automatically pick the correct format based on your project configuration. 273 | 274 | ## Tips 275 | 276 | 1. **Use a custom axios instance** instead of the global one - this prevents test isolation issues and is more reliable in Jest 277 | 2. **Use `nock.cleanAll()` in `afterEach`** to prevent test pollution 278 | 3. **Always call `rax.detach()`** with the instance parameter to clean up interceptors 279 | 4. **Use `scope.done()`** to verify all expected requests were made 280 | 5. **Keep retry delays low** in tests (10-100ms) to speed up test runs 281 | 6. **Test both success and failure scenarios** to ensure retry logic works correctly 282 | 283 | ## Common Issues 284 | 285 | ### Issue: Tests hang or timeout 286 | - Make sure you're cleaning up with `nock.cleanAll()` and `rax.detach()` 287 | - Check that your retry delays aren't too long 288 | - Ensure nock scope matches all expected requests 289 | 290 | ### Issue: "Nock: No match for request" 291 | - Verify the URL in your test matches exactly (including protocol, host, path) 292 | - Check that you've set up enough nock replies for the number of retries 293 | 294 | ### Issue: Retries aren't happening 295 | - Make sure `rax.attach()` is called before making the axios request 296 | - Verify the error status code is in `statusCodesToRetry` (defaults to 5xx, 429, and 1xx) 297 | - Check that the HTTP method is in `httpMethodsToRetry` (defaults include GET, HEAD, PUT, OPTIONS, DELETE) 298 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retry-axios", 3 | "version": "4.0.0", 4 | "description": "Retry HTTP requests with Axios.", 5 | "exports": { 6 | ".": { 7 | "types": "./build/src/index.d.ts", 8 | "import": "./build/src/index.js", 9 | "require": "./build/src/index.cjs" 10 | } 11 | }, 12 | "type": "module", 13 | "main": "./build/src/index.cjs", 14 | "module": "./build/src/index.js", 15 | "types": "./build/src/index.d.ts", 16 | "engines": { 17 | "node": ">=20" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/JustinBeckwith/retry-axios.git" 22 | }, 23 | "scripts": { 24 | "fix": "biome check --write .", 25 | "lint": "biome check .", 26 | "compile": "npm run compile:esm && npm run compile:cjs", 27 | "compile:esm": "tsc -p .", 28 | "compile:cjs": "esbuild build/src/index.js --bundle --platform=node --format=cjs --external:axios --outfile=build/src/index.cjs", 29 | "test": "vitest run --coverage", 30 | "pretest": "npm run compile", 31 | "test:watch": "vitest watch", 32 | "license-check": "jsgl --local ." 33 | }, 34 | "keywords": [ 35 | "axios", 36 | "retry" 37 | ], 38 | "author": { 39 | "name": "Justin Beckwith" 40 | }, 41 | "license": "Apache-2.0", 42 | "peerDependencies": { 43 | "axios": "*" 44 | }, 45 | "devDependencies": { 46 | "@biomejs/biome": "^2.2.3", 47 | "@types/node": "^24.0.0", 48 | "@vitest/coverage-v8": "^4.0.0", 49 | "axios": "^1.2.1", 50 | "esbuild": "^0.25.9", 51 | "js-green-licenses": "^4.0.0", 52 | "nock": "^14.0.10", 53 | "p-defer": "^4.0.1", 54 | "typescript": "~5.9.3", 55 | "vitest": "^4.0.0" 56 | }, 57 | "files": [ 58 | "build/src" 59 | ], 60 | "c8": { 61 | "exclude": [ 62 | "build/test", 63 | "dist" 64 | ], 65 | "reporter": [ 66 | "text", 67 | "lcov" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "node", 5 | "package-name": "retry-axios", 6 | "changelog-path": "CHANGELOG.md", 7 | "extra-files": ["package.json"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", ":disableDependencyDashboard"], 3 | "pinVersions": false, 4 | "rebaseStalePrs": true 5 | } 6 | -------------------------------------------------------------------------------- /site/retry-axios.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustinBeckwith/retry-axios/c700f8404286cd93fd5d698fa86ed867c3ccdddc/site/retry-axios.webp -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { 2 | type AxiosError, 3 | type AxiosInstance, 4 | type AxiosRequestConfig, 5 | type AxiosResponse, 6 | isCancel, 7 | } from 'axios'; 8 | 9 | /** 10 | * Configuration for the Axios `request` method. 11 | */ 12 | export interface RetryConfig { 13 | /** 14 | * The number of times to retry the request. Defaults to 3. 15 | */ 16 | retry?: number; 17 | 18 | /** 19 | * The number of retries already attempted. 20 | */ 21 | currentRetryAttempt?: number; 22 | 23 | /** 24 | * The number of retries remaining before giving up. 25 | * Calculated as: retry - currentRetryAttempt 26 | */ 27 | retriesRemaining?: number; 28 | 29 | /** 30 | * The delay in milliseconds used for retry backoff. Defaults to 100. 31 | * - For 'static' backoff: Fixed delay between retries 32 | * - For 'exponential' backoff: Base multiplier for exponential calculation 33 | * - For 'linear' backoff: Ignored (uses attempt * 1000) 34 | */ 35 | retryDelay?: number; 36 | 37 | /** 38 | * The HTTP Methods that will be automatically retried. 39 | * Defaults to ['GET','PUT','HEAD','OPTIONS','DELETE'] 40 | */ 41 | httpMethodsToRetry?: string[]; 42 | 43 | /** 44 | * The HTTP response status codes that will automatically be retried. 45 | * Defaults to: [[100, 199], [429, 429], [500, 599]] 46 | */ 47 | statusCodesToRetry?: number[][]; 48 | 49 | /** 50 | * Function to invoke when error occurred. 51 | */ 52 | onError?: (error: AxiosError) => void | Promise; 53 | 54 | /** 55 | * Function to invoke when a retry attempt is made. 56 | * The retry will wait for the returned promise to resolve before proceeding. 57 | * If the promise rejects, the retry will be aborted and the rejection will be propagated. 58 | */ 59 | onRetryAttempt?: (error: AxiosError) => Promise; 60 | 61 | /** 62 | * Function to invoke which determines if you should retry. 63 | * This is called after checking the retry count limit but before other default checks. 64 | * Return true to retry, false to stop retrying. 65 | * If not provided, uses the default retry logic based on status codes and HTTP methods. 66 | */ 67 | shouldRetry?: (error: AxiosError) => boolean; 68 | 69 | /** 70 | * Backoff Type; 'linear', 'static' or 'exponential'. 71 | */ 72 | backoffType?: 'linear' | 'static' | 'exponential'; 73 | 74 | /** 75 | * Jitter strategy for exponential backoff. Defaults to 'none'. 76 | * - 'none': No jitter (default) 77 | * - 'full': Random delay between 0 and calculated exponential backoff 78 | * - 'equal': Half fixed delay, half random 79 | * 80 | * Jitter helps prevent the "thundering herd" problem where many clients 81 | * retry at the same time. Only applies when backoffType is 'exponential'. 82 | */ 83 | jitter?: 'none' | 'full' | 'equal'; 84 | 85 | /** 86 | * Whether to check for 'Retry-After' header in response and use value as delay. Defaults to true. 87 | */ 88 | checkRetryAfter?: boolean; 89 | 90 | /** 91 | * Max permitted Retry-After value (in ms) - rejects if greater. Defaults to 5 mins. 92 | */ 93 | maxRetryAfter?: number; 94 | 95 | /** 96 | * Ceiling for calculated delay (in ms) - delay will not exceed this value. 97 | */ 98 | maxRetryDelay?: number; 99 | 100 | /** 101 | * Array of all errors encountered during retry attempts. 102 | * Populated automatically when retries are performed. 103 | * The first element is the initial error, subsequent elements are retry errors. 104 | */ 105 | errors?: AxiosError[]; 106 | } 107 | 108 | export type RaxConfig = { 109 | raxConfig: RetryConfig; 110 | } & AxiosRequestConfig; 111 | 112 | // If this wasn't in the list of status codes where we want to automatically retry, return. 113 | const retryRanges = [ 114 | // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes 115 | // 1xx - Retry (Informational, request still processing) 116 | // 2xx - Do not retry (Success) 117 | // 3xx - Do not retry (Redirect) 118 | // 4xx - Do not retry (Client errors) 119 | // 429 - Retry ("Too Many Requests") 120 | // 5xx - Retry (Server errors) 121 | [100, 199], 122 | [429, 429], 123 | [500, 599], 124 | ]; 125 | 126 | /** 127 | * Attach the interceptor to the Axios instance. 128 | * @param instance The optional Axios instance on which to attach the 129 | * interceptor. 130 | * @returns The id of the interceptor attached to the axios instance. 131 | */ 132 | export function attach(instance?: AxiosInstance) { 133 | const inst = instance || axios; 134 | return inst.interceptors.response.use( 135 | onFulfilled, 136 | async (error: AxiosError) => onError(inst, error), 137 | ); 138 | } 139 | 140 | /** 141 | * Eject the Axios interceptor that is providing retry capabilities. 142 | * @param interceptorId The interceptorId provided in the config. 143 | * @param instance The axios instance using this interceptor. 144 | */ 145 | export function detach(interceptorId: number, instance?: AxiosInstance) { 146 | const inst = instance || axios; 147 | inst.interceptors.response.eject(interceptorId); 148 | } 149 | 150 | function onFulfilled(result: AxiosResponse) { 151 | return result; 152 | } 153 | 154 | /** 155 | * Some versions of axios are converting arrays into objects during retries. 156 | * This will attempt to convert an object with the following structure into 157 | * an array, where the keys correspond to the indices: 158 | * { 159 | * 0: { 160 | * // some property 161 | * }, 162 | * 1: { 163 | * // another 164 | * } 165 | * } 166 | * @param obj The object that (may) have integers that correspond to an index 167 | * @returns An array with the pucked values 168 | */ 169 | function normalizeArray(object?: T[]): T[] | undefined { 170 | const array: T[] = []; 171 | if (!object) { 172 | return undefined; 173 | } 174 | 175 | if (Array.isArray(object)) { 176 | return object; 177 | } 178 | 179 | if (typeof object === 'object') { 180 | for (const key of Object.keys(object)) { 181 | const number_ = Number.parseInt(key, 10); 182 | if (!Number.isNaN(number_)) { 183 | array[number_] = object[key]; 184 | } 185 | } 186 | } 187 | 188 | return array; 189 | } 190 | 191 | /** 192 | * Parse the Retry-After header. 193 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After 194 | * @param header Retry-After header value 195 | * @returns Number of milliseconds, or undefined if invalid 196 | */ 197 | function parseRetryAfter(header: string): number | undefined { 198 | // Header value may be string containing integer seconds 199 | const value = Number(header); 200 | if (!Number.isNaN(value)) { 201 | return value * 1000; 202 | } 203 | 204 | // Or HTTP date time string 205 | const dateTime = Date.parse(header); 206 | if (!Number.isNaN(dateTime)) { 207 | return dateTime - Date.now(); 208 | } 209 | 210 | return undefined; 211 | } 212 | 213 | async function onError(instance: AxiosInstance, error: AxiosError) { 214 | if (isCancel(error)) { 215 | throw error; 216 | } 217 | 218 | const config = getConfig(error) || {}; 219 | config.currentRetryAttempt ||= 0; 220 | config.retry = typeof config.retry === 'number' ? config.retry : 3; 221 | config.retryDelay = 222 | typeof config.retryDelay === 'number' ? config.retryDelay : 100; 223 | config.backoffType ||= 'exponential'; 224 | config.httpMethodsToRetry = normalizeArray(config.httpMethodsToRetry) || [ 225 | 'GET', 226 | 'HEAD', 227 | 'PUT', 228 | 'OPTIONS', 229 | 'DELETE', 230 | ]; 231 | config.checkRetryAfter = 232 | typeof config.checkRetryAfter === 'boolean' ? config.checkRetryAfter : true; 233 | config.maxRetryAfter = 234 | typeof config.maxRetryAfter === 'number' 235 | ? config.maxRetryAfter 236 | : 60_000 * 5; 237 | 238 | config.statusCodesToRetry = 239 | normalizeArray(config.statusCodesToRetry) || retryRanges; 240 | 241 | // Put the config back into the err 242 | const axiosError = error as AxiosError; 243 | 244 | // biome-ignore lint/suspicious/noExplicitAny: Allow for wider range of errors 245 | (axiosError.config as any) = axiosError.config || {}; // Allow for wider range of errors 246 | (axiosError.config as RaxConfig).raxConfig = { ...config }; 247 | 248 | // Initialize errors array on first error, or append to existing array 249 | if (!config.errors) { 250 | config.errors = [axiosError]; 251 | (axiosError.config as RaxConfig).raxConfig.errors = config.errors; 252 | } else { 253 | config.errors.push(axiosError); 254 | } 255 | 256 | // Determine if we should retry the request 257 | // First check the retry count limit, then apply custom logic if provided 258 | if (config.shouldRetry) { 259 | // When custom shouldRetry is provided, we still need to check the retry count 260 | // to prevent infinite retries (see issue #117) 261 | config.currentRetryAttempt ||= 0; 262 | if (config.currentRetryAttempt >= (config.retry ?? 0)) { 263 | throw axiosError; 264 | } 265 | // Now apply the custom shouldRetry logic 266 | if (!config.shouldRetry(axiosError)) { 267 | throw axiosError; 268 | } 269 | } else { 270 | // Use the default shouldRetryRequest logic 271 | if (!shouldRetryRequest(axiosError)) { 272 | throw axiosError; 273 | } 274 | } 275 | 276 | // Create a promise that invokes the retry after the backOffDelay 277 | const onBackoffPromise = new Promise((resolve, reject) => { 278 | let delay = 0; 279 | // If enabled, check for 'Retry-After' header in response to use as delay 280 | if ( 281 | config.checkRetryAfter && 282 | axiosError.response?.headers?.['retry-after'] 283 | ) { 284 | const retryAfter = parseRetryAfter( 285 | axiosError.response.headers['retry-after'] as string, 286 | ); 287 | if ( 288 | retryAfter && 289 | retryAfter > 0 && 290 | retryAfter <= (config.maxRetryAfter ?? 0) 291 | ) { 292 | delay = retryAfter; 293 | } else { 294 | reject(axiosError); 295 | return; 296 | } 297 | } 298 | 299 | // Now it's certain that a retry is supposed to happen. Incremenent the 300 | // counter, critical for linear and exp backoff delay calc. Note that 301 | // `config.currentRetryAttempt` is local to this function whereas 302 | // `(err.config as RaxConfig).raxConfig` is state that is tranferred across 303 | // retries. That is, we want to mutate `(err.config as 304 | // RaxConfig).raxConfig`. Another important note is about the definition of 305 | // `currentRetryAttempt`: When we are here becasue the first and actual 306 | // HTTP request attempt failed then `currentRetryAttempt` is still zero. We 307 | // have found that a retry is indeed required. Since that is (will be) 308 | // indeed the first retry it makes sense to now increase 309 | // `currentRetryAttempt` by 1. So that it is in fact 1 for the first retry 310 | // (as opposed to 0 or 2); an intuitive convention to use for the math 311 | // below. 312 | // biome-ignore lint/style/noNonNullAssertion: Checked above 313 | (axiosError.config as RaxConfig).raxConfig.currentRetryAttempt! += 1; 314 | 315 | // Calculate retries remaining 316 | (axiosError.config as RaxConfig).raxConfig.retriesRemaining = 317 | // biome-ignore lint/style/noNonNullAssertion: Checked above 318 | config.retry! - 319 | // biome-ignore lint/style/noNonNullAssertion: Checked above 320 | (axiosError.config as RaxConfig).raxConfig.currentRetryAttempt!; 321 | 322 | // Store with shorter and more expressive variable name. 323 | // biome-ignore lint/style/noNonNullAssertion: Checked above 324 | const retrycount = (axiosError.config as RaxConfig).raxConfig 325 | .currentRetryAttempt!; 326 | 327 | // Calculate delay according to chosen strategy 328 | // Default to exponential backoff - formula: ((2^c - 1) / 2) * retryDelay 329 | if (delay === 0) { 330 | // Was not set by Retry-After logic 331 | if (config.backoffType === 'linear') { 332 | // The delay between the first (actual) attempt and the first retry 333 | // should be non-zero. Rely on the convention that `retrycount` is 334 | // equal to 1 for the first retry when we are in here (was once 0, 335 | // which was a bug -- see #122). 336 | delay = retrycount * 1000; 337 | } else if (config.backoffType === 'static') { 338 | // biome-ignore lint/style/noNonNullAssertion: Checked above 339 | delay = config.retryDelay!; 340 | } else { 341 | // Exponential backoff with retryDelay as base multiplier 342 | // biome-ignore lint/style/noNonNullAssertion: Checked above 343 | const baseDelay = config.retryDelay!; 344 | delay = ((2 ** retrycount - 1) / 2) * baseDelay; 345 | 346 | // Apply jitter if configured 347 | const jitter = config.jitter || 'none'; 348 | if (jitter === 'full') { 349 | // Full jitter: random delay between 0 and calculated delay 350 | delay = Math.random() * delay; 351 | } else if (jitter === 'equal') { 352 | // Equal jitter: half fixed, half random 353 | delay = delay / 2 + Math.random() * (delay / 2); 354 | } 355 | // 'none' or any other value: no jitter applied 356 | } 357 | 358 | if (typeof config.maxRetryDelay === 'number') { 359 | delay = Math.min(delay, config.maxRetryDelay); 360 | } 361 | } 362 | 363 | setTimeout(resolve, delay); 364 | }); 365 | 366 | if (config.onError) { 367 | await config.onError(axiosError); 368 | } 369 | 370 | // Return the promise in which recalls axios to retry the request 371 | return ( 372 | Promise.resolve() 373 | .then(async () => onBackoffPromise) 374 | .then(async () => config.onRetryAttempt?.(axiosError)) 375 | // biome-ignore lint/style/noNonNullAssertion: Checked above 376 | .then(async () => instance.request(axiosError.config!)) 377 | ); 378 | } 379 | 380 | /** 381 | * Determine based on config if we should retry the request. 382 | * @param err The AxiosError passed to the interceptor. 383 | */ 384 | export function shouldRetryRequest(error: AxiosError) { 385 | const config = (error.config as RaxConfig).raxConfig; 386 | 387 | // If there's no config, or retries are disabled, return. 388 | if (!config || config.retry === 0) { 389 | return false; 390 | } 391 | 392 | // Check if we are out of retry attempts first 393 | config.currentRetryAttempt ||= 0; 394 | if (config.currentRetryAttempt >= (config.retry ?? 0)) { 395 | return false; 396 | } 397 | 398 | // Only retry with configured HttpMethods. 399 | if ( 400 | !error.config?.method || 401 | !config.httpMethodsToRetry?.includes(error.config.method.toUpperCase()) 402 | ) { 403 | return false; 404 | } 405 | 406 | // For errors with responses, check status codes 407 | if (error.response?.status) { 408 | let isInRange = false; 409 | // biome-ignore lint/style/noNonNullAssertion: Checked above 410 | for (const [min, max] of config.statusCodesToRetry!) { 411 | const { status } = error.response; 412 | if (status >= min && status <= max) { 413 | isInRange = true; 414 | break; 415 | } 416 | } 417 | 418 | if (!isInRange) { 419 | return false; 420 | } 421 | } 422 | 423 | // For errors without responses (network errors, timeouts, etc.) 424 | // we allow retry as long as we haven't exceeded the retry limit 425 | // This includes: ETIMEDOUT, ENOTFOUND, ECONNABORTED, ECONNRESET, etc. 426 | 427 | return true; 428 | } 429 | 430 | /** 431 | * Acquire the raxConfig object from an AxiosError if available. 432 | * @param err The Axios error with a config object. 433 | */ 434 | export function getConfig(error: AxiosError) { 435 | if (error?.config) { 436 | return (error.config as RaxConfig).raxConfig; 437 | } 438 | } 439 | 440 | // Include this so `config.raxConfig` works easily. 441 | // See https://github.com/JustinBeckwith/retry-axios/issues/64. 442 | declare module 'axios' { 443 | export interface AxiosRequestConfig { 444 | raxConfig?: RetryConfig; 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /test/cjs-import.test.cjs: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const nock = require('nock'); 3 | 4 | // Test CommonJS import 5 | const rax = require('../build/src/index.cjs'); 6 | 7 | const url = 'http://test-cjs.local'; 8 | 9 | nock.disableNetConnect(); 10 | 11 | describe('retry-axios CJS import', () => { 12 | let interceptorId; 13 | 14 | afterEach(() => { 15 | nock.cleanAll(); 16 | if (interceptorId !== undefined) { 17 | rax.detach(interceptorId); 18 | interceptorId = undefined; 19 | } 20 | }); 21 | 22 | it('should successfully import all exports via CommonJS', () => { 23 | assert.strictEqual(typeof rax.attach, 'function', 'attach should be a function'); 24 | assert.strictEqual(typeof rax.detach, 'function', 'detach should be a function'); 25 | assert.strictEqual(typeof rax.shouldRetryRequest, 'function', 'shouldRetryRequest should be a function'); 26 | assert.strictEqual(typeof rax.getConfig, 'function', 'getConfig should be a function'); 27 | }); 28 | 29 | it('should work with basic retry functionality via CJS import', async () => { 30 | const scope = nock(url) 31 | .get('/') 32 | .reply(500) 33 | .get('/') 34 | .reply(200, 'success'); 35 | 36 | interceptorId = rax.attach(); 37 | 38 | const response = await axios.get(url, { 39 | raxConfig: { 40 | retry: 2, 41 | retryDelay: 10 42 | } 43 | }); 44 | 45 | assert.strictEqual(response.status, 200); 46 | assert.strictEqual(response.data, 'success'); 47 | scope.done(); 48 | }); 49 | 50 | it('should properly handle axios instance with CJS import', async () => { 51 | const customAxios = axios.create(); 52 | const scope = nock(url) 53 | .get('/instance') 54 | .reply(500) 55 | .get('/instance') 56 | .reply(200, 'instance-success'); 57 | 58 | interceptorId = rax.attach(customAxios); 59 | 60 | const response = await customAxios.get(url + '/instance', { 61 | raxConfig: { 62 | retry: 2, 63 | retryDelay: 10 64 | } 65 | }); 66 | 67 | assert.strictEqual(response.status, 200); 68 | assert.strictEqual(response.data, 'instance-success'); 69 | scope.done(); 70 | }); 71 | 72 | it('should provide access to configuration via CJS import', async () => { 73 | const scope = nock(url) 74 | .get('/config') 75 | .reply(500); 76 | 77 | interceptorId = rax.attach(); 78 | 79 | try { 80 | await axios.get(url + '/config', { 81 | raxConfig: { 82 | retry: 1, 83 | retryDelay: 10 84 | } 85 | }); 86 | } catch (error) { 87 | const config = rax.getConfig(error); 88 | assert.ok(config, 'config should exist'); 89 | assert.strictEqual(config.retry, 1, 'retry should be 1'); 90 | assert.strictEqual(config.currentRetryAttempt, 1, 'should have attempted retry'); 91 | scope.done(); 92 | } 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import process from 'node:process'; 3 | import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'; 4 | import nock from 'nock'; 5 | import pDefer from 'p-defer'; 6 | import { afterEach, describe, it, vitest } from 'vitest'; 7 | import type { RaxConfig } from '../src/index.js'; 8 | import * as rax from '../src/index.js'; 9 | 10 | const url = 'http://test.local'; 11 | 12 | nock.disableNetConnect(); 13 | 14 | describe('retry-axios', () => { 15 | let interceptorId: number | undefined; 16 | afterEach(() => { 17 | vitest.useRealTimers(); 18 | nock.cleanAll(); 19 | if (interceptorId !== undefined) { 20 | rax.detach(interceptorId); 21 | } 22 | }); 23 | 24 | it('should provide an expected set of defaults', async () => { 25 | const scope = nock(url).get('/').thrice().reply(500); 26 | interceptorId = rax.attach(); 27 | try { 28 | await axios(url); 29 | } catch (error) { 30 | const axiosError = error as AxiosError; 31 | scope.done(); 32 | const config = rax.getConfig(axiosError); 33 | assert.ok(config); 34 | assert.strictEqual(config.currentRetryAttempt, 3, 'currentRetryAttempt'); 35 | assert.strictEqual(config.retry, 3, 'retry'); 36 | assert.strictEqual(config.retryDelay, 100, 'retryDelay'); 37 | assert.strictEqual(config.backoffType, 'exponential', 'backoffType'); 38 | assert.strictEqual(config.checkRetryAfter, true); 39 | assert.strictEqual(config.maxRetryAfter, 60_000 * 5); 40 | const expectedMethods = new Set([ 41 | 'GET', 42 | 'HEAD', 43 | 'PUT', 44 | 'OPTIONS', 45 | 'DELETE', 46 | ]); 47 | assert.ok(config.httpMethodsToRetry); 48 | for (const method of config.httpMethodsToRetry) { 49 | assert(expectedMethods.has(method), 'exected method: $method'); 50 | } 51 | 52 | const expectedStatusCodes = [ 53 | [100, 199], 54 | [429, 429], 55 | [500, 599], 56 | ]; 57 | const statusCodesToRetry = config.statusCodesToRetry; 58 | assert.ok(statusCodesToRetry); 59 | for (const [i, [min, max]] of statusCodesToRetry.entries()) { 60 | const [expMin, expMax] = expectedStatusCodes[i]; 61 | assert.strictEqual(min, expMin, 'status code min'); 62 | assert.strictEqual(max, expMax, 'status code max'); 63 | } 64 | 65 | return; 66 | } 67 | 68 | assert.fail('Expected to throw.'); 69 | }); 70 | 71 | it('should retry on 500 on the main export', async () => { 72 | const scopes = [ 73 | nock(url).get('/').reply(500), 74 | nock(url).get('/').reply(200, 'toast'), 75 | ]; 76 | interceptorId = rax.attach(); 77 | const result = await axios({ url }); 78 | assert.strictEqual(result.data, 'toast'); 79 | for (const s of scopes) { 80 | s.done(); 81 | } 82 | }); 83 | 84 | it('should support methods passed as an object', async () => { 85 | const scopes = [ 86 | nock(url).post('/').reply(500), 87 | nock(url).post('/').reply(200, 'toast'), 88 | ]; 89 | interceptorId = rax.attach(); 90 | const result = await axios.post( 91 | url, 92 | {}, 93 | { raxConfig: { httpMethodsToRetry: { ...['POST'] } } }, 94 | ); 95 | assert.strictEqual(result.data, 'toast'); 96 | for (const s of scopes) { 97 | s.done(); 98 | } 99 | }); 100 | 101 | it('should not retry on a post', async () => { 102 | const scope = nock(url).post('/').reply(500); 103 | interceptorId = rax.attach(); 104 | try { 105 | await axios.post(url); 106 | } catch (error) { 107 | const axiosError = error as AxiosError; 108 | const config = rax.getConfig(axiosError); 109 | assert.ok(config); 110 | assert.strictEqual(config.currentRetryAttempt, 0); 111 | scope.done(); 112 | return; 113 | } 114 | 115 | assert.fail('Expected to throw'); 116 | }); 117 | 118 | it( 119 | 'should retry at least the configured number of times', 120 | { timeout: 10_000 }, 121 | async () => { 122 | const scopes = [ 123 | nock(url).get('/').times(3).reply(500), 124 | nock(url).get('/').reply(200, 'milk'), 125 | ]; 126 | interceptorId = rax.attach(); 127 | const cfg: rax.RaxConfig = { url, raxConfig: { retry: 4 } }; 128 | const result = await axios(cfg); 129 | assert.strictEqual(result.data, 'milk'); 130 | for (const s of scopes) { 131 | s.done(); 132 | } 133 | }, 134 | ); 135 | 136 | it( 137 | 'should retry at least the configured number of times for custom client', 138 | { timeout: 10_000 }, 139 | async () => { 140 | const scopes = [ 141 | nock(url).get('/').times(3).reply(500), 142 | nock(url).get('/').reply(200, 'milk'), 143 | ]; 144 | const client = axios.create(); 145 | interceptorId = rax.attach(client); 146 | const cfg: rax.RaxConfig = { url, raxConfig: { retry: 4 } }; 147 | const result = await client(cfg); 148 | assert.strictEqual(result.data, 'milk'); 149 | for (const s of scopes) { 150 | s.done(); 151 | } 152 | }, 153 | ); 154 | 155 | it('should not retry more than configured', async () => { 156 | const scope = nock(url).get('/').twice().reply(500); 157 | interceptorId = rax.attach(); 158 | const cfg: rax.RaxConfig = { url, raxConfig: { retry: 1 } }; 159 | try { 160 | await axios(cfg); 161 | } catch (error) { 162 | const axiosError = error as AxiosError; 163 | const config = rax.getConfig(axiosError); 164 | assert.ok(config); 165 | assert.strictEqual(config.currentRetryAttempt, 1); 166 | scope.done(); 167 | return; 168 | } 169 | 170 | assert.fail('Expected to throw'); 171 | }); 172 | 173 | it('should have non-zero delay between first and second attempt, static backoff', async () => { 174 | const requesttimes: bigint[] = []; 175 | const scopes = [ 176 | nock(url) 177 | .get('/') 178 | .reply(() => { 179 | requesttimes.push(process.hrtime.bigint()); 180 | return [500, 'foo']; 181 | }), 182 | nock(url) 183 | .get('/') 184 | .reply(() => { 185 | requesttimes.push(process.hrtime.bigint()); 186 | return [200, 'bar']; 187 | }), 188 | ]; 189 | 190 | interceptorId = rax.attach(); 191 | const result = await axios({ 192 | url, 193 | raxConfig: { 194 | backoffType: 'static', 195 | }, 196 | }); 197 | 198 | // Confirm that first retry did yield 200 OK with expected body 199 | assert.strictEqual(result.data, 'bar'); 200 | for (const s of scopes) { 201 | s.done(); 202 | } 203 | 204 | assert.strictEqual(requesttimes.length, 2); 205 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 206 | 207 | // The default delay between attempts using the 208 | // static backoff strategy is 100 ms. Test with tolerance. 209 | assert.strict( 210 | delayInSeconds < 0.16 && delayInSeconds > 0.1, 211 | `unexpected delay: ${delayInSeconds.toFixed(3)} s`, 212 | ); 213 | }); 214 | 215 | it('should have non-zero delay between first and second attempt, linear backoff', async () => { 216 | const requesttimes: bigint[] = []; 217 | const scopes = [ 218 | nock(url) 219 | .get('/') 220 | .reply(() => { 221 | requesttimes.push(process.hrtime.bigint()); 222 | return [500, 'foo']; 223 | }), 224 | nock(url) 225 | .get('/') 226 | .reply(() => { 227 | requesttimes.push(process.hrtime.bigint()); 228 | return [200, 'bar']; 229 | }), 230 | ]; 231 | 232 | interceptorId = rax.attach(); 233 | const result = await axios({ 234 | url, 235 | raxConfig: { 236 | backoffType: 'linear', 237 | }, 238 | }); 239 | 240 | // Confirm that first retry did yield 200 OK with expected body 241 | assert.strictEqual(result.data, 'bar'); 242 | for (const s of scopes) { 243 | s.done(); 244 | } 245 | 246 | assert.strictEqual(requesttimes.length, 2); 247 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 248 | 249 | // The default delay between the first two attempts using the 250 | // linear backoff strategy is 1000 ms. Test with tolerance. 251 | assert.strict( 252 | delayInSeconds < 1.1 && delayInSeconds > 1, 253 | `unexpected delay: ${delayInSeconds.toFixed(3)} s`, 254 | ); 255 | }); 256 | 257 | it('should have non-zero delay between first and second attempt, exp backoff', async () => { 258 | const requesttimes: bigint[] = []; 259 | const scopes = [ 260 | nock(url) 261 | .get('/') 262 | .reply(() => { 263 | requesttimes.push(process.hrtime.bigint()); 264 | return [500, 'foo']; 265 | }), 266 | nock(url) 267 | .get('/') 268 | .reply(() => { 269 | requesttimes.push(process.hrtime.bigint()); 270 | return [200, 'bar']; 271 | }), 272 | ]; 273 | 274 | interceptorId = rax.attach(); 275 | const result = await axios({ 276 | url, 277 | raxConfig: { 278 | backoffType: 'exponential', 279 | }, 280 | }); 281 | 282 | // Confirm that first retry did yield 200 OK with expected body 283 | assert.strictEqual(result.data, 'bar'); 284 | for (const s of scopes) { 285 | s.done(); 286 | } 287 | 288 | assert.strictEqual(requesttimes.length, 2); 289 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 290 | 291 | // The default delay between attempts using the exp backoff strategy with 292 | // default retryDelay=100 is: ((2^1 - 1) / 2) * 100 = 50 ms. Test with tolerance. 293 | assert.strict( 294 | delayInSeconds < 0.1 && delayInSeconds > 0.04, 295 | `unexpected delay: ${delayInSeconds.toFixed(3)} s`, 296 | ); 297 | }); 298 | 299 | it('should accept a new axios instance', async () => { 300 | const scopes = [ 301 | nock(url).get('/').times(2).reply(500), 302 | nock(url).get('/').reply(200, 'raisins'), 303 | ]; 304 | const ax = axios.create(); 305 | interceptorId = rax.attach(ax); 306 | const result = await ax.get(url); 307 | assert.strictEqual(result.data, 'raisins'); 308 | for (const s of scopes) { 309 | s.done(); 310 | } 311 | 312 | // Now make sure it fails the first time with just `axios` 313 | const scope = nock(url).get('/').reply(500); 314 | assert.notStrictEqual(ax, axios); 315 | try { 316 | await axios({ url }); 317 | } catch (error) { 318 | const axiosError = error as AxiosError; 319 | assert.strictEqual(undefined, rax.getConfig(axiosError)); 320 | scope.done(); 321 | return; 322 | } 323 | 324 | assert.fail('Expected to throw'); 325 | }); 326 | 327 | it('should accept defaults on a new instance', async () => { 328 | const scopes = [ 329 | nock(url).get('/').times(2).reply(500), 330 | nock(url).get('/').reply(200, '🥧'), 331 | ]; 332 | const ax = axios.create(); 333 | ax.defaults.raxConfig = { 334 | retry: 3, 335 | async onRetryAttempt(event) { 336 | const config = event.config; 337 | assert.ok(config); 338 | console.log(`attempt #${config.raxConfig?.currentRetryAttempt}`); 339 | }, 340 | }; 341 | interceptorId = rax.attach(ax); 342 | const result = await ax.get(url); 343 | assert.strictEqual(result.data, '🥧'); 344 | for (const s of scopes) { 345 | s.done(); 346 | } 347 | }); 348 | 349 | it('should not retry on 4xx errors', async () => { 350 | const scope = nock(url).get('/').reply(404); 351 | interceptorId = rax.attach(); 352 | try { 353 | await axios.get(url); 354 | } catch (error) { 355 | const axiosError = error as AxiosError; 356 | const config = rax.getConfig(axiosError); 357 | assert.ok(config); 358 | assert.strictEqual(config.currentRetryAttempt, 0); 359 | scope.done(); 360 | return; 361 | } 362 | 363 | assert.fail('Expected to throw'); 364 | }); 365 | 366 | it('should not retry if retries set to 0', async () => { 367 | const scope = nock(url).get('/').reply(500); 368 | interceptorId = rax.attach(); 369 | try { 370 | const cfg: rax.RaxConfig = { url, raxConfig: { retry: 0 } }; 371 | await axios(cfg); 372 | } catch (error) { 373 | const axiosError = error as AxiosError; 374 | const config = rax.getConfig(axiosError); 375 | assert.ok(config); 376 | assert.strictEqual(0, config.currentRetryAttempt); 377 | scope.done(); 378 | return; 379 | } 380 | 381 | assert.fail('Expected to throw'); 382 | }); 383 | 384 | it('should allow configuring backoffType', async () => { 385 | const scope = nock(url) 386 | .get('/') 387 | .replyWithError( 388 | Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), 389 | ); 390 | interceptorId = rax.attach(); 391 | const config: AxiosRequestConfig = { 392 | url, 393 | raxConfig: { backoffType: 'exponential' }, 394 | }; 395 | try { 396 | await axios(config); 397 | } catch (error) { 398 | const axiosError = error as AxiosError; 399 | const config = rax.getConfig(axiosError); 400 | assert.ok(config); 401 | assert.strictEqual(config.backoffType, 'exponential'); 402 | scope.done(); 403 | return; 404 | } 405 | 406 | assert.fail('Expected to throw'); 407 | }); 408 | 409 | it('should notify on retry attempts', async () => { 410 | const scopes = [ 411 | nock(url).get('/').reply(500), 412 | nock(url).get('/').reply(200, 'toast'), 413 | ]; 414 | interceptorId = rax.attach(); 415 | let flipped = false; 416 | const config: RaxConfig = { 417 | url, 418 | raxConfig: { 419 | async onRetryAttempt(error) { 420 | const config = rax.getConfig(error); 421 | assert.ok(config); 422 | assert.strictEqual(config.currentRetryAttempt, 1); 423 | flipped = true; 424 | }, 425 | }, 426 | }; 427 | await axios(config); 428 | assert.strictEqual(flipped, true); 429 | for (const s of scopes) { 430 | s.done(); 431 | } 432 | }); 433 | 434 | it('should notify on retry attempts as a promise', async () => { 435 | const scopes = [ 436 | nock(url).get('/').reply(500), 437 | nock(url).get('/').reply(200, 'toast'), 438 | ]; 439 | interceptorId = rax.attach(); 440 | let flipped = false; 441 | const config: RaxConfig = { 442 | url, 443 | raxConfig: { 444 | async onRetryAttempt(error) { 445 | return new Promise((resolve) => { 446 | const config = rax.getConfig(error); 447 | assert.ok(config); 448 | assert.strictEqual(config.currentRetryAttempt, 1); 449 | flipped = true; 450 | resolve(undefined); 451 | }); 452 | }, 453 | }, 454 | }; 455 | await axios(config); 456 | assert.strictEqual(flipped, true); 457 | for (const s of scopes) { 458 | s.done(); 459 | } 460 | }); 461 | 462 | it('should support overriding the shouldRetry method', async () => { 463 | const scope = nock(url).get('/').reply(500); 464 | interceptorId = rax.attach(); 465 | const config: RaxConfig = { 466 | url, 467 | raxConfig: { 468 | shouldRetry(error) { 469 | rax.getConfig(error); 470 | return false; 471 | }, 472 | }, 473 | }; 474 | try { 475 | await axios(config); 476 | } catch (error) { 477 | const axiosError = error as AxiosError; 478 | const config = rax.getConfig(axiosError); 479 | assert.ok(config); 480 | assert.strictEqual(config.currentRetryAttempt, 0); 481 | scope.done(); 482 | return; 483 | } 484 | 485 | assert.fail('Expected to throw'); 486 | }); 487 | 488 | it('should respect retry limit when using shouldRetry', async () => { 489 | // This test reproduces issue #117 490 | // When shouldRetry is provided along with retry count, 491 | // the retry count should still be respected 492 | const scope = nock(url).get('/').times(3).reply(500); 493 | interceptorId = rax.attach(); 494 | let retryCount = 0; 495 | const config: RaxConfig = { 496 | url, 497 | raxConfig: { 498 | retry: 2, // Should only retry 2 times 499 | shouldRetry(_error) { 500 | retryCount++; 501 | // Always return true to retry (simulating user's condition check) 502 | return true; 503 | }, 504 | }, 505 | }; 506 | try { 507 | await axios(config); 508 | } catch (error) { 509 | const axiosError = error as AxiosError; 510 | const config = rax.getConfig(axiosError); 511 | assert.ok(config); 512 | // Should have retried exactly 2 times, not more 513 | assert.strictEqual(config.currentRetryAttempt, 2); 514 | // shouldRetry should have been called 2 times: 515 | // once after initial failure, and once after the first retry failure 516 | // (not called after second retry because retry limit was reached) 517 | assert.strictEqual(retryCount, 2); 518 | scope.done(); 519 | return; 520 | } 521 | 522 | assert.fail('Expected to throw'); 523 | }); 524 | 525 | it('should retry on ENOTFOUND', async () => { 526 | const scopes = [ 527 | nock(url) 528 | .get('/') 529 | .replyWithError( 530 | Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' }), 531 | ), 532 | nock(url).get('/').reply(200, 'oatmeal'), 533 | ]; 534 | interceptorId = rax.attach(); 535 | const result = await axios.get(url); 536 | assert.strictEqual(result.data, 'oatmeal'); 537 | for (const s of scopes) { 538 | s.done(); 539 | } 540 | }); 541 | 542 | it('should retry on ETIMEDOUT', async () => { 543 | const scopes = [ 544 | nock(url) 545 | .get('/') 546 | .replyWithError( 547 | Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), 548 | ), 549 | nock(url).get('/').reply(200, 'bacon'), 550 | ]; 551 | interceptorId = rax.attach(); 552 | const result = await axios.get(url); 553 | assert.strictEqual(result.data, 'bacon'); 554 | for (const s of scopes) { 555 | s.done(); 556 | } 557 | }); 558 | 559 | it('should not retry network errors when retry is 0', async () => { 560 | const scope = nock(url) 561 | .get('/') 562 | .replyWithError( 563 | Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), 564 | ); 565 | interceptorId = rax.attach(); 566 | const config = { url, raxConfig: { retry: 0 } }; 567 | try { 568 | await axios(config); 569 | } catch (error) { 570 | const axiosError = error as AxiosError; 571 | const config = rax.getConfig(axiosError); 572 | assert.ok(config); 573 | assert.strictEqual(config.currentRetryAttempt, 0); 574 | scope.done(); 575 | return; 576 | } 577 | 578 | assert.fail('Expected to throw'); 579 | }); 580 | 581 | it('should reset error counter upon success', async () => { 582 | const scopes = [ 583 | nock(url).get('/').times(2).reply(500), 584 | nock(url).get('/').reply(200, 'milk'), 585 | nock(url).get('/').reply(500), 586 | nock(url).get('/').reply(200, 'toast'), 587 | ]; 588 | interceptorId = rax.attach(); 589 | const cfg: rax.RaxConfig = { url, raxConfig: { retry: 2 } }; 590 | const result = await axios(cfg); 591 | assert.strictEqual(result.data, 'milk'); 592 | const result2 = await axios(cfg); 593 | assert.strictEqual(result2.data, 'toast'); 594 | for (const s of scopes) { 595 | s.done(); 596 | } 597 | }); 598 | 599 | it('should ignore requests that have been canceled', async () => { 600 | const scopes = [ 601 | nock(url).get('/').times(2).delay(5).reply(500), 602 | nock(url).get('/').reply(200, 'toast'), 603 | ]; 604 | interceptorId = rax.attach(); 605 | try { 606 | // eslint-disable-next-line import/no-named-as-default-member 607 | const source = axios.CancelToken.source(); 608 | const cfg: rax.RaxConfig = { 609 | url, 610 | raxConfig: { retry: 2 }, 611 | cancelToken: source.token, 612 | }; 613 | const request = axios(cfg); 614 | setTimeout(() => { 615 | source.cancel(); 616 | }, 10); 617 | await request; 618 | throw new Error('The canceled request completed.'); 619 | } catch (error) { 620 | // eslint-disable-next-line import/no-named-as-default-member 621 | assert.strictEqual(axios.isCancel(error), true); 622 | } 623 | 624 | assert.strictEqual(scopes[1].isDone(), false); 625 | }); 626 | 627 | it('should accept 0 for config.retryDelay', async () => { 628 | const scope = nock(url) 629 | .get('/') 630 | .replyWithError( 631 | Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), 632 | ); 633 | interceptorId = rax.attach(); 634 | const config: AxiosRequestConfig = { 635 | url, 636 | raxConfig: { retryDelay: 0 }, 637 | }; 638 | try { 639 | await axios(config); 640 | } catch (error) { 641 | const axiosError = error as AxiosError; 642 | const config = rax.getConfig(axiosError); 643 | assert.ok(config); 644 | assert.strictEqual(config.retryDelay, 0); 645 | scope.done(); 646 | return; 647 | } 648 | 649 | assert.fail('Expected to throw'); 650 | }); 651 | 652 | it('should accept 0 for config.retry', async () => { 653 | const scope = nock(url) 654 | .get('/') 655 | .replyWithError( 656 | Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), 657 | ); 658 | interceptorId = rax.attach(); 659 | const config: AxiosRequestConfig = { 660 | url, 661 | raxConfig: { retry: 0 }, 662 | }; 663 | try { 664 | await axios(config); 665 | } catch (error) { 666 | const axiosError = error as AxiosError; 667 | const config = rax.getConfig(axiosError); 668 | assert.ok(config); 669 | assert.strictEqual(config.retry, 0); 670 | scope.done(); 671 | return; 672 | } 673 | 674 | assert.fail('Expected to throw'); 675 | }); 676 | 677 | // Short timeout to trip test if delay longer than expected 678 | it( 679 | 'should retry with Retry-After header in seconds', 680 | { timeout: 1000 }, 681 | async () => { 682 | const scopes = [ 683 | nock(url).get('/').reply(429, undefined, { 684 | 'Retry-After': '5', 685 | }), 686 | nock(url).get('/').reply(200, 'toast'), 687 | ]; 688 | interceptorId = rax.attach(); 689 | const { promise, resolve } = pDefer(); 690 | vitest.useFakeTimers({ 691 | shouldAdvanceTime: true, // Otherwise interferes with nock 692 | }); 693 | const axiosPromise = axios({ 694 | url, 695 | raxConfig: { 696 | onError: resolve, 697 | retryDelay: 10_000, // Higher default to ensure Retry-After is used 698 | backoffType: 'static', 699 | }, 700 | }); 701 | await promise; 702 | await vitest.advanceTimersByTimeAsync(5000); // Advance clock by expected retry delay 703 | const result = await axiosPromise; 704 | assert.strictEqual(result.data, 'toast'); 705 | for (const s of scopes) { 706 | s.done(); 707 | } 708 | }, 709 | ); 710 | 711 | it( 712 | 'should retry with Retry-After header in http datetime', 713 | { timeout: 1000 }, 714 | async () => { 715 | const scopes = [ 716 | nock(url).get('/').reply(429, undefined, { 717 | 'Retry-After': 'Thu, 01 Jan 1970 00:00:05 UTC', 718 | }), 719 | nock(url).get('/').reply(200, 'toast'), 720 | ]; 721 | interceptorId = rax.attach(); 722 | const { promise, resolve } = pDefer(); 723 | vitest.useFakeTimers({ 724 | now: new Date('1970-01-01T00:00:00Z').getTime(), // Set the clock to the epoch 725 | }); 726 | const axiosPromise = axios({ 727 | url, 728 | raxConfig: { 729 | onError: resolve, 730 | backoffType: 'static', 731 | retryDelay: 10_000, 732 | }, 733 | }); 734 | await promise; 735 | await vitest.advanceTimersByTimeAsync(5000); 736 | const result = await axiosPromise; 737 | assert.strictEqual(result.data, 'toast'); 738 | for (const s of scopes) { 739 | s.done(); 740 | } 741 | }, 742 | ); 743 | 744 | it('should not retry if Retry-After greater than maxRetryAfter', async () => { 745 | const scopes = [ 746 | nock(url).get('/').reply(429, undefined, { 'Retry-After': '2' }), 747 | nock(url).get('/').reply(200, 'toast'), 748 | ]; 749 | interceptorId = rax.attach(); 750 | const cfg: rax.RaxConfig = { url, raxConfig: { maxRetryAfter: 1000 } }; 751 | await assert.rejects(axios(cfg)); 752 | assert.strictEqual(scopes[1].isDone(), false); 753 | }); 754 | 755 | it('should not retry if Retry-After is invalid', async () => { 756 | const scopes = [ 757 | nock(url).get('/').reply(429, undefined, { 'Retry-After': 'foo' }), 758 | nock(url).get('/').reply(200, 'toast'), 759 | ]; 760 | interceptorId = rax.attach(); 761 | const cfg: rax.RaxConfig = { url, raxConfig: { maxRetryAfter: 1000 } }; 762 | await assert.rejects(axios(cfg)); 763 | assert.strictEqual(scopes[1].isDone(), false); 764 | }); 765 | 766 | // Short timeout to trip test if delay longer than expected 767 | it('should use maxRetryDelay', { timeout: 1000 }, async () => { 768 | const scopes = [ 769 | nock(url).get('/').reply(429, undefined), 770 | nock(url).get('/').reply(200, 'toast'), 771 | ]; 772 | interceptorId = rax.attach(); 773 | const { promise, resolve } = pDefer(); 774 | vitest.useFakeTimers({ 775 | shouldAdvanceTime: true, // Otherwise interferes with nock 776 | }); 777 | const axiosPromise = axios({ 778 | url, 779 | raxConfig: { 780 | onError: resolve, 781 | retryDelay: 10_000, // Higher default to ensure maxRetryDelay is used 782 | maxRetryDelay: 5000, 783 | backoffType: 'exponential', 784 | }, 785 | }); 786 | await promise; 787 | await vitest.advanceTimersByTimeAsync(5000); // Advance clock by expected retry delay 788 | const result = await axiosPromise; 789 | assert.strictEqual(result.data, 'toast'); 790 | for (const s of scopes) { 791 | s.done(); 792 | } 793 | }); 794 | 795 | it('should handle promise rejection in onRetryAttempt', async () => { 796 | const scope = nock(url) 797 | .get('/') 798 | .replyWithError( 799 | Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' }), 800 | ); 801 | interceptorId = rax.attach(); 802 | const config: RaxConfig = { 803 | url, 804 | raxConfig: { 805 | onRetryAttempt(error) { 806 | return new Promise((resolve, reject) => { 807 | // User wants to abort retry for ENOTFOUND errors 808 | if ('code' in error && error.code === 'ENOTFOUND') { 809 | reject(new Error('Not retrying ENOTFOUND')); 810 | } else { 811 | resolve(); 812 | } 813 | }); 814 | }, 815 | }, 816 | }; 817 | try { 818 | await axios(config); 819 | assert.fail('Expected to throw'); 820 | } catch (error) { 821 | // Should catch the rejection from onRetryAttempt 822 | assert.ok(error); 823 | assert.strictEqual((error as Error).message, 'Not retrying ENOTFOUND'); 824 | scope.done(); 825 | } 826 | }); 827 | it('should use retryDelay as base multiplier for exponential backoff', async () => { 828 | const requesttimes: bigint[] = []; 829 | const scopes = [ 830 | nock(url) 831 | .get('/') 832 | .reply(() => { 833 | requesttimes.push(process.hrtime.bigint()); 834 | return [500]; 835 | }), 836 | nock(url) 837 | .get('/') 838 | .reply(() => { 839 | requesttimes.push(process.hrtime.bigint()); 840 | return [200, 'success']; 841 | }), 842 | ]; 843 | 844 | interceptorId = rax.attach(); 845 | const result = await axios({ 846 | url, 847 | raxConfig: { 848 | backoffType: 'exponential', 849 | retryDelay: 1000, // Use 1000ms as base instead of default 100ms 850 | }, 851 | }); 852 | 853 | assert.strictEqual(result.data, 'success'); 854 | for (const s of scopes) { 855 | s.done(); 856 | } 857 | 858 | assert.strictEqual(requesttimes.length, 2); 859 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 860 | 861 | // With retryDelay=1000, first retry delay should be: 862 | // ((2^1 - 1) / 2) * 1000 = 0.5 * 1000 = 500ms 863 | // Test with tolerance 864 | assert.ok( 865 | delayInSeconds < 0.55 && delayInSeconds > 0.45, 866 | `unexpected delay: ${delayInSeconds.toFixed(3)} s (expected ~0.5s)`, 867 | ); 868 | }); 869 | 870 | it('should apply full jitter to exponential backoff', async () => { 871 | const requesttimes: bigint[] = []; 872 | const scopes = [ 873 | nock(url) 874 | .get('/') 875 | .reply(() => { 876 | requesttimes.push(process.hrtime.bigint()); 877 | return [500]; 878 | }), 879 | nock(url) 880 | .get('/') 881 | .reply(() => { 882 | requesttimes.push(process.hrtime.bigint()); 883 | return [200, 'success']; 884 | }), 885 | ]; 886 | 887 | interceptorId = rax.attach(); 888 | const result = await axios({ 889 | url, 890 | raxConfig: { 891 | backoffType: 'exponential', 892 | jitter: 'full', 893 | retryDelay: 1000, 894 | }, 895 | }); 896 | 897 | assert.strictEqual(result.data, 'success'); 898 | for (const s of scopes) { 899 | s.done(); 900 | } 901 | 902 | assert.strictEqual(requesttimes.length, 2); 903 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 904 | 905 | // With full jitter, delay should be random between 0 and base delay (0.5s) 906 | // So it should be less than 0.5s but greater than 0 907 | assert.ok( 908 | delayInSeconds >= 0 && delayInSeconds < 0.55, 909 | `unexpected delay with full jitter: ${delayInSeconds.toFixed(3)} s (expected 0-0.5s)`, 910 | ); 911 | }); 912 | 913 | it('should apply equal jitter to exponential backoff', async () => { 914 | const requesttimes: bigint[] = []; 915 | const scopes = [ 916 | nock(url) 917 | .get('/') 918 | .reply(() => { 919 | requesttimes.push(process.hrtime.bigint()); 920 | return [500]; 921 | }), 922 | nock(url) 923 | .get('/') 924 | .reply(() => { 925 | requesttimes.push(process.hrtime.bigint()); 926 | return [200, 'success']; 927 | }), 928 | ]; 929 | 930 | interceptorId = rax.attach(); 931 | const result = await axios({ 932 | url, 933 | raxConfig: { 934 | backoffType: 'exponential', 935 | jitter: 'equal', 936 | retryDelay: 1000, 937 | }, 938 | }); 939 | 940 | assert.strictEqual(result.data, 'success'); 941 | for (const s of scopes) { 942 | s.done(); 943 | } 944 | 945 | assert.strictEqual(requesttimes.length, 2); 946 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 947 | 948 | // With equal jitter, delay should be between 0.25s (half of 0.5s) and 0.5s 949 | assert.ok( 950 | delayInSeconds >= 0.25 && delayInSeconds < 0.55, 951 | `unexpected delay with equal jitter: ${delayInSeconds.toFixed(3)} s (expected 0.25-0.5s)`, 952 | ); 953 | }); 954 | 955 | it('should not apply jitter to static backoff', async () => { 956 | const requesttimes: bigint[] = []; 957 | const scopes = [ 958 | nock(url) 959 | .get('/') 960 | .reply(() => { 961 | requesttimes.push(process.hrtime.bigint()); 962 | return [500]; 963 | }), 964 | nock(url) 965 | .get('/') 966 | .reply(() => { 967 | requesttimes.push(process.hrtime.bigint()); 968 | return [200, 'success']; 969 | }), 970 | ]; 971 | 972 | interceptorId = rax.attach(); 973 | const result = await axios({ 974 | url, 975 | raxConfig: { 976 | backoffType: 'static', 977 | jitter: 'full', // Should be ignored for static backoff 978 | retryDelay: 500, 979 | }, 980 | }); 981 | 982 | assert.strictEqual(result.data, 'success'); 983 | for (const s of scopes) { 984 | s.done(); 985 | } 986 | 987 | assert.strictEqual(requesttimes.length, 2); 988 | const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; 989 | 990 | // Static backoff should use exactly retryDelay regardless of jitter setting 991 | assert.ok( 992 | delayInSeconds >= 0.45 && delayInSeconds < 0.55, 993 | `unexpected delay: ${delayInSeconds.toFixed(3)} s (expected ~0.5s)`, 994 | ); 995 | }); 996 | 997 | it('should respect maxRetryDelay with jitter', async () => { 998 | const scopes = [ 999 | nock(url).get('/').reply(500), 1000 | nock(url).get('/').reply(200, 'toast'), 1001 | ]; 1002 | interceptorId = rax.attach(); 1003 | const { promise, resolve } = pDefer(); 1004 | vitest.useFakeTimers({ 1005 | shouldAdvanceTime: true, 1006 | }); 1007 | const axiosPromise = axios({ 1008 | url, 1009 | raxConfig: { 1010 | onError: resolve, 1011 | retryDelay: 10_000, 1012 | maxRetryDelay: 2000, 1013 | backoffType: 'exponential', 1014 | jitter: 'full', 1015 | }, 1016 | }); 1017 | await promise; 1018 | // Even with full jitter, delay should not exceed maxRetryDelay 1019 | await vitest.advanceTimersByTimeAsync(2000); 1020 | const result = await axiosPromise; 1021 | assert.strictEqual(result.data, 'toast'); 1022 | for (const s of scopes) { 1023 | s.done(); 1024 | } 1025 | }); 1026 | 1027 | it('should collect all errors in the errors array', async () => { 1028 | const scopes = [ 1029 | nock(url).post('/').reply(500, 'Internal Server Error'), 1030 | nock(url).post('/').reply(503, 'Service Unavailable'), 1031 | nock(url).post('/').reply(502, 'Bad Gateway'), 1032 | nock(url).post('/').reply(504, 'Gateway Timeout'), 1033 | ]; 1034 | 1035 | interceptorId = rax.attach(); 1036 | try { 1037 | await axios.post( 1038 | url, 1039 | { data: 'test' }, 1040 | { 1041 | raxConfig: { 1042 | httpMethodsToRetry: ['POST'], 1043 | retry: 3, 1044 | retryDelay: 1, 1045 | }, 1046 | }, 1047 | ); 1048 | assert.fail('Expected to throw'); 1049 | } catch (error) { 1050 | const axiosError = error as AxiosError; 1051 | const config = rax.getConfig(axiosError); 1052 | 1053 | // Verify all scopes were called 1054 | for (const s of scopes) { 1055 | s.done(); 1056 | } 1057 | 1058 | // Check that errors array exists and has all 4 errors (initial + 3 retries) 1059 | assert.ok(config?.errors, 'errors array should exist'); 1060 | assert.strictEqual( 1061 | config.errors.length, 1062 | 4, 1063 | 'should have 4 errors (initial + 3 retries)', 1064 | ); 1065 | 1066 | // Verify the status codes are captured in order 1067 | assert.strictEqual(config.errors[0].response?.status, 500); 1068 | assert.strictEqual(config.errors[1].response?.status, 503); 1069 | assert.strictEqual(config.errors[2].response?.status, 502); 1070 | assert.strictEqual(config.errors[3].response?.status, 504); 1071 | 1072 | // Verify that the last error is the same as the thrown error 1073 | assert.strictEqual( 1074 | config.errors[3].response?.status, 1075 | axiosError.response?.status, 1076 | ); 1077 | } 1078 | }); 1079 | 1080 | it('should collect errors even when using shouldRetry', async () => { 1081 | const scopes = [ 1082 | nock(url).get('/').reply(500, 'Error 1'), 1083 | nock(url).get('/').reply(500, 'Error 2'), 1084 | ]; 1085 | 1086 | interceptorId = rax.attach(); 1087 | try { 1088 | await axios({ 1089 | url, 1090 | raxConfig: { 1091 | retry: 3, 1092 | retryDelay: 1, 1093 | shouldRetry: (error: AxiosError) => { 1094 | // Custom logic: only retry once 1095 | const config = rax.getConfig(error); 1096 | return (config?.currentRetryAttempt || 0) < 1; 1097 | }, 1098 | }, 1099 | }); 1100 | assert.fail('Expected to throw'); 1101 | } catch (error) { 1102 | const axiosError = error as AxiosError; 1103 | const config = rax.getConfig(axiosError); 1104 | 1105 | // Verify both scopes were called 1106 | for (const s of scopes) { 1107 | s.done(); 1108 | } 1109 | 1110 | // Should have 2 errors (initial + 1 retry due to shouldRetry limiting) 1111 | assert.ok(config?.errors, 'errors array should exist'); 1112 | assert.strictEqual( 1113 | config.errors.length, 1114 | 2, 1115 | 'should have 2 errors (initial + 1 retry)', 1116 | ); 1117 | } 1118 | }); 1119 | 1120 | it('should track retriesRemaining correctly', async () => { 1121 | const scopes = [ 1122 | nock(url).get('/').reply(500), 1123 | nock(url).get('/').reply(500), 1124 | nock(url).get('/').reply(500), 1125 | ]; 1126 | interceptorId = rax.attach(); 1127 | const retriesRemainingValues: number[] = []; 1128 | const config: RaxConfig = { 1129 | url, 1130 | raxConfig: { 1131 | retry: 3, 1132 | retryDelay: 1, 1133 | async onRetryAttempt(error) { 1134 | const config = rax.getConfig(error); 1135 | assert.ok(config); 1136 | assert.ok( 1137 | config.retriesRemaining !== undefined, 1138 | 'retriesRemaining should be defined', 1139 | ); 1140 | retriesRemainingValues.push(config.retriesRemaining); 1141 | }, 1142 | }, 1143 | }; 1144 | try { 1145 | await axios(config); 1146 | assert.fail('Expected to throw'); 1147 | } catch (error) { 1148 | const axiosError = error as AxiosError; 1149 | const config = rax.getConfig(axiosError); 1150 | assert.ok(config); 1151 | assert.strictEqual(config.currentRetryAttempt, 3); 1152 | assert.strictEqual(config.retriesRemaining, 0); 1153 | // Verify retriesRemaining decreased correctly: [2, 1, 0] 1154 | assert.deepStrictEqual(retriesRemainingValues, [2, 1, 0]); 1155 | for (const s of scopes) { 1156 | s.done(); 1157 | } 1158 | } 1159 | }); 1160 | }); 1161 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "declaration": true, 5 | "sourceMap": true, 6 | "target": "ES2022", 7 | "rootDir": ".", 8 | "outDir": "build", 9 | "moduleResolution": "node", 10 | "module": "ES2022", 11 | "esModuleInterop": true 12 | }, 13 | "include": [ 14 | "src/*.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | /** @type {import('vite').UserConfig} */ 4 | export default defineConfig({ 5 | test: { 6 | include: ['test/**/*.ts', 'test/**/*.cjs'], 7 | testTimeout: 60_000, 8 | globals: true, 9 | coverage: { 10 | include: ['src/**/*.ts'], 11 | reporter: ['text-summary', 'lcov'], 12 | reportsDirectory: 'coverage' 13 | } 14 | } 15 | }); 16 | --------------------------------------------------------------------------------