Skip to content

Commit 0cbfbe0

Browse files
committed
Merge branch 'release/1.11.0'
2 parents 096fa2f + 8d45b12 commit 0cbfbe0

8 files changed

+481
-272
lines changed

package-lock.json

Lines changed: 317 additions & 258 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "serverless-api-gateway-caching",
3-
"version": "1.10.4",
3+
"version": "1.11.0",
44
"description": "A plugin for the serverless framework which helps with configuring caching for API Gateway endpoints.",
55
"main": "src/apiGatewayCachingPlugin.js",
66
"scripts": {
@@ -30,10 +30,12 @@
3030
},
3131
"devDependencies": {
3232
"chai": "^4.1.2",
33+
"chai-as-promised": "^8.0.1",
3334
"chance": "^1.0.16",
3435
"lodash.split": "^4.4.0",
3536
"mocha": "^10.0.0",
3637
"mocha-junit-reporter": "^2.0.2",
37-
"proxyquire": "^2.1.0"
38+
"proxyquire": "^2.1.0",
39+
"sinon": "^20.0.0"
3840
}
3941
}

src/apiGatewayCachingPlugin.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
const ApiGatewayCachingSettings = require('./ApiGatewayCachingSettings');
44
const cacheKeyParameters = require('./cacheKeyParameters');
5-
const updateStageCacheSettings = require('./stageCache');
5+
const { updateStageCacheSettings } = require('./stageCache');
66
const { restApiExists, outputRestApiIdTo } = require('./restApiId');
77

