├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── images ├── params-options-autocomplete.png └── params-options-hint.png ├── package-lock.json ├── package.json ├── src ├── .clasp.json ├── FetchApp.js ├── appsscript.json └── package.json └── tests ├── mocks ├── UrlFetchApp.js ├── Utilities.js └── console.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2023-12-06 - 1.0.1 4 | 5 | - Update JSDoc descriptions. 6 | 7 | ## 2023-12-03 - 1.0.0 8 | 9 | - First public release. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2023 Dataful.Tech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FetchApp 2 | 3 | FetchApp extends the functionality of the built-in UrlFetchApp in Google Apps Script with advanced features like: 4 | 5 | 1. **Optional Retries:** Depending on the response code received. 6 | 2. **Delay Strategies:** Choose between linear or exponential delay between retries. 7 | 3. **Custom Callbacks:** Implement callbacks on failed attempts for tailored actions and logic. 8 | 4. **Enhanced Type Hints:** Improved hints for UrlFetchApp's `params` argument. 9 | 5. **Automatic Logging:** Logs failed attempts automatically. 10 | 11 | FetchApp is a [Dataful.Tech](https://dataful.tech) project. 12 | 13 | ## Setup 14 | 15 | You can set up FetchApp in two ways: 16 | 17 | 1. **Copy the code** from `src/FetchApp.js` to your project. In addition to having control over the code and being able to modify it, you will also get better type hints (see section Type Hints and Autocomplete below). 18 | 2. **Connect as a library**: use the library id `1-U70HU_cxKSdTe4U6leREd4wlA7Wey5zOPE5eDF38WgBPrOoi-cztxlb`. 19 | 20 | ## Usage 21 | 22 | There are two ways to use FetchApp. 23 | 24 | ### Drop-in Replacement for UrlFetchApp 25 | 26 | Replace `UrlFetchApp` with `FetchApp` directly. **Note:** By default, FetchApp sets `muteHttpExceptions: true` in `params` unless explicitly specified otherwise. 27 | 28 | ```js 29 | // `url` and `params` are defined elsewhere 30 | 31 | // regular UrlFetchApp 32 | const response1 = UrlFetchApp.fetch(url, params); 33 | 34 | // FetchApp without configuration is a pass-through to UrlFetchApp 35 | const response2 = FetchApp.fetch(url, params); 36 | 37 | // FetchApp with retries and delay enabled 38 | const config = { 39 | maxRetries: 5, 40 | successCodes: [200], 41 | delay: 500, 42 | }; 43 | const response3 = FetchApp.fetch(url, params, config); 44 | 45 | // If there are no `params`, pass an empty object 46 | const response4 = FetchApp.fetch(url, {}, config); 47 | ``` 48 | 49 | ### Configurable Client 50 | 51 | If you need to use FetchApp multiple times, you can initiate a client to reuse the configuration: 52 | 53 | ```js 54 | // FetchApp with retries and delay enabled 55 | const config = { 56 | maxRetries: 5, 57 | retryCodes: [500, 502, 503, 504], 58 | delay: 500, 59 | }; 60 | 61 | const client = FetchApp.getClient(config); 62 | 63 | // All client's fetch calls will use this config 64 | const response1 = client.fetch(url, params); 65 | 66 | // Partially modify the config for a specific request 67 | const response2 = client.fetch(url, params, { successCodes: [200] }); 68 | ``` 69 | 70 | ## Configuration 71 | 72 | FetchApp offers a variety of customizable configuration options to fine-tune its behavior: 73 | 74 | 1. Retries: 75 | - `maxRetries`: Defines the maximum number of retry attempts. Type: Number. Default: `0`. The total number of requests can be up to `maxRetries` + 1, including the original request. 76 | - `successCodes`: Specifies response codes that indicate a successful response, halting further retries. Type: Array of numbers. Default: `[]`. _Note:_ When set, `retryCodes` are disregarded. 77 | - `retryCodes`: Lists response codes that trigger a retry, suggesting the initial request failed. Type: Array of numbers. Default: `[]`. 78 | 2. Delays between retries: 79 | - `delay`: Sets the waiting period between retries in milliseconds. Type: Number. Default: `0` (immediate retry). 80 | - `delayFactor`: Determines the exponential increase in delay. Type: Number. Default: `1` (constant delay). Delay is calculated as delay multiplied by delayFactor raised to the power of the retry attempt number. _Example:_ With a delay of 1000ms and delayFactor of 2, delays would be 1, 2, 4, 8, 16 seconds, and so on. 81 | - `maxDelay`: Caps the maximum delay in milliseconds. Type: Number. Default: `Infinity`. This cap prevents excessively long delays. 82 | 3. Callbacks: 83 | - `onRequestFailure`: Invoked upon a request failure. Type: Function. Default: No action. This function receives an object containing the FetchApp context, including the request's **url** and **params**, the **response**, the FetchApp **config** for the request, and the number of **retries**. 84 | - `onAllRequestsFailure`: Triggered when all attempts to make a request fail. Type: Function. Default: No action. It receives the same context as `onRequestFailure`, with the **response** being the last failed attempt. 85 | 86 | ## Limitations 87 | 88 | FetchApp sets `params.muteHttpExceptions: true` unless it is explicitly specified otherwise. If you need to throw an exception, for example, on certain response codes, you can do it via callbacks: 89 | 90 | ```js 91 | // Throw an exception on 401 or 403 response codes 92 | const stopRequests = ({ url, response }) => { 93 | const responseCode = response.getResponseCode(); 94 | if ([401, 403].includes(responseCode)) { 95 | throw new Error(`Received ${responseCode} when accessing ${url}`); 96 | } 97 | }; 98 | 99 | const config = { 100 | successCodes: [200], 101 | maxRetries: 5, 102 | delay: 500, 103 | onRequestFailure: stopRequests, 104 | }; 105 | 106 | const response = FetchApp.fetch(url, params, config); 107 | ``` 108 | 109 | ## Type Hints and Autocomplete 110 | 111 | FetchApp leverages Google Apps Script IDE's limited JSDoc annotations support. If you copy the full code of the library, you will get complete type hints and autocomplete for all variables, including `params` and `config`. 112 | 113 |
114 | 115 |
116 | 117 | Unfortunately, the IDE does not recognize most JSDoc annotations from the libraries, and only text description of the fields is visible. All options are also duplicated in the field descriptions to mitigate this limitation and for easier reference. 118 | 119 |
120 | 121 |
122 | 123 | ## Versioning 124 | 125 | This project follows standard `MAJOR.MINOR.PATCH` semantic versioning. Breaking changes may be introduced in new major versions. 126 | 127 | ## License 128 | 129 | FetchApp is available under the MIT license. 130 | 131 | ## Contribution 132 | 133 | Contributions are welcome. Feel free to submit PRs or issues on GitHub for any suggestions or issues. 134 | -------------------------------------------------------------------------------- /images/params-options-autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dataful-tech/fetch-app/f7419d5a1a0c9849c86726cf513562b5b1137b90/images/params-options-autocomplete.png -------------------------------------------------------------------------------- /images/params-options-hint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dataful-tech/fetch-app/f7419d5a1a0c9849c86726cf513562b5b1137b90/images/params-options-hint.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-app", 3 | "version": "1.0.1", 4 | "description": "Google Apps Script library that extends built-in UrlFetchApp with retries, callbacks, and more.", 5 | "author": "Stan ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/dataful-tech/fetch-app.git" 9 | }, 10 | "license": "MIT", 11 | "private": true, 12 | "bugs": { 13 | "url": "https://github.com/dataful-tech/fetch-app/issues" 14 | }, 15 | "homepage": "https://github.com/dataful-tech/fetch-app", 16 | "scripts": { 17 | "test": "jest" 18 | }, 19 | "devDependencies": { 20 | "@google/clasp": "^2.4.2", 21 | "@types/google-apps-script": "^1.0.78", 22 | "jest": "^29.7.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/.clasp.json: -------------------------------------------------------------------------------- 1 | { "scriptId": "1-U70HU_cxKSdTe4U6leREd4wlA7Wey5zOPE5eDF38WgBPrOoi-cztxlb" } 2 | -------------------------------------------------------------------------------- /src/FetchApp.js: -------------------------------------------------------------------------------- 1 | // MIT License 2 | 3 | // Copyright 2023 Dataful.Tech 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the “Software”), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included 13 | // in all copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /** 24 | * @typedef {Object} FetchAppConfig 25 | * @property {number} maxRetries Maximum number of retry attempts. Default: `0`. 26 | * @property {Array.} successCodes HTTP status codes considered as successful. Default: `[]`. 27 | * @property {Array.} retryCodes HTTP status codes that trigger a retry. Default: `[]`. 28 | * @property {number} delayFactor Factor to calculate the exponential backoff delay. Default: `1` (constant delay). 29 | * @property {number} delay Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 30 | * @property {number} maxDelay Maximum delay between retries in milliseconds. Default: `Infinity`. 31 | * @property {Function} onRequestFailure Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 32 | * @property {Function} onAllRequestsFailure Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 33 | */ 34 | 35 | /** 36 | * @typedef {Object} FetchParams 37 | * @property {string} [method] The HTTP method for the request: 'get', 'delete', 'patch', 'post', or 'put'. 38 | * @property {Object.} [headers] A map of headers for the request. 39 | * @property {string} [contentType] The MIME type of the payload. 40 | * @property {string|BlobSource|byte[]} [payload] The payload (e.g., POST body) for the request. 41 | * @property {boolean} [muteHttpExceptions] If true, the fetch will not throw an exception on failure. 42 | * @property {string} [validateHttpsCertificates] If false, it will allow requests to sites with invalid certificates. 43 | * @property {boolean} [followRedirects] If true, the fetch will follow HTTP redirects. 44 | * @property {boolean} [useIntranet] If true, the fetch can access sites in the intranet. 45 | * @property {boolean} [escaping] If false, it will not automatically escape the payload. 46 | */ 47 | 48 | /** 49 | * Creates a new FetchApp instance with the given configuration. 50 | * @param {FetchAppConfig} config The configuration object for the FetchApp. 51 | * Supported options: 52 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 53 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 54 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 55 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 56 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 57 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 58 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 59 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 60 | * @returns {FetchApp} An instance of FetchApp. 61 | */ 62 | function getClient(config = {}) { 63 | return new FetchApp(config); 64 | } 65 | 66 | /** 67 | * Initiates an HTTP request using instance configuration. 68 | * @param {string} url The URL to fetch. 69 | * @param {FetchParams=} params Parameters for the request. 70 | * Supported options: 71 | * - **method** - The HTTP method for the request: 'get', 'delete', 'patch', 'post', or 'put'. 72 | * - **headers** - A map of headers for the request. 73 | * - **contentType** - The MIME type of the payload. 74 | * - **payload** - The payload (e.g., POST body) for the request. 75 | * - **muteHttpExceptions** - If true, the fetch will not throw an exception on failure. 76 | * - **validateHttpsCertificates** - If false, it will allow requests to sites with invalid certificates. 77 | * - **followRedirects** - If true, the fetch will follow HTTP redirects. 78 | * - **useIntranet** - If true, the fetch can access sites in the intranet. 79 | * - **escaping** - If false, it will not automatically escape the payload. 80 | * @param {FetchAppConfig=} config Additional config for the request. 81 | * Supported options: 82 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 83 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 84 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 85 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 86 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 87 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 88 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 89 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 90 | * @returns {UrlFetchApp.HTTPResponse|null} The response object or null if all retries fail. 91 | */ 92 | function fetch(url, params = {}, config = {}) { 93 | return FetchApp.fetch(url, params, config); 94 | } 95 | 96 | /** 97 | * Class representing a fetch application with automatic retries. 98 | * @property {FetchAppConfig=} config The configuration object for the FetchApp. 99 | * Supported options: 100 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 101 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 102 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 103 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 104 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 105 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 106 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 107 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 108 | * @class 109 | */ 110 | class FetchApp { 111 | /** 112 | * Creates an instance of FetchApp. 113 | * @param {FetchAppConfig=} config FetchApp configuration object. 114 | * Supported options: 115 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 116 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 117 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 118 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 119 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 120 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 121 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 122 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 123 | * @constructor 124 | */ 125 | constructor(config = {}) { 126 | /** 127 | * FetchApp configuration object. 128 | * Supported options: 129 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 130 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 131 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 132 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 133 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 134 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 135 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 136 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 137 | * @type {FetchAppConfig} 138 | * @public 139 | */ 140 | this.config = config; 141 | } 142 | 143 | /** 144 | * Initiates an HTTP request using instance configuration. 145 | * @param {string} url The URL to fetch. 146 | * @param {FetchParams=} params Parameters for the request. 147 | * Supported options: 148 | * - **method** - The HTTP method for the request: 'get', 'delete', 'patch', 'post', or 'put'. 149 | * - **headers** - A map of headers for the request. 150 | * - **contentType** - The MIME type of the payload. 151 | * - **payload** - The payload (e.g., POST body) for the request. 152 | * - **muteHttpExceptions** - If true, the fetch will not throw an exception on failure. 153 | * - **validateHttpsCertificates** - If false, it will allow requests to sites with invalid certificates. 154 | * - **followRedirects** - If true, the fetch will follow HTTP redirects. 155 | * - **useIntranet** - If true, the fetch can access sites in the intranet. 156 | * - **escaping** - If false, it will not automatically escape the payload. 157 | * @param {FetchAppConfig=} config Additional config for the request. 158 | * Supported options: 159 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 160 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 161 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 162 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 163 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 164 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 165 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 166 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 167 | * @returns {UrlFetchApp.HTTPResponse|null} The response object or null if all retries fail. 168 | */ 169 | fetch(url, params = {}, config = {}) { 170 | return FetchApp.fetch(url, params, { ...this.config, ...config }); 171 | } 172 | 173 | /** 174 | * Initiates an HTTP request using instance configuration. 175 | * @param {string} url The URL to fetch. 176 | * @param {FetchParams=} params Parameters for the request. 177 | * Supported options: 178 | * - **method** - The HTTP method for the request: 'get', 'delete', 'patch', 'post', or 'put'. 179 | * - **headers** - A map of headers for the request. 180 | * - **contentType** - The MIME type of the payload. 181 | * - **payload** - The payload (e.g., POST body) for the request. 182 | * - **muteHttpExceptions** - If true, the fetch will not throw an exception on failure. 183 | * - **validateHttpsCertificates** - If false, it will allow requests to sites with invalid certificates. 184 | * - **followRedirects** - If true, the fetch will follow HTTP redirects. 185 | * - **useIntranet** - If true, the fetch can access sites in the intranet. 186 | * - **escaping** - If false, it will not automatically escape the payload. 187 | * @param {FetchAppConfig=} config Additional config for the request. 188 | * Supported options: 189 | * - **maxRetries** - Maximum number of retry attempts. Default: `0`. 190 | * - **successCodes** - HTTP status codes considered as successful. Default: `[]`. 191 | * - **retryCodes** - HTTP status codes that trigger a retry. Default: `[]`. 192 | * - **delayFactor** - Factor to calculate the exponential delay. Default: `1`. 193 | * - **delay** - Delay between retries in milliseconds, modified by `delayFactor`. Default: `0`. 194 | * - **maxDelay** - Maximum delay between retries in milliseconds. Default: `Infinity`. 195 | * - **onRequestFailure** - Callback for each failed request. Default: `({ url, params, response, retries, config }) => {}`. 196 | * - **onAllRequestsFailure** - Callback when all retries fail. Default: `({ url, params, response, retries, config }) => {}`. 197 | * @returns {UrlFetchApp.HTTPResponse|null} The response object or null if all retries fail. 198 | */ 199 | static fetch(url, params = {}, config = {}) { 200 | let retries = 0; 201 | let response; 202 | 203 | const configMerged = this._getConfig(config); 204 | 205 | // Mute HTTP exceptions, if the param is not set explicitly. 206 | // If it is set to `false`, the user is responsible for handling the exceptions 207 | // and the retry logic will not be work. 208 | const paramsMerged = { 209 | muteHttpExceptions: true, 210 | ...params, 211 | }; 212 | 213 | while (true) { 214 | response = UrlFetchApp.fetch(url, paramsMerged); 215 | const responseCode = response.getResponseCode(); 216 | 217 | if ( 218 | configMerged.successCodes.includes(responseCode) || 219 | (configMerged.successCodes.length === 0 && 220 | !configMerged.retryCodes.includes(responseCode)) 221 | ) { 222 | return response; 223 | } 224 | 225 | console.log( 226 | `Request failed for ${url} with response code ${responseCode}. Attempt ${ 227 | retries + 1 228 | }` 229 | ); 230 | configMerged.onRequestFailure({ 231 | url, 232 | params: paramsMerged, 233 | response, 234 | retries, 235 | config: configMerged, 236 | }); 237 | 238 | // If we're going to retry the request, add a linear or exponential delay, depending on the config 239 | if (retries < configMerged.maxRetries) { 240 | const delay = Math.min( 241 | Math.pow(configMerged.delayFactor, retries) * configMerged.delay, 242 | configMerged.maxDelay 243 | ); 244 | Utilities.sleep(delay); 245 | } else { 246 | break; 247 | } 248 | 249 | retries++; 250 | } 251 | 252 | console.log(`Max retries reached for URL: ${url}`); 253 | configMerged.onAllRequestsFailure({ 254 | url, 255 | params: paramsMerged, 256 | response, 257 | retries, 258 | config: configMerged, 259 | }); 260 | return null; 261 | } 262 | 263 | static _getConfig(config = {}) { 264 | return { 265 | maxRetries: config.maxRetries || defaultConfig_.maxRetries, 266 | successCodes: config.successCodes || defaultConfig_.successCodes, 267 | retryCodes: config.retryCodes || defaultConfig_.retryCodes, 268 | delayFactor: config.delayFactor || defaultConfig_.delayFactor, 269 | delay: config.delay || defaultConfig_.delay, 270 | maxDelay: config.maxDelay || defaultConfig_.maxDelay, 271 | onRequestFailure: 272 | config.onRequestFailure || defaultConfig_.onRequestFailure, 273 | onAllRequestsFailure: 274 | config.onAllRequestsFailure || defaultConfig_.onAllRequestsFailure, 275 | ...config, 276 | }; 277 | } 278 | } 279 | 280 | const defaultConfig_ = { 281 | maxRetries: 0, 282 | successCodes: [], 283 | retryCodes: [], 284 | delayFactor: 1, 285 | delay: 0, 286 | maxDelay: Infinity, 287 | onRequestFailure: ({ url, params, response, retries, config }) => {}, 288 | onAllRequestsFailure: ({ url, params, response, retries, config }) => {}, 289 | }; 290 | 291 | // Export to allow unit testing 292 | if (typeof module === "object") { 293 | module.exports = { 294 | fetch, 295 | getClient, 296 | FetchApp, 297 | defaultConfig_, 298 | }; 299 | } 300 | -------------------------------------------------------------------------------- /src/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8" 6 | } -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/mocks/UrlFetchApp.js: -------------------------------------------------------------------------------- 1 | const urlFetchAppMock = { 2 | fetch: jest.fn((url, params) => { 3 | return { 4 | getContentText: () => `Mock response for URL: ${url}`, 5 | getResponseCode: () => 200, 6 | }; 7 | }), 8 | }; 9 | 10 | module.exports = urlFetchAppMock; -------------------------------------------------------------------------------- /tests/mocks/Utilities.js: -------------------------------------------------------------------------------- 1 | const Utilities = { 2 | sleep: jest.fn(), 3 | }; 4 | 5 | module.exports = Utilities; 6 | -------------------------------------------------------------------------------- /tests/mocks/console.js: -------------------------------------------------------------------------------- 1 | const console = { 2 | log: jest.fn(), 3 | }; 4 | 5 | module.exports = console; 6 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | const FetchApp = require("../src/FetchApp"); 2 | 3 | global.UrlFetchApp = require("./mocks/UrlFetchApp"); 4 | global.Utilities = require("./mocks/Utilities"); 5 | global.console = require("./mocks/console"); 6 | 7 | const url = "https://example.com/api/data"; 8 | const params = { method: "get" }; 9 | const paramsExpected = { 10 | ...params, 11 | muteHttpExceptions: true, 12 | }; 13 | 14 | describe("FetchApp tests", () => { 15 | beforeEach(() => { 16 | UrlFetchApp.fetch.mockClear(); 17 | Utilities.sleep.mockClear(); 18 | console.log.mockClear(); 19 | }); 20 | 21 | it("FetchApp.fetch without configuration, simple pass-through to UrlFetchApp", () => { 22 | const result = FetchApp.fetch(url, params); 23 | 24 | expect(result.getResponseCode()).toBe(200); 25 | expect(result.getContentText()).toContain("Mock response for URL"); 26 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 27 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 28 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 29 | expect(console.log).toHaveBeenCalledTimes(0); 30 | }); 31 | 32 | it("FetchApp.fetch without configuration and params, simple pass-through to UrlFetchApp", () => { 33 | const result = FetchApp.fetch(url); 34 | 35 | expect(result.getResponseCode()).toBe(200); 36 | expect(result.getContentText()).toContain("Mock response for URL"); 37 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 38 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 39 | muteHttpExceptions: true, 40 | }); 41 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 42 | expect(console.log).toHaveBeenCalledTimes(0); 43 | }); 44 | 45 | it("FetchApp.fetch with maxRetries, failed, returns null", () => { 46 | const config = { 47 | maxRetries: 3, 48 | retryCodes: [200], 49 | }; 50 | const result = FetchApp.fetch(url, params, config); 51 | 52 | expect(result).toBe(null); 53 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 54 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 55 | // 1 original request + 3 retries + 1 total fail message 56 | expect(console.log).toHaveBeenCalledTimes(5); 57 | }); 58 | 59 | it("FetchApp.fetch with success on the last retry, success codes only", () => { 60 | const config = { 61 | maxRetries: 3, 62 | successCodes: [200], 63 | }; 64 | UrlFetchApp.fetch 65 | .mockImplementationOnce(() => { 66 | return { 67 | getResponseCode: () => 500, 68 | }; 69 | }) 70 | .mockImplementationOnce(() => { 71 | return { 72 | getResponseCode: () => 500, 73 | }; 74 | }) 75 | .mockImplementationOnce(() => { 76 | return { 77 | getResponseCode: () => 500, 78 | }; 79 | }) 80 | .mockImplementationOnce(() => { 81 | return { 82 | getResponseCode: () => 200, 83 | getContentText: () => `Mock response for URL: ${url}`, 84 | }; 85 | }); 86 | const result = FetchApp.fetch(url, params, config); 87 | 88 | expect(result).not.toBe(null); 89 | expect(result.getResponseCode()).toBe(200); 90 | expect(result.getContentText()).toBe(`Mock response for URL: ${url}`); 91 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 92 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 93 | expect(Utilities.sleep).toHaveBeenCalledTimes(3); 94 | expect(Utilities.sleep).toHaveBeenCalledWith(0); 95 | expect(console.log).toHaveBeenCalledTimes(3); 96 | }); 97 | 98 | it("FetchApp.fetch with success on a retry, retry codes only", () => { 99 | const config = { 100 | maxRetries: 3, 101 | retryCodes: [500], 102 | }; 103 | UrlFetchApp.fetch 104 | .mockImplementationOnce(() => { 105 | return { 106 | getResponseCode: () => 500, 107 | }; 108 | }) 109 | .mockImplementationOnce(() => { 110 | return { 111 | getResponseCode: () => 200, 112 | getContentText: () => `Mock response for URL: ${url}`, 113 | }; 114 | }); 115 | const result = FetchApp.fetch(url, params, config); 116 | 117 | expect(result).not.toBe(null); 118 | expect(result.getResponseCode()).toBe(200); 119 | expect(result.getContentText()).toBe(`Mock response for URL: ${url}`); 120 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 121 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(2); 122 | expect(Utilities.sleep).toHaveBeenCalledTimes(1); 123 | 124 | expect(Utilities.sleep).toHaveBeenCalledWith(0); 125 | 126 | expect(console.log).toHaveBeenCalledTimes(1); 127 | }); 128 | 129 | it("FetchApp.fetch with exponential delay", () => { 130 | const config = { 131 | maxRetries: 5, 132 | retryCodes: [200], 133 | delayFactor: 2, 134 | delay: 100, 135 | }; 136 | const result = FetchApp.fetch(url, params, config); 137 | 138 | expect(result).toBe(null); 139 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 140 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 141 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 142 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 143 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 144 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 400); 145 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 800); 146 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 1600); 147 | 148 | expect(console.log).toHaveBeenCalledTimes(7); 149 | }); 150 | 151 | it("FetchApp.fetch with constant delay", () => { 152 | const config = { 153 | maxRetries: 5, 154 | retryCodes: [200], 155 | delayFactor: 1, 156 | delay: 100, 157 | }; 158 | const result = FetchApp.fetch(url, params, config); 159 | 160 | expect(result).toBe(null); 161 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 162 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 163 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 164 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 165 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 100); 166 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 100); 167 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 100); 168 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 100); 169 | expect(console.log).toHaveBeenCalledTimes(7); 170 | }); 171 | 172 | it("Test FetchApp client without configuration, simple pass-through to UrlFetchApp", () => { 173 | const client = FetchApp.getClient(); 174 | const result = client.fetch(url, params); 175 | 176 | expect(result.getResponseCode()).toBe(200); 177 | expect(result.getContentText()).toContain("Mock response for URL"); 178 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 179 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 180 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 181 | expect(console.log).toHaveBeenCalledTimes(0); 182 | }); 183 | 184 | it("Test FetchApp client with configuration", () => { 185 | const client = FetchApp.getClient({ 186 | maxRetries: 3, 187 | retryCodes: [200], 188 | delayFactor: 2, 189 | delay: 100, 190 | }); 191 | const result = client.fetch(url, params); 192 | 193 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 194 | expect(result).toBe(null); 195 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 196 | expect(Utilities.sleep).toHaveBeenCalledTimes(3); 197 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 198 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 199 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 400); 200 | expect(console.log).toHaveBeenCalledTimes(5); 201 | }); 202 | 203 | it("Test FetchApp client with configuration and without params", () => { 204 | const client = FetchApp.getClient({ 205 | maxRetries: 3, 206 | retryCodes: [200], 207 | delayFactor: 2, 208 | delay: 100, 209 | }); 210 | const result = client.fetch(url); 211 | 212 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 213 | expect(result).toBe(null); 214 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 215 | muteHttpExceptions: true, 216 | }); 217 | expect(Utilities.sleep).toHaveBeenCalledTimes(3); 218 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 219 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 220 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 400); 221 | expect(console.log).toHaveBeenCalledTimes(5); 222 | }); 223 | 224 | it("Test FetchApp client with exponential delay", () => { 225 | const client = FetchApp.getClient({ 226 | maxRetries: 5, 227 | retryCodes: [200], 228 | delayFactor: 2, 229 | delay: 100, 230 | }); 231 | const result = client.fetch(url, params); 232 | 233 | expect(result).toBe(null); 234 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 235 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 236 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 237 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 238 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 239 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 400); 240 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 800); 241 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 1600); 242 | expect(console.log).toHaveBeenCalledTimes(7); 243 | }); 244 | 245 | it("Test FetchApp client with exponential delay up to maxDelay", () => { 246 | const client = FetchApp.getClient({ 247 | maxRetries: 5, 248 | retryCodes: [200], 249 | delayFactor: 2, 250 | delay: 100, 251 | maxDelay: 1000, 252 | }); 253 | const result = client.fetch(url, params); 254 | 255 | expect(result).toBe(null); 256 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 257 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 258 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 259 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 260 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 261 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 400); 262 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 800); 263 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 1000); 264 | expect(console.log).toHaveBeenCalledTimes(7); 265 | }); 266 | 267 | it("Test FetchApp client with constant delay", () => { 268 | const client = FetchApp.getClient({ 269 | maxRetries: 5, 270 | retryCodes: [200], 271 | delayFactor: 1, 272 | delay: 100, 273 | }); 274 | const result = client.fetch(url, params); 275 | 276 | expect(result).toBe(null); 277 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 278 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 279 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 280 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 281 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 100); 282 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 100); 283 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 100); 284 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 100); 285 | expect(console.log).toHaveBeenCalledTimes(7); 286 | }); 287 | 288 | it("Test overriding client config for a specific request", () => { 289 | const client = FetchApp.getClient({ 290 | maxRetries: 5, 291 | retryCodes: [200], 292 | delayFactor: 1, 293 | delay: 100, 294 | }); 295 | 296 | const result = client.fetch(url, params); 297 | expect(result).toBe(null); 298 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 299 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 300 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 301 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 302 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 100); 303 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 100); 304 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 100); 305 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 100); 306 | expect(console.log).toHaveBeenCalledTimes(7); 307 | 308 | UrlFetchApp.fetch.mockClear(); 309 | Utilities.sleep.mockClear(); 310 | console.log.mockClear(); 311 | 312 | const result2 = client.fetch(url, params, { 313 | maxRetries: 3, 314 | retryCodes: [200], 315 | delayFactor: 2, 316 | delay: 100, 317 | maxDelay: 250, 318 | }); 319 | 320 | expect(result2).toBe(null); 321 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 322 | ...params, 323 | muteHttpExceptions: true, 324 | }); 325 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 326 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 327 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 328 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 250); 329 | expect(console.log).toHaveBeenCalledTimes(5); 330 | }); 331 | 332 | it("Test FetchApp client class directly, simple pass-through to UrlFetchApp", () => { 333 | const params = { 334 | method: "get", 335 | }; 336 | const client = new FetchApp.FetchApp(); 337 | const result = client.fetch(url, params); 338 | 339 | expect(result.getResponseCode()).toBe(200); 340 | expect(result.getContentText()).toContain("Mock response for URL"); 341 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 342 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 343 | ...params, 344 | muteHttpExceptions: true, 345 | }); 346 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 347 | expect(console.log).toHaveBeenCalledTimes(0); 348 | }); 349 | 350 | it("Test FetchApp client class directly, static fetch, simple pass-through to UrlFetchApp", () => { 351 | const result = FetchApp.FetchApp.fetch(url, params); 352 | 353 | expect(result.getResponseCode()).toBe(200); 354 | expect(result.getContentText()).toContain("Mock response for URL"); 355 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 356 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 357 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 358 | expect(console.log).toHaveBeenCalledTimes(0); 359 | }); 360 | 361 | it("Test FetchApp client class directly, static fetch, no params, simple pass-through to UrlFetchApp", () => { 362 | const result = FetchApp.FetchApp.fetch(url); 363 | 364 | expect(result.getResponseCode()).toBe(200); 365 | expect(result.getContentText()).toContain("Mock response for URL"); 366 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 367 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 368 | muteHttpExceptions: true, 369 | }); 370 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 371 | expect(console.log).toHaveBeenCalledTimes(0); 372 | }); 373 | 374 | it("Test FetchApp client class directly, no params, simple pass-through to UrlFetchApp", () => { 375 | const client = new FetchApp.FetchApp(); 376 | const result = client.fetch(url); 377 | 378 | expect(result.getResponseCode()).toBe(200); 379 | expect(result.getContentText()).toContain("Mock response for URL"); 380 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(1); 381 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, { 382 | muteHttpExceptions: true, 383 | }); 384 | expect(Utilities.sleep).toHaveBeenCalledTimes(0); 385 | expect(console.log).toHaveBeenCalledTimes(0); 386 | }); 387 | 388 | it("Test FetchApp client class directly, with config and override", () => { 389 | const client = new FetchApp.FetchApp({ 390 | maxRetries: 5, 391 | retryCodes: [200], 392 | delayFactor: 1, 393 | delay: 100, 394 | }); 395 | 396 | const result = client.fetch(url, params); 397 | expect(result).toBe(null); 398 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 399 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(6); 400 | expect(Utilities.sleep).toHaveBeenCalledTimes(5); 401 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 402 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 100); 403 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 100); 404 | expect(Utilities.sleep).toHaveBeenNthCalledWith(4, 100); 405 | expect(Utilities.sleep).toHaveBeenNthCalledWith(5, 100); 406 | expect(console.log).toHaveBeenCalledTimes(7); 407 | 408 | UrlFetchApp.fetch.mockClear(); 409 | Utilities.sleep.mockClear(); 410 | console.log.mockClear(); 411 | 412 | const result2 = client.fetch(url, params, { 413 | maxRetries: 3, 414 | retryCodes: [200], 415 | delayFactor: 2, 416 | delay: 100, 417 | maxDelay: 250, 418 | }); 419 | 420 | expect(result2).toBe(null); 421 | expect(UrlFetchApp.fetch).toHaveBeenCalledWith(url, paramsExpected); 422 | expect(UrlFetchApp.fetch).toHaveBeenCalledTimes(4); 423 | expect(Utilities.sleep).toHaveBeenNthCalledWith(1, 100); 424 | expect(Utilities.sleep).toHaveBeenNthCalledWith(2, 200); 425 | expect(Utilities.sleep).toHaveBeenNthCalledWith(3, 250); 426 | expect(console.log).toHaveBeenCalledTimes(5); 427 | }); 428 | 429 | it("Test _getConfig", () => { 430 | // Test default config 431 | expect(FetchApp.FetchApp._getConfig()).toEqual({ 432 | maxRetries: 0, 433 | successCodes: [], 434 | retryCodes: [], 435 | delayFactor: 1, 436 | delay: 0, 437 | maxDelay: Infinity, 438 | onRequestFailure: FetchApp.defaultConfig_.onRequestFailure, 439 | onAllRequestsFailure: FetchApp.defaultConfig_.onAllRequestsFailure, 440 | }); 441 | 442 | // Test default config with empty overrides 443 | expect(FetchApp.FetchApp._getConfig({})).toEqual({ 444 | maxRetries: 0, 445 | successCodes: [], 446 | retryCodes: [], 447 | delayFactor: 1, 448 | delay: 0, 449 | maxDelay: Infinity, 450 | onRequestFailure: FetchApp.defaultConfig_.onRequestFailure, 451 | onAllRequestsFailure: FetchApp.defaultConfig_.onAllRequestsFailure, 452 | }); 453 | 454 | // Test default config with partial overrides 455 | expect( 456 | FetchApp.FetchApp._getConfig({ 457 | maxRetries: 3, 458 | retryCodes: [200], 459 | delayFactor: 2, 460 | delay: 100, 461 | maxDelay: 250, 462 | }) 463 | ).toEqual({ 464 | maxRetries: 3, 465 | successCodes: [], 466 | retryCodes: [200], 467 | delayFactor: 2, 468 | delay: 100, 469 | maxDelay: 250, 470 | onRequestFailure: FetchApp.defaultConfig_.onRequestFailure, 471 | onAllRequestsFailure: FetchApp.defaultConfig_.onAllRequestsFailure, 472 | }); 473 | 474 | const config = { 475 | maxRetries: 3, 476 | successCodes: [200], 477 | retryCodes: [200], 478 | delayFactor: 2, 479 | delay: 100, 480 | maxDelay: 250, 481 | onRequestFailure: () => {}, 482 | onAllRequestsFailure: () => {}, 483 | }; 484 | 485 | // Test default config with all overrides 486 | expect(FetchApp.FetchApp._getConfig(config)).toEqual(config); 487 | }); 488 | 489 | it("Test callback functions", () => { 490 | const onRequestFailureMock = jest.fn(); 491 | const onAllRequestsFailureMock = jest.fn(); 492 | 493 | const config = { 494 | maxRetries: 3, 495 | retryCodes: [500], 496 | onRequestFailure: onRequestFailureMock, 497 | onAllRequestsFailure: onAllRequestsFailureMock, 498 | }; 499 | 500 | const client = new FetchApp.FetchApp(config); 501 | 502 | const response1 = { 503 | getResponseCode: () => 500, 504 | }; 505 | const response2 = { 506 | getResponseCode: () => 500, 507 | }; 508 | 509 | UrlFetchApp.fetch 510 | .mockImplementationOnce(() => response1) 511 | .mockImplementationOnce(() => response1) 512 | .mockImplementationOnce(() => response1) 513 | .mockImplementationOnce(() => response2); 514 | 515 | const result = client.fetch(url, params); 516 | 517 | expect(result).toBe(null); 518 | expect(onAllRequestsFailureMock).toHaveBeenCalledTimes(1); 519 | expect(onAllRequestsFailureMock).toHaveBeenCalledWith({ 520 | url, 521 | params: paramsExpected, 522 | config: { ...FetchApp.defaultConfig_, ...config }, 523 | retries: 3, 524 | response: response2, 525 | }); 526 | expect(onRequestFailureMock).toHaveBeenCalledTimes(4); 527 | expect(onRequestFailureMock).toHaveBeenNthCalledWith(1, { 528 | url, 529 | params: paramsExpected, 530 | config: { ...FetchApp.defaultConfig_, ...config }, 531 | retries: 0, 532 | response: response1, 533 | }); 534 | expect(onRequestFailureMock).toHaveBeenNthCalledWith(2, { 535 | url, 536 | params: paramsExpected, 537 | config: { ...FetchApp.defaultConfig_, ...config }, 538 | retries: 1, 539 | response: response1, 540 | }); 541 | expect(onRequestFailureMock).toHaveBeenNthCalledWith(3, { 542 | url, 543 | params: paramsExpected, 544 | config: { ...FetchApp.defaultConfig_, ...config }, 545 | retries: 2, 546 | response: response1, 547 | }); 548 | expect(onRequestFailureMock).toHaveBeenNthCalledWith(4, { 549 | url, 550 | params: paramsExpected, 551 | config: { ...FetchApp.defaultConfig_, ...config }, 552 | retries: 3, 553 | response: response2, 554 | }); 555 | }); 556 | 557 | it("Test throwing exception from a callback function", () => { 558 | const stopRequest = ({ url, response }) => { 559 | const responseCode = response.getResponseCode(); 560 | if (responseCode === 401) { 561 | throw new Error(`Received ${responseCode} when accessing ${url}`); 562 | } 563 | }; 564 | 565 | const response1 = { 566 | getResponseCode: () => 429, 567 | }; 568 | const response2 = { 569 | getResponseCode: () => 401, 570 | }; 571 | 572 | UrlFetchApp.fetch 573 | .mockImplementationOnce(() => response1) 574 | .mockImplementationOnce(() => response2); 575 | 576 | const config = { 577 | successCodes: [200], 578 | maxRetries: 5, 579 | delay: 500, 580 | onRequestFailure: stopRequest, 581 | }; 582 | 583 | expect(() => FetchApp.fetch(url, params, config)).toThrow( 584 | `Received 401 when accessing ${url}` 585 | ); 586 | }); 587 | }); 588 | --------------------------------------------------------------------------------