UNPKG

@azure/service-bus

Version:
485 lines • 23.6 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { MessageReceiver } from "./messageReceiver"; import { ReceiverHelper } from "./receiverHelper"; import { throwErrorIfConnectionClosed } from "../util/errors"; import { RetryOperationType, ConditionErrorNameMapper, } from "@azure/core-amqp"; import { receiverLogger as logger } from "../log"; import { ServiceBusMessageImpl } from "../serviceBusMessage"; import { translateServiceBusError } from "../serviceBusError"; import { abandonMessage, completeMessage, retryForever } from "../receivers/receiverCommon"; import { toProcessingSpanOptions } from "../diagnostics/instrumentServiceBusMessage"; import { AbortError } from "@azure/abort-controller"; import { tracingClient } from "../diagnostics/tracing"; /** * @internal * Describes the streaming receiver where the user can receive the message * by providing handler functions. */ export class StreamingReceiver extends MessageReceiver { /** * Whether we are currently subscribed (or subscribing) for receiving messages. * (this is irrespective of receiver state, etc... - it's just a simple flag to prevent * multiple subscribe() calls from happening on this instance) */ get isSubscribeActive() { return !this._receiverHelper.isSuspended(); } /** * Instantiate a new Streaming receiver for receiving messages with handlers. * * @param identifier - the name used to identifier the receiver * @param connectionContext - The client entity context. * @param options - Options for how you'd like to connect. */ constructor(identifier, connectionContext, entityPath, options) { super(identifier, connectionContext, entityPath, "streaming", options); /** * The maximum number of messages that should be * processed concurrently while in streaming mode. Once this limit has been reached, more * messages will not be received until the user's message handler has completed processing current message. * Default: 1 */ this.maxConcurrentCalls = 1; /** * Indicates whether the receiver is already actively * running `onDetached`. * This is expected to be true while the receiver attempts * to bring its link back up due to a retryable issue. */ this._isDetaching = false; /** * The user's message handlers, wrapped so any thrown exceptions are properly logged * or forwarded to the user's processError handler. */ this._messageHandlers = () => { throw new Error("messageHandlers are not set."); }; /** * Used so we can stub out retry in tests. */ this._retryForeverFn = retryForever; if (typeof options?.maxConcurrentCalls === "number" && options?.maxConcurrentCalls > 0) { this.maxConcurrentCalls = options.maxConcurrentCalls; } this._retryOptions = options?.retryOptions || {}; this._receiverHelper = new ReceiverHelper(() => ({ receiver: this.link, logPrefix: this.logPrefix, })); this._onAmqpClose = async (context) => { const receiverError = context.receiver && context.receiver.error; const receiver = this.link || context.receiver; logger.logError(receiverError, `${this.logPrefix} 'receiver_close' event occurred. The associated error is`); this._lockRenewer?.stopAll(this); if (receiver && !receiver.isItselfClosed()) { await this.onDetached(receiverError); } else { logger.verbose("%s 'receiver_close' event occurred on the receiver '%s' with address '%s' " + "because the sdk initiated it. Hence not calling detached from the _onAmqpClose" + "() handler.", this.logPrefix, this.name, this.address); } }; this._onSessionClose = async (context) => { const receiver = this.link || context.receiver; const sessionError = context.session && context.session.error; logger.logError(sessionError, `${this.logPrefix} 'session_close' event occurred. The associated error is`); this._lockRenewer?.stopAll(this); if (receiver && !receiver.isSessionItselfClosed()) { await this.onDetached(sessionError); } else { logger.verbose("%s 'session_close' event occurred on the session of receiver '%s' with address " + "'%s' because the sdk initiated it. Hence not calling detached from the _onSessionClose" + "() handler.", this.logPrefix, this.name, this.address); } }; this._onAmqpError = (context) => { const receiverError = context.receiver && context.receiver.error; if (receiverError) { const sbError = translateServiceBusError(receiverError); logger.logError(sbError, `${this.logPrefix} 'receiver_error' event occurred. The associated error is`); this._messageHandlers().processError({ error: sbError, errorSource: "receive", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier, }); } }; this._onSessionError = (context) => { const sessionError = context.session && context.session.error; if (sessionError) { const sbError = translateServiceBusError(sessionError); logger.logError(sbError, `${this.logPrefix} 'session_error' event occurred. The associated error is`); this._messageHandlers().processError({ error: sbError, errorSource: "receive", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier, }); } }; this._onAmqpMessage = async (context) => { // If the receiver got closed in PeekLock mode, avoid processing the message as we // cannot settle the message. if (this.receiveMode === "peekLock" && (!this.link || !this.link.isOpen())) { logger.verbose("%s Not calling the user's message handler for the current message " + "as the receiver is closed", this.logPrefix); return; } const bMessage = new ServiceBusMessageImpl(context.message, context.delivery, true, this.receiveMode, options.skipParsingBodyAsJson ?? false, options.skipConvertingDate ?? false); this._lockRenewer?.start(this, bMessage, (err) => { this._messageHandlers().processError({ error: err, errorSource: "renewLock", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier, }); }); try { await this._messageHandlers().processMessage(bMessage); } catch (err) { logger.logError(err, "%s An error occurred while running user's message handler for the message " + "with id '%s' on the receiver '%s'", this.logPrefix, bMessage.messageId, this.name); // Do not want renewLock to happen unnecessarily, while abandoning the message. Hence, // doing this here. Otherwise, this should be done in finally. this._lockRenewer?.stop(this, bMessage); const error = translateServiceBusError(err); // Nothing much to do if user's message handler throws. Let us try abandoning the message. if (!bMessage.delivery.remote_settled && error.code !== ConditionErrorNameMapper["com.microsoft:message-lock-lost"] && this.receiveMode === "peekLock" && this.isOpen() // only try to abandon the messages if the connection is still open ) { try { logger.logError(error, "%s Abandoning the message with id '%s' on the receiver '%s' since " + "an error occured: %O.", this.logPrefix, bMessage.messageId, this.name, error); await abandonMessage(bMessage, this._context, entityPath, undefined, this._retryOptions); } catch (abandonError) { const translatedError = translateServiceBusError(abandonError); logger.logError(translatedError, "%s An error occurred while abandoning the message with id '%s' on the " + "receiver '%s'", this.logPrefix, bMessage.messageId, this.name); this._messageHandlers().processError({ error: translatedError, errorSource: "abandon", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier, }); } } return; } finally { try { this._receiverHelper.addCredit(1); } catch (err) { // if we're aborting out of the receive operation we don't need to report it (the user already // knows the link is being torn down or stopped) if (err.name !== "AbortError") { logger.logError(err, `[${this.logPrefix}] Failed to add credit after receiving message`); await this._reportInternalError(err); } } } // If we've made it this far, then user's message handler completed fine. Let us try // completing the message. if (this.autoComplete && this.receiveMode === "peekLock" && !bMessage.delivery.remote_settled) { try { logger.verbose("%s Auto completing the message with id '%s' on " + "the receiver.", this.logPrefix, bMessage.messageId); await completeMessage(bMessage, this._context, entityPath, this._retryOptions); } catch (completeError) { const translatedError = translateServiceBusError(completeError); logger.logError(translatedError, "%s An error occurred while completing the message with id '%s' on the " + "receiver '%s'", this.logPrefix, bMessage.messageId, this.name); this._messageHandlers().processError({ error: translatedError, errorSource: "complete", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier, }); } } }; } _reportInternalError(error) { const messageHandlers = this._messageHandlers(); if (messageHandlers.forwardInternalErrors) { const errorArgs = { error, entityPath: this.entityPath, errorSource: "internal", fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, }; return messageHandlers.processError(errorArgs); } return Promise.resolve(); } _getHandlers() { return { onMessage: (context) => this._onAmqpMessage(context).catch((err) => this._reportInternalError(err)), onClose: (context) => this._onAmqpClose(context).catch((err) => this._reportInternalError(err)), onSessionClose: (context) => this._onSessionClose(context).catch((err) => this._reportInternalError(err)), onError: this._onAmqpError, onSessionError: this._onSessionError, }; } async stopReceivingMessages() { await this._receiverHelper.suspend(); if (this._subscribeCallPromise) { await this._subscribeCallPromise; } } async close() { await this._receiverHelper.suspend(); return super.close(); } /** * Starts the receiver by establishing an AMQP session and an AMQP receiver link on the session. * * Any errors thrown by this function will also be sent to the messageHandlers.processError function * _and_ thrown, ultimately from this method. * * NOTE: This function retries _infinitely_ until success! It is completely up to the user to break * out of this retry cycle otherwise by: * 1. closing the receiver * 2. Calling `close` on the subscription instance they received when they initially called subscribe(). * 3. aborting the abortSignal they passed in when calling subscribe (this also applies to initialization calls in onDetach) * * @param onMessage - The message handler to receive servicebus messages. * @param onError - The error handler to receive an error that occurs while receivin messages. */ async subscribe(messageHandlers, subscribeOptions) { // these options and message handlers will be re-used if/when onDetach is called. this._subscribeOptions = subscribeOptions; this._setMessageHandlers(messageHandlers, subscribeOptions); let promiseResolve; this._subscribeCallPromise = new Promise((resolve) => { promiseResolve = resolve; }); try { this._receiverHelper.resume(); return await this._subscribeImpl("subscribe"); } catch (err) { // callers aren't going to be in a good position to forward this error properly // so we do it here. await this._messageHandlers().processError({ entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, errorSource: "receive", error: err, identifier: this.identifier, }); throw err; } finally { promiseResolve?.(); this._subscribeCallPromise = undefined; } } /** * Wraps the individual message handlers with tracing and proper error handling * and assigns them to `this._messageHandlers` * * @param userHandlers - The user's message handlers * @param operationOptions - The subscribe(options) */ _setMessageHandlers(userHandlers, operationOptions) { const messageHandlers = { processError: async (args) => { try { args.error = translateServiceBusError(args.error); await userHandlers.processError(args); } catch (err) { await this._reportInternalError(err); logger.logError(err, `An error was thrown from the user's processError handler`); } }, processMessage: async (message) => { try { await tracingClient.withSpan("StreamReceiver.process", operationOptions ?? {}, () => userHandlers.processMessage(message), toProcessingSpanOptions(message, this, this._context.config, "process")); } catch (err) { this._messageHandlers().processError({ error: err, errorSource: "processMessageCallback", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, }); throw err; } }, postInitialize: async () => { if (!userHandlers.postInitialize) { return; } return userHandlers.postInitialize().catch((err) => this._messageHandlers().processError({ error: err, errorSource: "processMessageCallback", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, })); }, preInitialize: async () => { if (!userHandlers.preInitialize) { return; } return userHandlers.preInitialize().catch((err) => this._messageHandlers().processError({ error: err, errorSource: "processMessageCallback", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, })); }, forwardInternalErrors: userHandlers.forwardInternalErrors ?? false, }; this._messageHandlers = () => messageHandlers; } /** * Subscribes using the already assigned `this._messageHandlers` and `this._subscribeOptions` * * @returns A promise that will resolve when a link is created and we successfully add credits to it. */ async _subscribeImpl(caller) { try { // we don't expect to ever get an error from retryForever but bugs // do happen. return await this._retryForeverFn({ retryConfig: { connectionId: this._context.connection.id, operationType: RetryOperationType.receiverLink, abortSignal: this._subscribeOptions?.abortSignal, retryOptions: this._retryOptions, operation: () => this._initAndAddCreditOperation(caller), }, onError: (err) => this._messageHandlers().processError({ error: err, errorSource: "receive", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, }), logPrefix: this.logPrefix, logger, }); } catch (err) { try { await this._receiverHelper.suspend(); } catch (error) { logger.logError(error, `${this.logPrefix} receiver.suspend threw an error`); } throw err; } } /** * Initializes the link and adds credits. If any of these operations fail any created link will * be closed. * * @param caller - The caller which dictates whether or not we create a new name for our created link. * @param catchAndReportError - A function and reports an error but does not throw it. */ async _initAndAddCreditOperation(caller) { if (this._receiverHelper.isSuspended()) { // user has suspended us while we were initializing // the connection. Abort this attempt - if they attempt // resubscribe we'll just reinitialize. // This checks should happen before throwErrorIfConnectionClosed(); otherwise // we won't be able to break out of the retry-for-ever loops when user suspend us. throw new AbortError("Receiver was suspended during initialization."); } throwErrorIfConnectionClosed(this._context); await this._messageHandlers().preInitialize(); if (this._receiverHelper.isSuspended()) { // Need to check again as user can suspend us in preInitialize() throw new AbortError("Receiver was suspended during initialization."); } await this._init(this._createReceiverOptions(caller === "detach", this._getHandlers()), this._subscribeOptions?.abortSignal); try { await this._messageHandlers().postInitialize(); this._receiverHelper.addCredit(this.maxConcurrentCalls); } catch (err) { try { await this.closeLink(); } catch (error) { await this._messageHandlers().processError({ error, errorSource: "receive", entityPath: this.entityPath, fullyQualifiedNamespace: this._context.config.host, identifier: this.identifier, }); } throw err; } } /** * Will reconnect the receiver link if necessary. * @param receiverError - The receiver error or connection error, if any. */ async onDetached(receiverError) { try { logger.verbose(`${this.logPrefix} onDetached: reinitializing link.`); // User explicitly called `close` on the receiver, so link is already closed // and we can exit early. if (this.wasClosedPermanently) { logger.verbose(`${this.logPrefix} onDetached: link has been closed permanently, not reinitializing. `); return; } // Prevent multiple onDetached invocations from running concurrently. if (this._isDetaching) { // This can happen when the network connection goes down for some amount of time. // The first connection `disconnect` will trigger `onDetached` and attempt to retry // creating the connection/receiver link. // While those retry attempts fail (until the network connection comes back up), // we'll continue to see connection `disconnect` errors. // These should be ignored until the already running `onDetached` completes // its retry attempts or errors. logger.verbose(`${this.logPrefix} onDetached: Call to detached on streaming receiver '${this.name}' is already in progress.`); return; } this._isDetaching = true; const translatedError = receiverError ? translateServiceBusError(receiverError) : receiverError; logger.logError(translatedError, `${this.logPrefix} onDetached: Reinitializing receiver because of error`); // Clears the token renewal timer. Closes the link and its session if they are open. // Removes the link and its session if they are present in rhea's cache. await this.closeLink(); } catch (err) { logger.verbose(`${this.logPrefix} onDetached: Encountered an error when closing the previous link: `, err); } try { await this._subscribeImpl("detach"); } finally { this._isDetaching = false; } } removeLinkFromContext() { delete this._context.messageReceivers[this.name]; } } //# sourceMappingURL=streamingReceiver.js.map