diff --git a/.github/workflows/policy-scan.yml b/.github/workflows/policy-scan.yml index 33e68cf..ff25923 100644 --- a/.github/workflows/policy-scan.yml +++ b/.github/workflows/policy-scan.yml @@ -43,4 +43,4 @@ jobs: if [ "$license_file_found" = false ]; then echo "No license file found. Please add a license file to the repository." exit 1 - fi + fi \ No newline at end of file diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml new file mode 100644 index 0000000..049c02f --- /dev/null +++ b/.github/workflows/secrets-scan.yml @@ -0,0 +1,29 @@ +name: Secrets Scan +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '2' + ref: '${{ github.event.pull_request.head.ref }}' + - run: | + git reset --soft HEAD~1 + - name: Install Talisman + run: | + # Download Talisman + wget https://github.com/thoughtworks/talisman/releases/download/v1.37.0/talisman_linux_amd64 -O talisman + + # Checksum verification + checksum=$(sha256sum ./talisman | awk '{print $1}') + if [ "$checksum" != "8e0ae8bb7b160bf10c4fa1448beb04a32a35e63505b3dddff74a092bccaaa7e4" ]; then exit 1; fi + + # Make it executable + chmod +x talisman + - name: Run talisman + run: | + # Run Talisman with the pre-commit hook + ./talisman --githook pre-commit \ No newline at end of file diff --git a/.talismanrc b/.talismanrc index 06ea795..6eb23c8 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,7 @@ fileignoreconfig: +- filename: .github/workflows/secrets-scan.yml + ignore_detectors: + - filecontent - filename: package-lock.json checksum: ebafc1a55b01b2259dacb35e2c286ad88c811974c6955d379be3205abbf1c7ff version: "" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 282a865..152768e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ ## Change log +### Version: 1.2.2 +#### Date: Jun-09-2025 + - Enhancement: Retry logic to check for rate limit remaining header + +### Version: 1.2.1 +#### Date: Apr-29-2025 + - Fix: Updated Regex for resolve the path traversal issue + ### Version: 1.2.0 #### Date: Jan-24-2025 - Fix: URL change for Live Preview diff --git a/package.json b/package.json index 35b0f65..82908b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/core", - "version": "1.2.1", + "version": "1.2.2", "type": "commonjs", "main": "./dist/cjs/src/index.js", "types": "./dist/cjs/src/index.d.ts", diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index 03d02dd..7abb95c 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -43,19 +43,26 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance } else { throw error; } - } else if (response.status == 429 || response.status == 401) { - retryCount++; + } else { + const rateLimitRemaining = response.headers['x-ratelimit-remaining']; + if (rateLimitRemaining !== undefined && parseInt(rateLimitRemaining) <= 0) { + return Promise.reject(error.response.data); + } + + if (response.status == 429 || response.status == 401) { + retryCount++; - if (retryCount >= config.retryLimit) { - if (error.response && error.response.data) { - return Promise.reject(error.response.data); + if (retryCount >= config.retryLimit) { + if (error.response && error.response.data) { + return Promise.reject(error.response.data); + } + + return Promise.reject(error); } + error.config.retryCount = retryCount; - return Promise.reject(error); + return axiosInstance(error.config); } - error.config.retryCount = retryCount; - - return axiosInstance(error.config); } if (config.retryCondition && config.retryCondition(error)) { diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index ce146a7..f6cd93a 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -46,17 +46,19 @@ describe('retryResponseErrorHandler', () => { await retryResponseErrorHandler(error, config, client); fail('Expected retryResponseErrorHandler to throw an error'); } catch (err) { - expect(err).toEqual(expect.objectContaining({ - code: 'ECONNABORTED', - config: expect.objectContaining({ retryOnError: false }), - })); + expect(err).toEqual( + expect.objectContaining({ + code: 'ECONNABORTED', + config: expect.objectContaining({ retryOnError: false }), + }) + ); } }); it('should reject the promise if retryOnError is true', async () => { const error = { config: { retryOnError: true } }; const config = { retryLimit: 5 }; const client = axios.create(); - + try { await retryResponseErrorHandler(error, config, client); fail('Expected retryResponseErrorHandler to throw an error'); @@ -73,37 +75,66 @@ describe('retryResponseErrorHandler', () => { await retryResponseErrorHandler(error, config, client); fail('Expected retryResponseErrorHandler to throw an error'); } catch (err) { - expect(err).toEqual(expect.objectContaining({ - error_code: 408, - error_message: `Timeout of ${config.timeout}ms exceeded`, - errors: null - })); - } + expect(err).toEqual( + expect.objectContaining({ + error_code: 408, + error_message: `Timeout of ${config.timeout}ms exceeded`, + errors: null, + }) + ); + } }); it('should reject the promise if response status is 429 and retryCount exceeds retryLimit', async () => { const error = { config: { retryOnError: true, retryCount: 5 }, - response: { status: 429, statusText: 'timeout of 1000ms exceeded' }, + response: { + status: 429, + statusText: 'timeout of 1000ms exceeded', + headers: {}, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, }; const config = { retryLimit: 5, timeout: 1000 }; const client = axios.create(); - await expect(retryResponseErrorHandler(error, config, client)).rejects.toBe(error); + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error.response.data); }); it('should reject the promise if response status is 401 and retryCount exceeds retryLimit', async () => { const error = { config: { retryOnError: true, retryCount: 5 }, - response: { status: 401, statusText: 'timeout of 1000ms exceeded' }, + response: { + status: 401, + statusText: 'timeout of 1000ms exceeded', + headers: {}, + data: { + error_message: 'Unauthorized', + error_code: 401, + errors: null, + }, + }, }; const config = { retryLimit: 5, timeout: 1000 }; const client = axios.create(); - await expect(retryResponseErrorHandler(error, config, client)).rejects.toBe(error); + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error.response.data); }); it('should reject the promise if response status is 429 or 401 and retryCount is within limit', async () => { const error = { config: { retryOnError: true, retryCount: 4 }, - response: { status: 429, statusText: 'timeout of 1000ms exceeded' }, + response: { + status: 429, + statusText: 'timeout of 1000ms exceeded', + headers: {}, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, request: { method: 'post', url: '/retryURL', @@ -111,30 +142,24 @@ describe('retryResponseErrorHandler', () => { headers: { 'Content-Type': 'application/json' }, }, }; - const config = { retryLimit: 5, timeout: 1000 }; + const config = { retryLimit: 4, timeout: 1000 }; const client = axios.create(); - const finalResponseObj = { - config: { retryOnError: true, retryCount: 4 }, - response: { status: 429, statusText: 'timeout of 1000ms exceeded' }, - }; - - mock.onPost('/retryURL').reply(200, finalResponseObj); - - try { - await retryResponseErrorHandler(error, config, client); - throw new Error('Expected retryResponseErrorHandler to throw an error'); - } catch (err: any) { - expect(err.response.status).toBe(429); - expect(err.response.statusText).toBe(error.response.statusText); - expect(err.config.retryCount).toBe(error.config.retryCount); - } - + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error.response.data); }); it('should call the retry function if retryCondition is passed', async () => { const error = { config: { retryOnError: true, retryCount: 4 }, - response: { status: 200, statusText: 'Success Response but retry needed' }, + response: { + status: 200, + statusText: 'Success Response but retry needed', + headers: {}, + data: { + error_message: 'Retry needed', + error_code: 200, + errors: null, + }, + }, request: { method: 'post', url: '/retryURL', @@ -142,26 +167,28 @@ describe('retryResponseErrorHandler', () => { headers: { 'Content-Type': 'application/json' }, }, }; - // eslint-disable-next-line @typescript-eslint/no-shadow - const retryCondition = (error: any) => true; - const config = { retryLimit: 5, timeout: 1000, retryCondition: retryCondition }; + const retryCondition = () => true; + const config = { retryLimit: 5, timeout: 1000, retryCondition }; const client = axios.create(); - const finalResponseObj = { - config: { retryOnError: true, retryCount: 5 }, - response: { status: 429, statusText: 'timeout of 1000ms exceeded' }, - }; - - mock.onPost('/retryURL').reply(200, finalResponseObj); + mock.onPost('/retryURL').reply(200, { success: true }); - const finalResponse: any = await retryResponseErrorHandler(error, config, client); - - expect(finalResponse.data).toEqual(finalResponseObj); + const response = (await retryResponseErrorHandler(error, config, client)) as AxiosResponse; + expect(response.status).toBe(200); }); it('should reject to error when retryCondition is passed but retryLimit is exceeded', async () => { const error = { config: { retryOnError: true, retryCount: 5 }, - response: { status: 200, statusText: 'Success Response but retry needed' }, + response: { + status: 200, + statusText: 'Success Response but retry needed', + headers: {}, + data: { + error_message: 'Retry needed', + error_code: 200, + errors: null, + }, + }, request: { method: 'post', url: '/retryURL', @@ -169,25 +196,91 @@ describe('retryResponseErrorHandler', () => { headers: { 'Content-Type': 'application/json' }, }, }; - // eslint-disable-next-line @typescript-eslint/no-shadow const retryCondition = (error: any) => true; - const config = { retryLimit: 5, timeout: 1000, retryCondition: retryCondition }; + const config = { retryLimit: 5, timeout: 1000, retryCondition }; const client = axios.create(); - const finalResponseObj = { - config: { retryOnError: true, retryCount: 5 }, - response: { status: 429, statusText: 'timeout of 1000ms exceeded' }, + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error); + }); + + it('should retry when response status is 429 and retryCount is less than retryLimit', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + statusText: 'Rate limit exceeded', + headers: {}, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + const response = (await retryResponseErrorHandler(error, config, client)) as AxiosResponse; + expect(response.status).toBe(200); + }); + + it('should retry when retryCondition is true', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 500, + statusText: 'Internal Server Error', + headers: {}, + data: { + error_message: 'Internal Server Error', + error_code: 500, + errors: null, + }, + }, }; + const retryCondition = jest.fn().mockReturnValue(true); + const config = { retryLimit: 3, retryCondition, retryDelay: 100 }; + const client = axios.create(); - mock.onPost('/retryURL').reply(200, finalResponseObj); + mock.onAny().reply(200, { success: true }); - await expect(retryResponseErrorHandler(error, config, client)).rejects.toBe(error); + const response = (await retryResponseErrorHandler(error, config, client)) as AxiosResponse; + expect(response.status).toBe(200); + expect(retryCondition).toHaveBeenCalledWith(error); }); - it('should retry when response status is 429 and retryCount is less than retryLimit', async () => { + it('should reject with rate limit error when x-ratelimit-remaining is 0', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, - response: { status: 429, statusText: 'Rate limit exceeded' }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error.response.data); + }); + + it('should retry when x-ratelimit-remaining is greater than 0', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '5', + }, + }, }; const config = { retryLimit: 3 }; const client = axios.create(); @@ -198,19 +291,20 @@ describe('retryResponseErrorHandler', () => { expect(response.status).toBe(200); }); - it('should retry when retryCondition is true', async () => { + it('should retry when x-ratelimit-remaining header is not present', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, - response: { status: 500, statusText: 'Internal Server Error' }, + response: { + status: 429, + headers: {}, + }, }; - const retryCondition = jest.fn().mockReturnValue(true); - const config = { retryLimit: 3, retryCondition, retryDelay: 100 }; + const config = { retryLimit: 3 }; const client = axios.create(); mock.onAny().reply(200); const response: any = await retryResponseErrorHandler(error, config, client); expect(response.status).toBe(200); - expect(retryCondition).toHaveBeenCalledWith(error); }); });