UNPKG

@button/divvy-client

Version:

NodeJS client to the Divvy quota service.

362 lines (306 loc) 10.7 kB
'use strict'; const carrier = require('carrier'); const net = require('net'); const Errors = require('./errors'); const util = require('./util'); const EventEmitter = require('events').EventEmitter; function defaultIfUndefined(val, defaultVal) { return val !== undefined ? val : defaultVal; } /** * Basic Divvy protocol client. * * Commands supported: The only command currently supported is `hit()`. * * Connection management: The client can be explicitly connected by * calling `connect()`. The connection will be kept open as long as possible. * If a command is called while the client is disconnected, the command * will be enqueued and the client will be connected. * * Timeouts: */ class Client extends EventEmitter { /** * Constructor. * * @param {string} host server hostname * @param {number} port server port number * @param {boolean} options.autoReconnect whether to automatically reconnect when the * server connection is unexpectedly closed (default true) * @param {number} options.defaultCommandTimeoutMillis default timeout for commands, or * `null` to not use a timouer (default 1000) * @param {number} options.maxPendingRequests maximum number of outgoing requests * that are allowed to be in flight at any one time. Requests made over this * limit will be dropped. (default 100) * @param {number} options.maxReconnectAttempts when `options.autoReconnect` is true, * maximum number of consecutive `connect()` attempts the client will automatically * make before giving up * @param {boolean} options.throttleConnect whether to throttle calls to `connect()` * such that they will not be attempted more than once every * `options.throttleConnectTimeoutMillis` (default true) * @param {boolean} options.throttleConnectTimeoutMillis when `throttleConnect` is * `true`, enforce a delay of this many milliseconds before connecting again. */ constructor(host, port, options) { super(); options = options || {}; this.host = host || 'localhost'; this.port = port || 8321; this.clientSocket = null; this.connected = false; // Requests that have not yet been written to the socket. Typically // empty unless disconnected. this.requestQueue = []; // Requests that have been written to the socket and are awaiting a // response. this.responseQueue = []; this.autoReconnect = defaultIfUndefined(options.autoReconnect, true); this.maxReconnectAttempts = defaultIfUndefined(options.maxReconnectAttempts, 5); this.throttleConnect = defaultIfUndefined(options.throttleConnect, true); this.throttleConnectTimeoutMillis = defaultIfUndefined( options.throttleConnectTimeoutMillis, 1000); this.maxPendingRequests = defaultIfUndefined(options.maxPendingRequests, 100); this.defaultCommandTimeoutMillis = defaultIfUndefined( options.defaultCommandTimeoutMillis, 1000); // Timeout handle, set when the client is unexpectedly closed. this.connectTimeoutHandle = null; this.lastDisconnectDate = null; this.reconnectAttempts = 0; this.onDisconnectedListener = this._onUnexpectedDisconnect.bind(this); } /** Schedules connection to the server; no-op if already connected. */ connect() { if (this.clientSocket || this.connectTimeoutHandle) { // Already connected or connecting. Ignore. return; } if (!this.throttleConnect) { this._doConnect(); return; } var delay = 0; if (this.lastDisconnectDate) { let age = Math.max(0, new Date() - this.lastDisconnectDate); delay = Math.max(0, this.throttleConnectTimeoutMillis - age); this.lastDisconnectDate = null; } if (delay <= 0) { this._doConnect(); } else { this.connectTimeoutHandle = setTimeout(() => this._doConnect(), delay); } } /** Manually close the connection. This will not trigger a reconnect. */ close() { if (this.connectTimeoutHandle) { clearTimeout(this.connectTimeoutHandle); this.connectTimeoutHandle = null; } if (!this.clientSocket) { return; } this.clientSocket.removeListener('close', this.onDisconnectedListener); this.clientSocket.destroy(); this._doDisconnect(); } /** * Perform a "hit" command against the given operation. * Upon success, the promise is resolve with an object containing * fields `isAllowed` (boolean), `currentCredit`, and * `nextResetSeconds`. * * @param {object} operation the operation object, consisting of string key-value pairs * (optional, default: `{}`) * @param {number} timeout the timeout in millis; if undefined, uses instance * default timeout. */ hit(operation, timeout) { operation = util.removeNullOrUndefinedKeys(operation || {}); if (timeout === undefined) { timeout = this.defaultCommandTimeoutMillis; } const numPending = this._numPendingRequests(); if (numPending >= this.maxPendingRequests) { return Promise.reject(new Errors.BacklogError(`Too many pending requests (${numPending})`)); } const operStr = util.operationToString(operation); var message; if (operStr) { message = `HIT ${operStr}\n`; } else { message = 'HIT\n'; } const pendingRequest = this._enqueueMessage(message, timeout); return pendingRequest.promise; } /** Returns total number of outstanding requests. */ _numPendingRequests() { // When connected, the requestQueue will typically be near-empty // (commands are flushed quickly) and the responseQueue will match // the arrival rate. // // When disconnected, the requestQueue will fill up and the responseQueue // will be empty (no writes to socket until connected). return this.requestQueue.length + this.responseQueue.length; } _doConnect() { this.connectTimeoutHandle = null; this.clientSocket = new net.Socket(); this.clientSocket.connect(this.port, this.host, this._onConnected.bind(this)); this.clientSocket.on('close', this.onDisconnectedListener); this.clientSocket.on('error', (err) => { this.emit('error', err); }); } _onConnected() { this.connected = true; this.reconnectAttempts = 0; carrier.carry(this.clientSocket, (line) => { this._receivedLine(line); }); this.emit('connected'); // One or more requests could have been enqueued while waiting to connect. this._flushPending(); } /** Do cleanup needed with every disconnect. */ _doDisconnect() { this.connected = false; this.clientSocket = null; this._rejectAllPending(); this.emit('disconnected'); } _onUnexpectedDisconnect() { this.lastDisconnectDate = new Date(); this._doDisconnect(); if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; this.connect(); } } /** Immediately rejects all requests, whether or not they have been sent. */ _rejectAllPending() { while (this.requestQueue.length) { let elem = this.requestQueue.shift(); if (elem.isRejectedOrResolved) { continue; } elem.reject(new Errors.DisconnectedError('Connection closed.')); } while (this.responseQueue.length) { let elem = this.responseQueue.shift(); if (elem.isRejectedOrResolved) { continue; } elem.reject(new Errors.DisconnectedError('Connection closed.')); } } /** * Parses a protocol response. * @param {[type]} line [description] * @return {[type]} [description] */ _parseLine(line) { const tokens = line.split(' '); if (tokens.length !== 4 || tokens[0] !== 'OK') { return new Errors.BadResponseError(line); } const isAllowed = tokens[1] === 'true'; const currentCredit = parseInt(tokens[2], 10); const nextResetSeconds = parseInt(tokens[3], 10); return { isAllowed: isAllowed, currentCredit: currentCredit, nextResetSeconds: nextResetSeconds }; } _receivedLine(line) { const currentRequest = this.responseQueue.shift(); if (!currentRequest) { throw new Errors.BadResponseError('Bug: Received an unexpected response.'); } // If we already rejected the request in flight, if (currentRequest.isRejectedOrResolved) { return; } const response = this._parseLine(line); if (response instanceof Error) { currentRequest.reject(response); } else { currentRequest.resolve(response); } } _enqueueMessage(message, timeout) { const pendingRequest = this._newPendingRequest(message, timeout); this.requestQueue.push(pendingRequest); if (!this.clientSocket) { this.connect(); } else if (this.connected) { this._flushPending(); } return pendingRequest; } _flushPending() { while (this.requestQueue.length) { let pendingRequest = this.requestQueue.shift(); if (pendingRequest.isRejectedOrResolved) { // Request timed out, don't even both sending. continue; } this.clientSocket.write(pendingRequest.message); this.responseQueue.push(pendingRequest); } } _newPendingRequest(message, timeout) { const pendingRequest = { message: message, isRejectedOrResolved: false }; pendingRequest.promise = new Promise((resolve, reject) => { pendingRequest.resolve = function(obj) { if (pendingRequest.isRejectedOrResolved) { return; } if (pendingRequest.timeout) { clearTimeout(pendingRequest.timeout); } pendingRequest.isRejectedOrResolved = true; resolve(obj); }; pendingRequest.reject = function(err) { if (pendingRequest.isRejectedOrResolved) { return; } if (pendingRequest.timeout) { clearTimeout(pendingRequest.timeout); } pendingRequest.isRejectedOrResolved = true; reject(err); }; if (timeout !== undefined && timeout !== null) { pendingRequest.timeout = setTimeout(function() { pendingRequest.reject(new Errors.TimeoutError('Timeout')); }, timeout); } }); return pendingRequest; } } /** * Stub of the public interface. */ Client.Stub = class DivvyClientStub extends Client { connect() { } close() { } hit() { return Promise.resolve({ isAllowed: true, currentCredit: 0, nextResetSeconds: 0 }); } }; /** Expose error hierarchy. */ Client.Error = Errors; module.exports = Client;