UNPKG

lifion-kinesis

Version:

Lifion client for Amazon Kinesis Data streams

470 lines (437 loc) 15.5 kB
/** * Module that wraps the calls to the AWS Kinesis client. Calls are wrapped so they can be * retried with a custom logic instead of the one provided by the AWS SDK. In addition to retries, * the call stacks are preserved even in async/await calls by using the `CAPTURE_STACK_TRACE` * environment variable. * * @module kinesis-client * @private */ import retry from 'async-retry'; import { Kinesis, waitUntilStreamExists, waitUntilStreamNotExists } from '@aws-sdk/client-kinesis'; import { getStackObj, shouldBailRetry, transformErrorStack } from './utils.js'; import { reportError, reportRecordSent, reportResponse } from './stats.js'; import { assertCredentialsSupported } from './aws-credentials.js'; const RETRIABLE_PUT_ERRORS = new Set([ 'EADDRINUSE', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ESOCKETTIMEDOUT', 'ETIMEDOUT', 'NetworkingError', 'ProvisionedThroughputExceededException', 'TimeoutError' ]); const STREAM_WAIT_TIME_SECONDS = 180; const statsSource = 'kinesis'; const stoppedClients = new WeakSet(); /** * Calls a method on the given Kinesis client. The call stack is preserved, and the results of the * call are aggregated in the stats. Retries in this function are the original ones provided by the * AWS SDK. * * @param {Object} client - An instance of the AWS Kinesis client. * @param {string} methodName - The name of the method to call. * @param {string} streamName - The name of the Kinesis stream for which the call relates to. * @param {...*} args - The arguments of the method call. * @fulfil {*} - The original response from the AWS Kinesis call. * @reject {Error} - The error details from AWS Kinesis with a corrected error stack. * @returns {Promise} * @private */ async function sdkCall(client, methodName, streamName, ...args) { const stackObj = getStackObj(sdkCall); try { return client[methodName](...args) .then((response) => { reportResponse(statsSource, streamName); return response; }) .catch((err) => { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); throw error; }); } catch (err) { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); throw error; } } /** * Calls a method on the given Kinesis client. The call stack is preserved, and the results of the * call are aggregated in the stats. Retries in this function are based on a custom logic replacing * the one provided by the AWS SDK. * * @param {Object} client - An instance of the AWS Kinesis client. * @param {string} methodName - The name of the method to call. * @param {string} streamName - The name of the Kinesis stream for which the call relates to. * @param {Object} retryOpts - The [retry options as in async-retry]{@link external:AsyncRetry}. * @param {...*} args - The argument of the method call. * @fulfil {*} - The original response from the AWS Kinesis call. * @reject {Error} - The error details from AWS Kinesis with a corrected error stack. * @returns {Promise} * @private */ function retriableSdkCall(client, methodName, streamName, retryOpts, ...args) { const stackObj = getStackObj(retriableSdkCall); return retry((bail) => { if (stoppedClients.has(client)) { bail(new Error('The Kinesis client is stopped.')); return undefined; } try { return client[methodName](...args) .then((response) => { reportResponse(statsSource, streamName); return response; }) .catch((err) => { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); if (!shouldBailRetry(err)) throw error; else bail(error); }); } catch (err) { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); bail(error); return undefined; } }, retryOpts); } /** * A class that wraps the AWS Kinesis client. * * @alias module:kinesis-client */ class KinesisClient { #data = {}; /** * Initializes the AWS Kinesis internal instance and prepares the retry logic. * * @param {Object} options - The initialization options. * @param {Object} options.awsOptions - The initialization options for the AWS Kinesis client. * @param {Object} options.logger - An instace of a logger. * @param {Object} [options.retryOptions] - The [retry options as in async-retry]{@link * external:AsyncRetry}, merged over the defaults (which retry forever with backoff). * @param {string} options.streamName - The name of the Kinesis stream for which calls relate to. * @param {boolean} options.supressThroughputWarnings - Flag indicating whether or not * to supress ProvisionedThroughputExceededException warning logs. */ constructor({ awsOptions, logger, retryOptions, streamName, supressThroughputWarnings }) { assertCredentialsSupported(awsOptions); const client = new Kinesis(awsOptions); const retryOpts = { forever: true, maxTimeout: 5 * 60 * 1000, minTimeout: 1000, onRetry: (err) => { const { code, message, requestId, statusCode } = err; const loggerMethod = supressThroughputWarnings && code === 'ProvisionedThroughputExceededException' ? 'debug' : 'warn'; logger[loggerMethod]( `Trying to recover from AWS Kinesis error…\n${[ `\t- Message: ${message}`, `\t- Request ID: ${requestId}`, `\t- Code: ${code} (${statusCode})`, `\t- Stream: ${streamName}` ].join('\n')}` ); }, randomize: true, ...retryOptions }; Object.assign(this.#data, { client, endpoint: awsOptions && awsOptions.endpoint, retryOpts, streamName }); } /** * Stops the client so its pending and future retriable calls stop retrying. */ stop() { const { client } = this.#data; stoppedClients.add(client); } /** * Resolves the AWS credentials the client is configured with. The enhanced fan-out signer uses * this so it signs requests with the same identity as the rest of the client. * * @fulfil {Object} - The resolved credentials. * @returns {Promise} */ getCredentials() { const { client } = this.#data; return client.config.credentials(); } /** * Adds or updates tags for the specified Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ addTagsToStream(...args) { const { client, streamName } = this.#data; return sdkCall(client, 'addTagsToStream', streamName, ...args); } /** * Creates a Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ createStream(...args) { const { client, streamName } = this.#data; return sdkCall(client, 'createStream', streamName, ...args).catch((err) => { if (err.code !== 'ResourceInUseException') throw err; }); } /** * To deregister a consumer, provide its ARN. * * @param {...*} args - The arguments. * @returns {Promise} */ deregisterStreamConsumer(...args) { const { client, streamName } = this.#data; return sdkCall(client, 'deregisterStreamConsumer', streamName, ...args); } /** * Describes the specified Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ describeStream(...args) { const { client, retryOpts, streamName } = this.#data; return retriableSdkCall(client, 'describeStream', streamName, retryOpts, ...args); } /** * Summarizes the specified Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ describeStreamSummary(...args) { const { client, retryOpts, streamName } = this.#data; return sdkCall(client, 'describeStreamSummary', streamName, ...args).catch((err) => { if (err.code !== 'UnknownOperationException') throw err; return retriableSdkCall(client, 'describeStream', streamName, retryOpts, ...args).then( (data) => { const { StreamDescription } = data; return { StreamDescriptionSummary: StreamDescription }; } ); }); } /** * Gets data records from a Kinesis data stream's shard. * * @param {...*} args - The arguments. * @returns {Promise} */ async getRecords(...args) { const { client, retryOpts, streamName } = this.#data; const response = await retriableSdkCall(client, 'getRecords', streamName, retryOpts, ...args); const { Records } = response; if (Records) { for (const record of Records) { if (record.Data) record.Data = Buffer.from(record.Data); } } return response; } /** * Gets an Amazon Kinesis shard iterator. * * @param {...*} args - The arguments. * @returns {Promise} */ getShardIterator(...args) { const { client, retryOpts, streamName } = this.#data; return retriableSdkCall(client, 'getShardIterator', streamName, retryOpts, ...args); } /** * Tells whether the endpoint of the client is local or not. * * @returns {boolean} `true` if the endpoints is local, `false` otherwise. */ isEndpointLocal() { const { endpoint } = this.#data; return ( typeof endpoint === 'string' && (endpoint.includes('localhost') || endpoint.includes('localstack')) ); } /** * Lists the shards in a stream and provides information about each shard. * * @param {...*} args - The arguments. * @returns {Promise} */ listShards(...args) { const { client, retryOpts, streamName } = this.#data; return retriableSdkCall(client, 'listShards', streamName, retryOpts, ...args); } /** * Lists the consumers registered to receive data from a stream using enhanced fan-out, and * provides information about each consumer. * * @param {...*} args - The arguments. * @returns {Promise} */ listStreamConsumers(...args) { const { client, retryOpts, streamName } = this.#data; return retriableSdkCall(client, 'listStreamConsumers', streamName, retryOpts, ...args); } /** * Lists the tags for the specified Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ listTagsForStream(...args) { const { client, retryOpts, streamName } = this.#data; return retriableSdkCall(client, 'listTagsForStream', streamName, retryOpts, ...args); } /** * Writes a single data record into an Amazon Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ putRecord(...args) { const { client, retryOpts, streamName } = this.#data; const stackObj = getStackObj(retriableSdkCall); return retry((bail) => { try { return client .putRecord(...args) .then((result) => { reportResponse(statsSource, streamName); reportRecordSent(streamName); return result; }) .catch((err) => { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); if (RETRIABLE_PUT_ERRORS.has(err.code)) throw error; else bail(error); }); } catch (err) { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); bail(error); return undefined; } }, retryOpts); } /** * Writes multiple data records into a Kinesis data stream in a single call (also referred to as * a PutRecords request). * * @param {...*} args - The arguments. * @returns {Promise} */ putRecords(...args) { const { client, retryOpts, streamName } = this.#data; const stackObj = getStackObj(retriableSdkCall); const [firstArg, ...restOfArgs] = args; let records = firstArg.Records; const results = []; return retry((bail) => { try { return client .putRecords({ ...firstArg, Records: records }, ...restOfArgs) .then((payload) => { const { EncryptionType, FailedRecordCount, Records } = payload; const failedCount = FailedRecordCount; const recordsCount = Records.length; const nextRecords = []; for (let i = 0; i < recordsCount; i += 1) { if (Records[i].ErrorCode) nextRecords.push(records[i]); else results.push(Records[i]); } reportResponse(statsSource, streamName); if (failedCount < records.length) { reportRecordSent(streamName); } if (failedCount === 0) { return { EncryptionType, Records: results }; } records = nextRecords; const error = new Error(`Failed to write ${failedCount} of ${recordsCount} record(s).`); error.code = 'ProvisionedThroughputExceededException'; throw error; }) .catch((err) => { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); if (RETRIABLE_PUT_ERRORS.has(err.code)) throw error; else bail(error); }); } catch (err) { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); bail(error); return undefined; } }, retryOpts); } /** * Registers a consumer with a Kinesis data stream. * * @param {...*} args - The arguments. * @returns {Promise} */ registerStreamConsumer(...args) { const { client, streamName } = this.#data; return sdkCall(client, 'registerStreamConsumer', streamName, ...args); } /** * Enables or updates server-side encryption using an AWS KMS key for a specified stream. * * @param {...*} args - The arguments. * @returns {Promise} */ startStreamEncryption(...args) { const { client, streamName } = this.#data; return sdkCall(client, 'startStreamEncryption', streamName, ...args).catch((err) => { const { code } = err; if (code !== 'UnknownOperationException' && code !== 'ResourceInUseException') throw err; }); } /** * Waits for the specified Kinesis stream to reach the given state. * * @param {string} state - The state to wait for, either `streamExists` or `streamNotExists`. * @param {Object} params - The parameters identifying the stream (e.g. `{ StreamName }`). * @fulfil {Object} - The last describe-stream response observed by the waiter. * @returns {Promise} */ async waitFor(state, params) { const { client, streamName } = this.#data; const stackObj = getStackObj(this.waitFor); const waiter = state === 'streamNotExists' ? waitUntilStreamNotExists : waitUntilStreamExists; try { const { reason } = await waiter({ client, maxWaitTime: STREAM_WAIT_TIME_SECONDS }, params); reportResponse(statsSource, streamName); return reason; } catch (err) { const error = transformErrorStack(err, stackObj); reportError(statsSource, error, streamName); throw error; } } } /** * @external AsyncRetry * @see https://github.com/zeit/async-retry#api */ export default KinesisClient;