@okta/stormpath-migration
Version:
Migration tool to import Stormpath data into an Okta tenant
149 lines (130 loc) • 5.15 kB
JavaScript
/*!
* Copyright (c) 2017, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/
;
const os = require('os');
const Promise = require('bluebird');
const rp = require('request-promise');
const logger = require('./logger');
const config = require('./config');
const ConcurrencyPool = require('./concurrency-pool');
const packageJson = require('../package.json');
// The max number of concurrent requests. Note, this is different from the
// concurrencyLimit in the config, which defines the max number of concurrent
// transactions (which can encompass more than one request).
// Note: Endpoints have different concurrency limits - for example,
// api/v1/apps/{id}/user/types/default?expand=schema has a limit of 100
const REQUEST_CONCURRENCY_LIMIT = 70;
/**
* Calculates the time to schedule the next request in milliseconds - returns 0
* if the rate limit hasn't been hit, otherwise the time to the next rate
* limit reset.
* @param {Object} headers
* @param {String} headers['date']
* @param {Number} headers['x-rate-limit-remaining']
* @param {Number} headers['x-rate-limit-reset']
* @returns {Number} number of milliseconds to next available request
*/
function timeToNextRequest(res) {
const headers = res.headers || res.response.headers;
const remaining = Number(headers['x-rate-limit-remaining']);
// Must be greater than the concurrency limit because there could be
// outstanding requests that have not finished. Add an extra 10 for buffer.
if (remaining > REQUEST_CONCURRENCY_LIMIT + 10) {
logger.silly(`x-rate-limit-remaining ${remaining}`);
return 0;
}
const serverTimeUtcMs = Date.parse(headers.date);
const serverResetUtcMs = headers['x-rate-limit-reset'] * 1000;
// Add an extra buffer of 1000ms
const time = serverResetUtcMs - serverTimeUtcMs + 1000;
const rateLimit = headers['x-rate-limit-limit'];
const msg = `Rate limit reached (${rateLimit}), scheduling next request in ${time}ms ${remaining}`;
logger[remaining === 11 ? 'warn' : 'silly'](msg);
return time;
}
/**
* Schedules the next request, and executes it if concurrency and rate limits
* are not hit.
* @param {RequestScheduler} scheduler
* @param {String} msg
* @param {Function} fn
*/
async function schedule(scheduler, msg, fn) {
const requestId = scheduler.requestId++;
logger.silly(`Scheduling request id=${requestId}`, msg);
const resource = await scheduler.pool.acquire();
logger.silly(`Executing request id=${requestId}`);
try {
const res = await fn();
logger.silly(`Finished request id=${requestId} status=SUCCESS`);
setTimeout(resource.release, timeToNextRequest(res));
return res.body;
} catch (err) {
logger.silly(`Finished request id=${requestId} status=FAILURE`);
setTimeout(resource.release, timeToNextRequest(err));
throw err;
}
}
/**
* Constructs user agent based on environment information
*/
function getUserAgent() {
return `stormpath-migration/${packageJson.version} node/${process.versions.node} ${os.platform()}/${os.release()}`;
}
/**
* Class that wraps request-promise with two enhancements:
* 1. Limits the number of concurrent requests that are made at any given time
* 2. Defers executing new requests if rate-limit is hit
*/
class RequestScheduler {
/** Constructor */
constructor() {
this.requestId = 0;
this.pool = new ConcurrencyPool(REQUEST_CONCURRENCY_LIMIT);
this.rp = rp.defaults({
baseUrl: config.oktaBaseUrl,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `SSWS ${config.oktaApiToken}`,
'User-Agent': getUserAgent()
},
resolveWithFullResponse: true,
json: true,
simple: true,
agentOptions: {
keepAlive: false
}
});
}
/** Wrapper around request-promise.get */
get() {
const msg = `GET ${JSON.stringify(arguments)}`;
return schedule(this, msg, () => this.rp.get.apply(null, arguments));
}
/** Wrapper around request-promise.put */
put() {
const msg = `PUT ${JSON.stringify(arguments)}`;
return schedule(this, msg, () => this.rp.put.apply(null, arguments));
}
/** Wrapper around request-promise.post */
post() {
const msg = `POST ${JSON.stringify(arguments)}`;
return schedule(this, msg, () => this.rp.post.apply(null, arguments));
}
/** Wrapper around request-promise.delete */
delete() {
const msg = `DELETE ${JSON.stringify(arguments)}`;
return schedule(this, msg, () => this.rp.delete.apply(null, arguments));
}
}
module.exports = new RequestScheduler();