UNPKG

mixpanel-browser

Version:

The official Mixpanel JavaScript browser client library

251 lines (224 loc) 9.29 kB
import { RequestQueue } from './request-queue'; import { console_with_prefix, _ } from './utils'; // eslint-disable-line camelcase // maximum interval between request retries after exponential backoff var MAX_RETRY_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes var logger = console_with_prefix('batch'); /** * RequestBatcher: manages the queueing, flushing, retry etc of requests of one * type (events, people, groups). * Uses RequestQueue to manage the backing store. * @constructor */ var RequestBatcher = function(storageKey, options) { this.errorReporter = options.errorReporter; this.queue = new RequestQueue(storageKey, { errorReporter: _.bind(this.reportError, this), storage: options.storage }); this.libConfig = options.libConfig; this.sendRequest = options.sendRequestFunc; this.beforeSendHook = options.beforeSendHook; this.stopAllBatching = options.stopAllBatchingFunc; // seed variable batch size + flush interval with configured values this.batchSize = this.libConfig['batch_size']; this.flushInterval = this.libConfig['batch_flush_interval_ms']; this.stopped = !this.libConfig['batch_autostart']; this.consecutiveRemovalFailures = 0; }; /** * Add one item to queue. */ RequestBatcher.prototype.enqueue = function(item, cb) { this.queue.enqueue(item, this.flushInterval, cb); }; /** * Start flushing batches at the configured time interval. Must call * this method upon SDK init in order to send anything over the network. */ RequestBatcher.prototype.start = function() { this.stopped = false; this.consecutiveRemovalFailures = 0; this.flush(); }; /** * Stop flushing batches. Can be restarted by calling start(). */ RequestBatcher.prototype.stop = function() { this.stopped = true; if (this.timeoutID) { clearTimeout(this.timeoutID); this.timeoutID = null; } }; /** * Clear out queue. */ RequestBatcher.prototype.clear = function() { this.queue.clear(); }; /** * Restore batch size configuration to whatever is set in the main SDK. */ RequestBatcher.prototype.resetBatchSize = function() { this.batchSize = this.libConfig['batch_size']; }; /** * Restore flush interval time configuration to whatever is set in the main SDK. */ RequestBatcher.prototype.resetFlush = function() { this.scheduleFlush(this.libConfig['batch_flush_interval_ms']); }; /** * Schedule the next flush in the given number of milliseconds. */ RequestBatcher.prototype.scheduleFlush = function(flushMS) { this.flushInterval = flushMS; if (!this.stopped) { // don't schedule anymore if batching has been stopped this.timeoutID = setTimeout(_.bind(this.flush, this), this.flushInterval); } }; /** * Flush one batch to network. Depending on success/failure modes, it will either * remove the batch from the queue or leave it in for retry, and schedule the next * flush. In cases of most network or API failures, it will back off exponentially * when retrying. * @param {Object} [options] * @param {boolean} [options.sendBeacon] - whether to send batch with * navigator.sendBeacon (only useful for sending batches before page unloads, as * sendBeacon offers no callbacks or status indications) */ RequestBatcher.prototype.flush = function(options) { try { if (this.requestInProgress) { logger.log('Flush: Request already in progress'); return; } options = options || {}; var timeoutMS = this.libConfig['batch_request_timeout_ms']; var startTime = new Date().getTime(); var currentBatchSize = this.batchSize; var batch = this.queue.fillBatch(currentBatchSize); var dataForRequest = []; var transformedItems = {}; _.each(batch, function(item) { var payload = item['payload']; if (this.beforeSendHook && !item.orphaned) { payload = this.beforeSendHook(payload); } if (payload) { dataForRequest.push(payload); } transformedItems[item['id']] = payload; }, this); if (dataForRequest.length < 1) { this.resetFlush(); return; // nothing to do } this.requestInProgress = true; var batchSendCallback = _.bind(function(res) { this.requestInProgress = false; try { // handle API response in a try-catch to make sure we can reset the // flush operation if something goes wrong var removeItemsFromQueue = false; if (options.unloading) { // update persisted data to include hook transformations this.queue.updatePayloads(transformedItems); } else if ( _.isObject(res) && res.error === 'timeout' && new Date().getTime() - startTime >= timeoutMS ) { this.reportError('Network timeout; retrying'); this.flush(); } else if ( _.isObject(res) && res.xhr_req && (res.xhr_req['status'] >= 500 || res.xhr_req['status'] === 429 || res.error === 'timeout') ) { // network or API error, or 429 Too Many Requests, retry var retryMS = this.flushInterval * 2; var headers = res.xhr_req['responseHeaders']; if (headers) { var retryAfter = headers['Retry-After']; if (retryAfter) { retryMS = (parseInt(retryAfter, 10) * 1000) || retryMS; } } retryMS = Math.min(MAX_RETRY_INTERVAL_MS, retryMS); this.reportError('Error; retry in ' + retryMS + ' ms'); this.scheduleFlush(retryMS); } else if (_.isObject(res) && res.xhr_req && res.xhr_req['status'] === 413) { // 413 Payload Too Large if (batch.length > 1) { var halvedBatchSize = Math.max(1, Math.floor(currentBatchSize / 2)); this.batchSize = Math.min(this.batchSize, halvedBatchSize, batch.length - 1); this.reportError('413 response; reducing batch size to ' + this.batchSize); this.resetFlush(); } else { this.reportError('Single-event request too large; dropping', batch); this.resetBatchSize(); removeItemsFromQueue = true; } } else { // successful network request+response; remove each item in batch from queue // (even if it was e.g. a 400, in which case retrying won't help) removeItemsFromQueue = true; } if (removeItemsFromQueue) { this.queue.removeItemsByID( _.map(batch, function(item) { return item['id']; }), _.bind(function(succeeded) { if (succeeded) { this.consecutiveRemovalFailures = 0; this.flush(); // handle next batch if the queue isn't empty } else { this.reportError('Failed to remove items from queue'); if (++this.consecutiveRemovalFailures > 5) { this.reportError('Too many queue failures; disabling batching system.'); this.stopAllBatching(); } else { this.resetFlush(); } } }, this) ); } } catch(err) { this.reportError('Error handling API response', err); this.resetFlush(); } }, this); var requestOptions = { method: 'POST', verbose: true, ignore_json_errors: true, // eslint-disable-line camelcase timeout_ms: timeoutMS // eslint-disable-line camelcase }; if (options.unloading) { requestOptions.transport = 'sendBeacon'; } logger.log('MIXPANEL REQUEST:', dataForRequest); this.sendRequest(dataForRequest, requestOptions, batchSendCallback); } catch(err) { this.reportError('Error flushing request queue', err); this.resetFlush(); } }; /** * Log error to global logger and optional user-defined logger. */ RequestBatcher.prototype.reportError = function(msg, err) { logger.error.apply(logger.error, arguments); if (this.errorReporter) { try { if (!(err instanceof Error)) { err = new Error(msg); } this.errorReporter(msg, err); } catch(err) { logger.error(err); } } }; export { RequestBatcher };