88
class ApiGatewayCachingPlugin {
@@ -13,7 +13,7 @@ class ApiGatewayCachingPlugin {
1313
this.hooks = {
1414
'before:package:initialize': this.createSettings.bind(this),
1515
'before:package:finalize': this.updateCloudFormationTemplate.bind(this),
16-
'after:aws:deploy:finalize:cleanup': this.updateStage.bind(this),
16+
'after:deploy:deploy': this.updateStage.bind(this),
1717
};
1818

1919
this.defineValidationSchema(serverless);

src/stageCache.js

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const isEmpty = require('lodash.isempty');
22
const { retrieveRestApiId } = require('./restApiId');
33
const MAX_PATCH_OPERATIONS_PER_STAGE_UPDATE = 80;
4+
const BASE_RETRY_DELAY_MS = 500;
45

56
String.prototype.replaceAll = function (search, replacement) {
67
let target = this;
@@ -194,14 +195,48 @@ const updateStageFor = async (serverless, params, stage, region) => {
194195
paramsInChunks.push(params);
195196
}
196197

197-
for (let index in paramsInChunks) {
198-
serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings (${parseInt(index) + 1} of ${paramsInChunks.length}).`);
199-
await serverless.providers.aws.request('APIGateway', 'updateStage', paramsInChunks[index], stage, region);
198+
for (const [index, chunk] of paramsInChunks.entries()) {
199+
serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings (${index + 1} of ${paramsInChunks.length}).`);
200+
await applyUpdateStageForChunk(chunk, serverless, stage, region);
200201
}
201202

202203
serverless.cli.log(`[serverless-api-gateway-caching] Done updating API Gateway cache settings.`);
203204
}
204205

206+
const applyUpdateStageForChunk = async (chunk, serverless, stage, region) => {
207+
const maxRetries = 10;
208+
const baseDelay = BASE_RETRY_DELAY_MS;
209+
let attempt = 0;
210+
211+
while (attempt < maxRetries) {
212+
try {
213+
serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt ${attempt + 1}.`);
214+
await serverless.providers.aws.request('APIGateway', 'updateStage', chunk, stage, region);
215+
return;
216+
} catch (error) {
217+
// Check for specific error code first, fallback to message check
218+
if (
219+
(error.code === 'ConflictException' || error.message.includes('A previous change is still in progress'))
220+
) {
221+
attempt++;
222+
if (attempt >= maxRetries) {
223+
serverless.cli.log(`[serverless-api-gateway-caching] Maximum retries (${maxRetries}) reached. Failed to update API Gateway cache settings.`);
224+
// Log the full error for better debugging before throwing
225+
console.error('[serverless-api-gateway-caching] Final error details:', error);
226+
throw new Error(`Failed to update API Gateway cache settings after ${maxRetries} retries: ${error.message}`);
227+
}
228+
const delay = baseDelay * 2 ** attempt;
229+
serverless.cli.log(`[serverless-api-gateway-caching] Retrying (${attempt}/${maxRetries}) after ${delay}ms due to error: ${error.message}`);
230+
await new Promise((resolve) => setTimeout(resolve, delay));
231+
} else {
232+
console.error('[serverless-api-gateway-caching] Non-retryable error during update:', error);
233+
// Throw immediately for non-retryable errors or if string/code doesn't match
234+
throw new Error(`Failed to update API Gateway cache settings: ${error.message}`);
235+
}
236+
}
237+
}
238+
}
239+
205240
const updateStageCacheSettings = async (settings, serverless) => {
206241
// do nothing if caching settings are not defined
207242
if (settings.cachingEnabled == undefined) {
@@ -240,4 +275,7 @@ const updateStageCacheSettings = async (settings, serverless) => {
240275
await updateStageFor(serverless, params, settings.stage, settings.region);
241276
}
242277

243-
module.exports = updateStageCacheSettings;
278+
module.exports = {
279+
updateStageCacheSettings,
280+
applyUpdateStageForChunk
281+
}

test/creating-plugin.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,16 @@ describe('Creating plugin', () => {
9090
const serverless = { cli: { log: (message) => { logCalledWith = message } } };
9191
const restApiIdStub = {
9292
restApiExists: () => scenario.thereIsARestApi,
93-
outputRestApiIdTo: () => outputRestApiIdCalled = true
93+
outputRestApiIdTo: () => {}
94+
};
95+
const stageCacheStub = {
96+
updateStageCacheSettings: () => updateStageCacheSettingsCalled = true
9497
};
95-
const stageCacheStub = () => updateStageCacheSettingsCalled = true;
9698
const ApiGatewayCachingPlugin = proxyquire('../src/apiGatewayCachingPlugin', { './restApiId': restApiIdStub, './stageCache': stageCacheStub });
9799
const plugin = new ApiGatewayCachingPlugin(serverless, {});
98100

99-
before(() => {
100-
plugin.updateStage();
101+
before(async () => {
102+
await plugin.updateStage();
101103
});
102104

103105
it('should log a message', () => {

test/steps/when.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const updateStageCacheSettings = require('../../src/stageCache');
1+
const { updateStageCacheSettings } = require('../../src/stageCache');
22

33
const updating_stage_cache_settings = async (settings, serverless) => {
44
return await updateStageCacheSettings(settings, serverless);

test/updating-stage-cache-settings-for-additional-endpoints.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const given = require('../test/steps/given');
22
const when = require('../test/steps/when');
33
const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings');
4-
const updateStageCacheSettings = require('../src/stageCache');
4+
const { updateStageCacheSettings } = require('../src/stageCache');
55
const expect = require('chai').expect;
66

77
describe('Updating stage cache settings for additional endpoints defined as CloudFormation', () => {

test/updating-stage-cache-settings.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ const given = require('../test/steps/given');
22
const when = require('../test/steps/when');
33
const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings');
44
const UnauthorizedCacheControlHeaderStrategy = require('../src/UnauthorizedCacheControlHeaderStrategy');
5+
const chai = require('chai');
6+
const sinon = require('sinon');
57
const expect = require('chai').expect;
68

9+
const { applyUpdateStageForChunk } = require('../src/stageCache');
10+
11+
// Use a before block to asynchronously load and configure chai-as-promised
12+
before(async () => {
13+
const chaiAsPromised = await import('chai-as-promised');
14+
chai.use(chaiAsPromised.default); // Use .default when importing ESM dynamically
15+
});
16+
717
describe('Updating stage cache settings', () => {
818
let serverless, settings, requestsToAws, apiGatewayRequest;
919
const apiGatewayService = 'APIGateway', updateStageMethod = 'updateStage';
@@ -787,4 +797,102 @@ describe('Updating stage cache settings', () => {
787797
});
788798
});
789799
});
800+
801+
describe('applyUpdateStageForChunk function', () => {
802+
let serverless;
803+
let clock;
804+
const stage = 'test-stage';
805+
const region = 'eu-west-1';
806+
const chunk = {
807+
restApiId: 'test-api-id',
808+
stageName: stage,
809+
patchOperations: [{ op: 'replace', path: '/cacheClusterEnabled', value: 'true' }]
810+
};
811+
812+
beforeEach(() => {
813+
serverless = given.a_serverless_instance()
814+
.forStage(stage)
815+
.forRegion(region);
816+
clock = sinon.useFakeTimers();
817+
});
818+
819+
afterEach(() => {
820+
clock.restore();
821+
sinon.restore();
822+
});
823+
824+
it('should call aws.request once on success', async () => {
825+
const requestStub = sinon.stub(serverless.providers.aws, 'request').resolves();
826+
827+
await applyUpdateStageForChunk(chunk, serverless, stage, region);
828+
829+
expect(requestStub.calledOnce).to.be.true;
830+
expect(requestStub.getCall(0).args[0]).to.equal('APIGateway');
831+
expect(requestStub.getCall(0).args[1]).to.equal('updateStage');
832+
expect(requestStub.getCall(0).args[2]).to.deep.equal(chunk);
833+
expect(requestStub.getCall(0).args[3]).to.equal(stage);
834+
expect(requestStub.getCall(0).args[4]).to.equal(region);
835+
expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 1.');
836+
});
837+
838+
it('should retry on ConflictException and succeed on the second attempt', async () => {
839+
const conflictError = new Error('A previous change is still in progress');
840+
conflictError.code = 'ConflictException';
841+
842+
// Mock AWS request: fail first, succeed second
843+
const requestStub = sinon.stub(serverless.providers.aws, 'request');
844+
requestStub.onFirstCall().rejects(conflictError);
845+
requestStub.onSecondCall().resolves();
846+
847+
const promise = applyUpdateStageForChunk(chunk, serverless, stage, region);
848+
849+
// Advance clock to trigger the retry timeout
850+
await clock.tickAsync(1000); // Advance past the first delay (500 * 2^1)
851+
852+
await promise; // Wait for the function to complete
853+
854+
expect(requestStub.calledTwice).to.be.true;
855+
expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 1.');
856+
expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Retrying (1/10) after 1000ms due to error: A previous change is still in progress');
857+
expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 2.');
858+
});
859+
860+
it('should fail after max retries on persistent ConflictException', async () => {
861+
const conflictError = new Error('A previous change is still in progress');
862+
conflictError.code = 'ConflictException';
863+
const maxRetries = 10; // As defined in the function
864+
865+
// Mock AWS request to always fail with ConflictException
866+
const requestStub = sinon.stub(serverless.providers.aws, 'request').rejects(conflictError);
867+
868+
const promise = applyUpdateStageForChunk(chunk, serverless, stage, region);
869+
870+
// Advance clock past all retry delays
871+
for (let i = 1; i <= maxRetries; i++) {
872+
await clock.tickAsync(500 * (2 ** i) + 10); // Ensure delay is passed
873+
}
874+
875+
// Assert the promise rejects with the correct error
876+
await expect(promise).to.be.rejectedWith(`Failed to update API Gateway cache settings after ${maxRetries} retries: ${conflictError.message}`);
877+
expect(requestStub.callCount).to.equal(maxRetries);
878+
expect(serverless._logMessages).to.include(`[serverless-api-gateway-caching] Maximum retries (${maxRetries}) reached. Failed to update API Gateway cache settings.`);
879+
});
880+
881+
it('should fail immediately on non-retryable error', async () => {
882+
const otherError = new Error('Some other API Gateway error');
883+
otherError.code = 'BadRequestException'; // Example non-retryable code
884+
885+
// Mock AWS request to fail with a non-retryable error
886+
const requestStub = sinon.stub(serverless.providers.aws, 'request').rejects(otherError);
887+
const errorSpy = sinon.spy(console, 'error'); // Spy on console.error
888+
889+
const promise = applyUpdateStageForChunk(chunk, serverless, stage, region);
890+
891+
// Assert the promise rejects immediately
892+
await expect(promise).to.be.rejectedWith(`Failed to update API Gateway cache settings: ${otherError.message}`);
893+
expect(requestStub.calledOnce).to.be.true; // Should not retry
894+
expect(errorSpy.calledWith('[serverless-api-gateway-caching] Non-retryable error during update:', otherError)).to.be.true;
895+
errorSpy.restore(); // Restore the spy
896+
});
897+
});
790898
});

0 commit comments

Comments
 (0)