diff --git a/.gitignore b/.gitignore index 3c3629e..55371e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 62c7acf..f190926 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ jobs: |ignoreStatusCodes| Prevent this Action to fail if the request respond with one of the configured Status Codes. Example: '404,401' || |httpsCA| Certificate authority as string in PEM format || |responseFile| Persist the response data to the specified file path || +|retry| optional amount of retries if the request is failing, does not retry if the status code is ignored || +|retryWait| time between each retry in millseconds | 3000 | ### Response diff --git a/action.yml b/action.yml index ce0c06f..a7f1775 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,12 @@ inputs: responseFile: description: 'Persist the response data to the specified file path' required: false + retry: + description: 'optional amount of retries if the request fails' + required: false + retryWait: + description: 'wait time between retries in milliseconds' + required: false outputs: response: description: 'HTTP Response Content' diff --git a/dist/index.js b/dist/index.js index b727ab5..d2eb978 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5056,6 +5056,91 @@ const createPersistHandler = (filePath, actions) => (response) => { module.exports = { createPersistHandler } +/***/ }), + +/***/ 6989: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const { GithubActions } = __nccwpck_require__(8169); +const FormData = __nccwpck_require__(4334); +const fs = __nccwpck_require__(7147); + +/** + * @param {string} value + * + * @returns {Object} + */ +const convertToJSON = (value) => { + try { + return JSON.parse(value) || {}; + } catch (e) { + return {}; + } +}; + +/** + * @param {{ [key: string]: string }} data + * @param {{ [key: string]: string }} files + * @param {boolean} convertPaths + * + * @returns {FormData} + */ +const convertToFormData = (data, files, convertPaths) => { + const formData = new FormData(); + + for (const [key, value] of Object.entries(data)) { + formData.append(key, value); + } + + for (const [key, value] of Object.entries(files)) { + formData.append(key, fs.createReadStream(value)); + } + + return formData; +}; + +/** + * @param {() => Promise} callback + * @param {{ retry: number; sleep: number; actions: GithubActions }} options + * + * @returns {Promise} + */ +const retry = async (callback, options) => { + let lastErr = null; + let i = 0; + + do { + try { + return await callback(); + } catch (err) { + lastErr = err; + } + + if (i < options.retries) { + options.actions.warning(`#${i + 1} request failed: ${err}`); + await sleep(options.sleep); + } + + i++; + } while (i <= options.retry); + + throw lastErr; +}; + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +module.exports = { + convertToJSON, + convertToFormData, + retry, +}; + + /***/ }), /***/ 9082: @@ -5065,10 +5150,11 @@ module.exports = { createPersistHandler } const axios = __nccwpck_require__(8757); -const FormData = __nccwpck_require__(4334) -const fs = __nccwpck_require__(7147) +const FormData = __nccwpck_require__(4334); +const fs = __nccwpck_require__(7147); const url = __nccwpck_require__(7310); const { GithubActions } = __nccwpck_require__(8169); +const { convertToJSON, convertToFormData, retry } = __nccwpck_require__(6989); const METHOD_GET = 'GET' const METHOD_POST = 'POST' @@ -5085,15 +5171,21 @@ const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded' * @param {string} param0.files Map of Request Files (name: absolute path) as JSON String, default: {} * @param {string} param0.file Single request file (absolute path) * @param {GithubActions} param0.actions - * @param {number[]} param0.ignoredCodes Prevent Action to fail if the API response with one of this StatusCodes - * @param {boolean} param0.preventFailureOnNoResponse Prevent Action to fail if the API respond without Response - * @param {boolean} param0.escapeData Escape unescaped JSON content in data + * @param {{ + * ignoredCodes: number[]; + * preventFailureOnNoResponse: boolean, + * escapeData: boolean; + * retry: number; + * retryWait: number; + * }} param0.options * * @returns {Promise} */ -const request = async({ method, instanceConfig, data, files, file, actions, ignoredCodes, preventFailureOnNoResponse, escapeData }) => { +const request = async({ method, instanceConfig, data, files, file, actions, options }) => { + actions.debug(`options: ${JSON.stringify(options)}`) + try { - if (escapeData) { + if (options.escapeData) { data = data.replace(/"[^"]*"/g, (match) => { return match.replace(/[\n\r]\s*/g, "\\n"); }); @@ -5145,10 +5237,38 @@ const request = async({ method, instanceConfig, data, files, file, actions, igno actions.debug('Request Data: ' + JSON.stringify(requestData)) - const response = await instance.request(requestData) + const execRequest = async () => { + try { + return await instance.request(requestData) + } catch(error) { + if (error.response && options.ignoredCodes.includes(error.response.status)) { + actions.warning(`ignored status code: ${JSON.stringify({ code: error.response.status, message: error.response.data })}`) + + return null + } + + if (!error.response && error.request && options.preventFailureOnNoResponse) { + actions.warning(`no response received: ${JSON.stringify(error)}`); + + return null + } + + throw error + } + } + + /** @type {axios.AxiosResponse|null} */ + const response = await retry(execRequest, { + actions, + retry: options.retry || 0, + sleep: options.retryWait // wait 3s after each retry + }) + + if (!response) { + return null + } actions.setOutput('response', JSON.stringify(response.data)) - actions.setOutput('headers', response.headers) return response @@ -5158,53 +5278,16 @@ const request = async({ method, instanceConfig, data, files, file, actions, igno actions.setOutput('requestError', JSON.stringify({ name, message, code, status: response && response.status ? response.status : null })); } - if (error.response && ignoredCodes.includes(error.response.status)) { - actions.warning(JSON.stringify({ code: error.response.status, message: error.response.data })) - } else if (error.response) { + if (error.response) { actions.setFailed(JSON.stringify({ code: error.response.status, message: error.response.data })) - } else if (error.request && !preventFailureOnNoResponse) { + } else if (error.request) { actions.setFailed(JSON.stringify({ error: "no response received" })); - } else if (error.request && preventFailureOnNoResponse) { - actions.warning(JSON.stringify(error)); } else { actions.setFailed(JSON.stringify({ message: error.message, data })); } } } -/** - * @param {string} value - * - * @returns {Object} - */ -const convertToJSON = (value) => { - try { - return JSON.parse(value) || {} - } catch(e) { - return {} - } -} - -/** - * @param {Object} data - * @param {Object} files - * - * @returns {FormData} - */ -const convertToFormData = (data, files) => { - const formData = new FormData() - - for (const [key, value] of Object.entries(data)) { - formData.append(key, value) - } - - for (const [key, value] of Object.entries(files)) { - formData.append(key, fs.createReadStream(value)) - } - - return formData -} - /** * @param {{ baseURL: string; timeout: number; headers: { [name: string]: string } }} instanceConfig * @param {FormData} formData @@ -5406,7 +5489,7 @@ module.exports = require("zlib"); /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { "use strict"; -// Axios v1.3.2 Copyright (c) 2023 Matt Zabriskie and contributors +// Axios v1.3.4 Copyright (c) 2023 Matt Zabriskie and contributors const FormData$1 = __nccwpck_require__(4334); @@ -6989,11 +7072,15 @@ function isValidHeaderName(str) { return /^[-_a-zA-Z]+$/.test(str.trim()); } -function matchHeaderValue(context, value, header, filter) { +function matchHeaderValue(context, value, header, filter, isHeaderNameFilter) { if (utils.isFunction(filter)) { return filter.call(this, value, header); } + if (isHeaderNameFilter) { + value = header; + } + if (!utils.isString(value)) return; if (utils.isString(filter)) { @@ -7137,7 +7224,7 @@ class AxiosHeaders { while (i--) { const key = keys[i]; - if(!matcher || matchHeaderValue(this, this[key], key, matcher)) { + if(!matcher || matchHeaderValue(this, this[key], key, matcher, true)) { delete this[key]; deleted = true; } @@ -7356,7 +7443,7 @@ function buildFullPath(baseURL, requestedURL) { return requestedURL; } -const VERSION = "1.3.2"; +const VERSION = "1.3.4"; function parseProtocol(url) { const match = /^([-+\w]{1,25})(:?\/\/|:)/.exec(url); @@ -7918,15 +8005,39 @@ function setProxy(options, configProxy, location) { const isHttpAdapterSupported = typeof process !== 'undefined' && utils.kindOf(process) === 'process'; +// temporary hotfix + +const wrapAsync = (asyncExecutor) => { + return new Promise((resolve, reject) => { + let onDone; + let isDone; + + const done = (value, isRejected) => { + if (isDone) return; + isDone = true; + onDone && onDone(value, isRejected); + }; + + const _resolve = (value) => { + done(value); + resolve(value); + }; + + const _reject = (reason) => { + done(reason, true); + reject(reason); + }; + + asyncExecutor(_resolve, _reject, (onDoneHandler) => (onDone = onDoneHandler)).catch(_reject); + }) +}; + /*eslint consistent-return:0*/ const httpAdapter = isHttpAdapterSupported && function httpAdapter(config) { - /*eslint no-async-promise-executor:0*/ - return new Promise(async function dispatchHttpRequest(resolvePromise, rejectPromise) { - let data = config.data; - const responseType = config.responseType; - const responseEncoding = config.responseEncoding; + return wrapAsync(async function dispatchHttpRequest(resolve, reject, onDone) { + let {data} = config; + const {responseType, responseEncoding} = config; const method = config.method.toUpperCase(); - let isFinished; let isDone; let rejected = false; let req; @@ -7934,10 +8045,7 @@ const httpAdapter = isHttpAdapterSupported && function httpAdapter(config) { // temporary internal emitter until the AxiosRequest class will be implemented const emitter = new EventEmitter__default["default"](); - function onFinished() { - if (isFinished) return; - isFinished = true; - + const onFinished = () => { if (config.cancelToken) { config.cancelToken.unsubscribe(abort); } @@ -7947,28 +8055,15 @@ const httpAdapter = isHttpAdapterSupported && function httpAdapter(config) { } emitter.removeAllListeners(); - } - - function done(value, isRejected) { - if (isDone) return; + }; + onDone((value, isRejected) => { isDone = true; - if (isRejected) { rejected = true; onFinished(); } - - isRejected ? rejectPromise(value) : resolvePromise(value); - } - - const resolve = function resolve(value) { - done(value); - }; - - const reject = function reject(value) { - done(value, true); - }; + }); function abort(reason) { emitter.emit('abort', !reason || reason.type ? new CanceledError(null, config, req) : reason); @@ -8066,7 +8161,7 @@ const httpAdapter = isHttpAdapterSupported && function httpAdapter(config) { if (!headers.hasContentLength()) { try { const knownLength = await util__default["default"].promisify(data.getLength).call(data); - headers.setContentLength(knownLength); + Number.isFinite(knownLength) && knownLength >= 0 && headers.setContentLength(knownLength); /*eslint no-empty:0*/ } catch (e) { } @@ -9683,6 +9778,16 @@ if (!!core.getInput('username') || !!core.getInput('password')) { } } +let retry = 0 +if (!!core.getInput('retry')) { + retry = parseInt(core.getInput('retry')) +} + +let retryWait = 3000 +if (!!core.getInput('retryWait')) { + retry = parseInt(core.getInput('retryWait')) +} + const data = core.getInput('data') || '{}'; const files = core.getInput('files') || '{}'; const file = core.getInput('file') @@ -9705,7 +9810,15 @@ if (!!responseFile) { handler.push(createPersistHandler(responseFile, actions)) } -request({ data, method, instanceConfig, preventFailureOnNoResponse, escapeData, files, file, ignoredCodes, actions }).then(response => { +const options = { + ignoredCodes, + preventFailureOnNoResponse, + escapeData, + retry, + retryWait +} + +request({ data, method, instanceConfig, files, file, actions, options }).then(response => { if (typeof response == 'object') { handler.forEach(h => h(response)) } diff --git a/src/helper.js b/src/helper.js new file mode 100644 index 0000000..5144de2 --- /dev/null +++ b/src/helper.js @@ -0,0 +1,77 @@ +'use strict'; + +const { GithubActions } = require('./githubActions'); +const FormData = require('form-data'); +const fs = require('fs'); + +/** + * @param {string} value + * + * @returns {Object} + */ +const convertToJSON = (value) => { + try { + return JSON.parse(value) || {}; + } catch (e) { + return {}; + } +}; + +/** + * @param {{ [key: string]: string }} data + * @param {{ [key: string]: string }} files + * @param {boolean} convertPaths + * + * @returns {FormData} + */ +const convertToFormData = (data, files, convertPaths) => { + const formData = new FormData(); + + for (const [key, value] of Object.entries(data)) { + formData.append(key, value); + } + + for (const [key, value] of Object.entries(files)) { + formData.append(key, fs.createReadStream(value)); + } + + return formData; +}; + +/** + * @param {() => Promise} callback + * @param {{ retry: number; sleep: number; actions: GithubActions }} options + * + * @returns {Promise} + */ +const retry = async (callback, options) => { + let lastErr = null; + let i = 0; + + do { + try { + return await callback(); + } catch (err) { + lastErr = err; + } + + if (i < options.retries) { + options.actions.warning(`#${i + 1} request failed: ${err}`); + await sleep(options.sleep); + } + + i++; + } while (i <= options.retry); + + throw lastErr; +}; + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +module.exports = { + convertToJSON, + convertToFormData, + retry, +}; diff --git a/src/httpClient.js b/src/httpClient.js index bea553c..fa5cc17 100644 --- a/src/httpClient.js +++ b/src/httpClient.js @@ -1,10 +1,11 @@ 'use strict' const axios = require('axios'); -const FormData = require('form-data') -const fs = require('fs') +const FormData = require('form-data'); +const fs = require('fs'); const url = require('url'); const { GithubActions } = require('./githubActions'); +const { convertToJSON, convertToFormData, retry } = require('./helper'); const METHOD_GET = 'GET' const METHOD_POST = 'POST' @@ -21,15 +22,21 @@ const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded' * @param {string} param0.files Map of Request Files (name: absolute path) as JSON String, default: {} * @param {string} param0.file Single request file (absolute path) * @param {GithubActions} param0.actions - * @param {number[]} param0.ignoredCodes Prevent Action to fail if the API response with one of this StatusCodes - * @param {boolean} param0.preventFailureOnNoResponse Prevent Action to fail if the API respond without Response - * @param {boolean} param0.escapeData Escape unescaped JSON content in data + * @param {{ + * ignoredCodes: number[]; + * preventFailureOnNoResponse: boolean, + * escapeData: boolean; + * retry: number; + * retryWait: number; + * }} param0.options * * @returns {Promise} */ -const request = async({ method, instanceConfig, data, files, file, actions, ignoredCodes, preventFailureOnNoResponse, escapeData }) => { +const request = async({ method, instanceConfig, data, files, file, actions, options }) => { + actions.debug(`options: ${JSON.stringify(options)}`) + try { - if (escapeData) { + if (options.escapeData) { data = data.replace(/"[^"]*"/g, (match) => { return match.replace(/[\n\r]\s*/g, "\\n"); }); @@ -81,10 +88,38 @@ const request = async({ method, instanceConfig, data, files, file, actions, igno actions.debug('Request Data: ' + JSON.stringify(requestData)) - const response = await instance.request(requestData) + const execRequest = async () => { + try { + return await instance.request(requestData) + } catch(error) { + if (error.response && options.ignoredCodes.includes(error.response.status)) { + actions.warning(`ignored status code: ${JSON.stringify({ code: error.response.status, message: error.response.data })}`) + + return null + } + + if (!error.response && error.request && options.preventFailureOnNoResponse) { + actions.warning(`no response received: ${JSON.stringify(error)}`); + + return null + } + + throw error + } + } + + /** @type {axios.AxiosResponse|null} */ + const response = await retry(execRequest, { + actions, + retry: options.retry || 0, + sleep: options.retryWait // wait 3s after each retry + }) + + if (!response) { + return null + } actions.setOutput('response', JSON.stringify(response.data)) - actions.setOutput('headers', response.headers) return response @@ -94,53 +129,16 @@ const request = async({ method, instanceConfig, data, files, file, actions, igno actions.setOutput('requestError', JSON.stringify({ name, message, code, status: response && response.status ? response.status : null })); } - if (error.response && ignoredCodes.includes(error.response.status)) { - actions.warning(JSON.stringify({ code: error.response.status, message: error.response.data })) - } else if (error.response) { + if (error.response) { actions.setFailed(JSON.stringify({ code: error.response.status, message: error.response.data })) - } else if (error.request && !preventFailureOnNoResponse) { + } else if (error.request) { actions.setFailed(JSON.stringify({ error: "no response received" })); - } else if (error.request && preventFailureOnNoResponse) { - actions.warning(JSON.stringify(error)); } else { actions.setFailed(JSON.stringify({ message: error.message, data })); } } } -/** - * @param {string} value - * - * @returns {Object} - */ -const convertToJSON = (value) => { - try { - return JSON.parse(value) || {} - } catch(e) { - return {} - } -} - -/** - * @param {Object} data - * @param {Object} files - * - * @returns {FormData} - */ -const convertToFormData = (data, files) => { - const formData = new FormData() - - for (const [key, value] of Object.entries(data)) { - formData.append(key, value) - } - - for (const [key, value] of Object.entries(files)) { - formData.append(key, fs.createReadStream(value)) - } - - return formData -} - /** * @param {{ baseURL: string; timeout: number; headers: { [name: string]: string } }} instanceConfig * @param {FormData} formData diff --git a/src/index.js b/src/index.js index af722ba..75147d9 100644 --- a/src/index.js +++ b/src/index.js @@ -43,6 +43,16 @@ if (!!core.getInput('username') || !!core.getInput('password')) { } } +let retry = 0 +if (!!core.getInput('retry')) { + retry = parseInt(core.getInput('retry')) +} + +let retryWait = 3000 +if (!!core.getInput('retryWait')) { + retry = parseInt(core.getInput('retryWait')) +} + const data = core.getInput('data') || '{}'; const files = core.getInput('files') || '{}'; const file = core.getInput('file') @@ -65,7 +75,15 @@ if (!!responseFile) { handler.push(createPersistHandler(responseFile, actions)) } -request({ data, method, instanceConfig, preventFailureOnNoResponse, escapeData, files, file, ignoredCodes, actions }).then(response => { +const options = { + ignoredCodes, + preventFailureOnNoResponse, + escapeData, + retry, + retryWait +} + +request({ data, method, instanceConfig, files, file, actions, options }).then(response => { if (typeof response == 'object') { handler.forEach(h => h(response)) }