Skip to content

Commit ec1e757

Browse files
committed
Adds retry with exponential backoff to EC2MetadataCredentials and ECSCredentials. Adds queuing of refresh callbacks to ECSCredentials to reduce the number of asynchronous requests to retrieve credentials.
1 parent 7e3c279 commit ec1e757

9 files changed

+355
-73
lines changed

lib/config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ require('./credentials/credential_provider_chain');
100100
* Currently supported options are:
101101
*
102102
* * **base** [Integer] — The base number of milliseconds to use in the
103-
* exponential backoff for operation retries. Defaults to 100 ms.
103+
* exponential backoff for operation retries. Defaults to 30 ms.
104104
* * **customBackoff ** [function] — A custom function that accepts a retry count
105105
* and returns the amount of time to delay in milliseconds. The `base` option will be
106106
* ignored if this option is supplied.

lib/credentials/ec2_metadata_credentials.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,30 @@ require('../metadata_service');
99
* can connect, and credentials are available, these will be used with zero
1010
* configuration.
1111
*
12-
* This credentials class will timeout after 1 second of inactivity by default.
12+
* This credentials class will by default timeout after 1 second of inactivity
13+
* and retry 3 times.
1314
* If your requests to the EC2 metadata service are timing out, you can increase
14-
* the value by configuring them directly:
15+
* these values by configuring them directly:
1516
*
1617
* ```javascript
1718
* AWS.config.credentials = new AWS.EC2MetadataCredentials({
18-
* httpOptions: { timeout: 5000 } // 5 second timeout
19+
* httpOptions: { timeout: 5000 }, // 5 second timeout
20+
* maxRetries: 10, // retry 10 times
21+
* retryDelayOptions: { base: 30 } // see AWS.Config for information
1922
* });
2023
* ```
2124
*
25+
* @see AWS.Config.retryDelayOptions
26+
*
2227
* @!macro nobrowser
2328
*/
2429
AWS.EC2MetadataCredentials = AWS.util.inherit(AWS.Credentials, {
2530
constructor: function EC2MetadataCredentials(options) {
2631
AWS.Credentials.call(this);
2732

2833
options = options ? AWS.util.copy(options) : {};
34+
options = AWS.util.merge(
35+
{maxRetries: this.defaultMaxRetries}, options);
2936
if (!options.httpOptions) options.httpOptions = {};
3037
options.httpOptions = AWS.util.merge(
3138
{timeout: this.defaultTimeout}, options.httpOptions);
@@ -39,6 +46,11 @@ AWS.EC2MetadataCredentials = AWS.util.inherit(AWS.Credentials, {
3946
*/
4047
defaultTimeout: 1000,
4148

49+
/**
50+
* @api private
51+
*/
52+
defaultMaxRetries: 3,
53+
4254
/**
4355
* Loads the credentials from the instance metadata service
4456
*

lib/credentials/ecs_credentials.js

+45-23
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@ var AWS = require('../core');
88
* in the container. If valid credentials are returned in the response, these
99
* will be used with zero configuration.
1010
*
11-
* This credentials class will timeout after 1 second of inactivity by default.
11+
* This credentials class will by default timeout after 1 second of inactivity
12+
* and retry 3 times.
1213
* If your requests to the relative URI are timing out, you can increase
1314
* the value by configuring them directly:
1415
*
1516
* ```javascript
1617
* AWS.config.credentials = new AWS.ECSCredentials({
17-
* httpOptions: { timeout: 5000 } // 5 second timeout
18+
* httpOptions: { timeout: 5000 }, // 5 second timeout
19+
* maxRetries: 10, // retry 10 times
20+
* retryDelayOptions: { base: 30 } // see AWS.Config for information
1821
* });
1922
* ```
2023
*
24+
* @see AWS.Config.retryDelayOptions
25+
*
2126
* @!macro nobrowser
2227
*/
2328
AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
@@ -40,6 +45,11 @@ AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
4045
*/
4146
host: '169.254.170.2',
4247

48+
/**
49+
* @api private
50+
*/
51+
maxRetries: 3,
52+
4353
/**
4454
* Sets the name of the ECS environment variable to check for relative URI
4555
* If changed, please change the name in the documentation for defaultProvider
@@ -69,22 +79,17 @@ AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
6979
*/
7080
request: function request(path, callback) {
7181
path = path || '/';
72-
73-
var data = '';
74-
var http = AWS.HttpClient.getInstance();
7582
var httpRequest = new AWS.HttpRequest('http://' + this.host + path);
7683
httpRequest.method = 'GET';
7784
httpRequest.headers.Accept = 'application/json';
78-
var httpOptions = this.httpOptions;
79-
80-
process.nextTick(function() {
81-
http.handleRequest(httpRequest, httpOptions, function(httpResponse) {
82-
httpResponse.on('data', function(chunk) { data += chunk.toString(); });
83-
httpResponse.on('end', function() { callback(null, data); });
84-
}, callback);
85-
});
85+
AWS.util.handleRequestWithTimeoutRetries(httpRequest, this, callback);
8686
},
8787

88+
/**
89+
* @api private
90+
*/
91+
refreshQueue: [],
92+
8893
/**
8994
* Loads the credentials from the relative URI specified by container
9095
*
@@ -98,18 +103,33 @@ AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
98103
*/
99104
refresh: function refresh(callback) {
100105
var self = this;
106+
var refreshQueue = self.refreshQueue;
101107
if (!callback) callback = function(err) { if (err) throw err; };
108+
refreshQueue.push({
109+
provider: self,
110+
errCallback: callback
111+
});
112+
if (refreshQueue.length > 1) { return; }
113+
114+
function callbacks(err, creds) {
115+
var call, cb;
116+
while ((call = refreshQueue.shift()) !== undefined) {
117+
cb = call.errCallback;
118+
if (!err) AWS.util.update(call.provider, creds);
119+
cb(err);
120+
}
121+
}
102122

103123
if (process === undefined) {
104-
callback(AWS.util.error(
124+
callbacks(AWS.util.error(
105125
new Error('No process info available'),
106126
{ code: 'ECSCredentialsProviderFailure' }
107127
));
108128
return;
109129
}
110130
var relativeUri = this.getECSRelativeUri();
111131
if (relativeUri === undefined) {
112-
callback(AWS.util.error(
132+
callbacks(AWS.util.error(
113133
new Error('Variable ' + this.environmentVar + ' not set.'),
114134
{ code: 'ECSCredentialsProviderFailure' }
115135
));
@@ -119,13 +139,15 @@ AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
119139
this.request(relativeUri, function(err, data) {
120140
if (!err) {
121141
try {
122-
var creds = JSON.parse(data);
123-
if (self.credsFormatIsValid(creds)) {
124-
self.expired = false;
125-
self.accessKeyId = creds.AccessKeyId;
126-
self.secretAccessKey = creds.SecretAccessKey;
127-
self.sessionToken = creds.Token;
128-
self.expireTime = new Date(creds.Expiration);
142+
data = JSON.parse(data);
143+
if (self.credsFormatIsValid(data)) {
144+
var creds = {
145+
expired: false,
146+
accessKeyId: data.AccessKeyId,
147+
secretAccessKey: data.SecretAccessKey,
148+
sessionToken: data.Token,
149+
expireTime: new Date(data.Expiration)
150+
};
129151
} else {
130152
throw AWS.util.error(
131153
new Error('Response data is not in valid format'),
@@ -136,7 +158,7 @@ AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, {
136158
err = dataError;
137159
}
138160
}
139-
callback(err);
161+
callbacks(err, creds);
140162
});
141163
}
142164
});

lib/metadata_service.js

+5-11
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ AWS.MetadataService = inherit({
4444
*
4545
* * **timeout** (Number) — a timeout value in milliseconds to wait
4646
* before aborting the connection. Set to 0 for no timeout.
47+
* @option options maxRetries [Integer] the maximum number of retries to
48+
* perform for timeout errors
49+
* @option options retryDelayOptions [map] A set of options to configure the
50+
* retry delay on retryable errors. See AWS.Config for details.
4751
*/
4852
constructor: function MetadataService(options) {
4953
AWS.util.update(this, options);
@@ -61,19 +65,9 @@ AWS.MetadataService = inherit({
6165
*/
6266
request: function request(path, callback) {
6367
path = path || '/';
64-
65-
var data = '';
66-
var http = AWS.HttpClient.getInstance();
6768
var httpRequest = new AWS.HttpRequest('http://' + this.host + path);
6869
httpRequest.method = 'GET';
69-
var httpOptions = this.httpOptions;
70-
71-
process.nextTick(function() {
72-
http.handleRequest(httpRequest, httpOptions, function(httpResponse) {
73-
httpResponse.on('data', function(chunk) { data += chunk.toString(); });
74-
httpResponse.on('end', function() { callback(null, data); });
75-
}, callback);
76-
});
70+
AWS.util.handleRequestWithTimeoutRetries(httpRequest, this, callback);
7771
},
7872

7973
/**

lib/service.js

+1-8
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,7 @@ AWS.Service = inherit({
311311
* @api private
312312
*/
313313
retryDelays: function retryDelays(retryCount) {
314-
var retryDelayOptions = this.config.retryDelayOptions || {};
315-
var customBackoff = retryDelayOptions.customBackoff || null;
316-
if (typeof customBackoff === 'function') {
317-
return customBackoff(retryCount);
318-
}
319-
var base = retryDelayOptions.base || 30;
320-
var delay = Math.random() * (Math.pow(2, retryCount) * base);
321-
return delay;
314+
return AWS.util.calculateRetryDelay(retryCount, this.config.retryDelayOptions);
322315
},
323316

324317
/**

lib/util.js

+45
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,51 @@ var util = {
793793
if (typeof service !== 'string') service = service.serviceIdentifier;
794794
if (typeof service !== 'string' || !metadata.hasOwnProperty(service)) return false;
795795
return !!metadata[service].dualstackAvailable;
796+
},
797+
798+
/**
799+
* @api private
800+
*/
801+
calculateRetryDelay: function calculateRetryDelay(retryCount, retryDelayOptions) {
802+
if (!retryDelayOptions) retryDelayOptions = {};
803+
var customBackoff = retryDelayOptions.customBackoff || null;
804+
if (typeof customBackoff === 'function') {
805+
return customBackoff(retryCount);
806+
}
807+
var base = retryDelayOptions.base || 30;
808+
var delay = Math.random() * (Math.pow(2, retryCount) * base);
809+
return delay;
810+
},
811+
812+
/**
813+
* @api private
814+
*/
815+
handleRequestWithTimeoutRetries: function handleRequestWithTimeoutRetries(httpRequest, options, cb) {
816+
if (!options) options = {};
817+
var http = AWS.HttpClient.getInstance();
818+
var httpOptions = options.httpOptions || {};
819+
var retryCount = 0;
820+
821+
var errCallback = function(err) {
822+
var maxRetries = options.maxRetries || 0;
823+
if (err && err.code === 'TimeoutError' && retryCount < maxRetries) {
824+
retryCount++;
825+
var delay = util.calculateRetryDelay(retryCount, options.retryDelayOptions);
826+
setTimeout(sendRequest, delay);
827+
} else {
828+
cb(err);
829+
}
830+
};
831+
832+
var sendRequest = function() {
833+
var data = '';
834+
http.handleRequest(httpRequest, httpOptions, function(httpResponse) {
835+
httpResponse.on('data', function(chunk) { data += chunk.toString(); });
836+
httpResponse.on('end', function() { cb(null, data); });
837+
}, errCallback);
838+
};
839+
840+
process.nextTick(sendRequest);
796841
}
797842

798843
};

test/credentials.spec.coffee

+45-15
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,12 @@ if AWS.util.isNode()
427427

428428
describe 'AWS.ECSCredentials', ->
429429
creds = null
430+
responseData = {
431+
AccessKeyId: 'KEY',
432+
SecretAccessKey: 'SECRET',
433+
Token: 'TOKEN',
434+
Expiration: (new Date(0)).toISOString()
435+
}
430436

431437
beforeEach ->
432438
creds = new AWS.ECSCredentials(host: 'host')
@@ -437,13 +443,8 @@ if AWS.util.isNode()
437443

438444
mockEndpoint = (expireTime) ->
439445
helpers.spyOn(creds, 'request').andCallFake (path, cb) ->
440-
cb null,
441-
JSON.stringify({
442-
AccessKeyId: 'KEY'
443-
SecretAccessKey: 'SECRET'
444-
Token: 'TOKEN'
445-
Expiration: expireTime.toISOString()
446-
})
446+
expiration = expireTime.toISOString()
447+
cb null, JSON.stringify(AWS.util.merge(responseData, {Expiration: expiration}))
447448

448449
describe 'constructor', ->
449450
it 'allows passing of options', ->
@@ -479,16 +480,10 @@ if AWS.util.isNode()
479480

480481
describe 'credsFormatIsValid', ->
481482
it 'returns false when data is missing required property', ->
482-
responseData = {AccessKeyId: 'KEY', SecretAccessKey: 'SECRET', Token: 'TOKEN'}
483-
expect(creds.credsFormatIsValid(responseData)).to.be.false
483+
incompleteData = {AccessKeyId: 'KEY', SecretAccessKey: 'SECRET', Token: 'TOKEN'}
484+
expect(creds.credsFormatIsValid(incompleteData)).to.be.false
484485

485486
it 'returns true when data has all required properties', ->
486-
responseData = {
487-
AccessKeyId: 'KEY',
488-
SecretAccessKey: 'SECRET',
489-
Token: 'TOKEN',
490-
Expiration: (new Date(0)).toISOString()
491-
}
492487
expect(creds.credsFormatIsValid(responseData)).to.be.true
493488

494489
describe 'needsRefresh', ->
@@ -519,6 +514,41 @@ if AWS.util.isNode()
519514
expect(spy.calls.length).to.equal(0)
520515
expect(callbackErr).to.not.be.null
521516

517+
it 'retries up to specified maxRetries for timeout errors', (done) ->
518+
process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path'
519+
options = {maxRetries: 3}
520+
creds = new AWS.ECSCredentials(options)
521+
httpClient = AWS.HttpClient.getInstance()
522+
spy = helpers.spyOn(httpClient, 'handleRequest').andCallFake (httpReq, httpOp, cb, errCb) ->
523+
errCb({code: 'TimeoutError'})
524+
creds.refresh (err) ->
525+
expect(err).to.not.be.null
526+
expect(err.code).to.equal('TimeoutError')
527+
expect(spy.calls.length).to.equal(4)
528+
done()
529+
530+
it 'makes only one request when multiple calls are made before first one finishes', (done) ->
531+
concurrency = countdown = 10
532+
process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path'
533+
creds = AWS.ECSCredentials.prototype
534+
spy = mockEndpoint(new Date(0))
535+
spy = helpers.spyOn(AWS.ECSCredentials.prototype, 'request').andCallFake (path, cb) ->
536+
respond = ->
537+
cb null, JSON.stringify(responseData)
538+
process.nextTick(respond)
539+
providers = []
540+
callRefresh = (ind) ->
541+
providers[ind] = new AWS.ECSCredentials(host: 'host')
542+
providers[ind].refresh (err) ->
543+
expect(err).to.equal(null)
544+
expect(providers[ind].accessKeyId).to.equal('KEY')
545+
countdown--
546+
if countdown == 0
547+
expect(spy.calls.length).to.equal(1)
548+
done()
549+
for x in [1..concurrency]
550+
callRefresh(x - 1)
551+
522552
describe 'AWS.TemporaryCredentials', ->
523553
creds = null
524554

0 commit comments

Comments
 (0)