using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using RabbitMQ.Client; using RabbitMQ.Client.Events; using RabbitMQ.Client.Exceptions; using Tapeti.Config; using Tapeti.Default; using Tapeti.Exceptions; namespace Tapeti.Connection { internal enum TapetiChannelType { Consume, Publish } /// /// /// Implementation of ITapetiClient for the RabbitMQ Client library /// internal class TapetiClient : ITapetiClient { private const int ReconnectDelay = 5000; private const int MandatoryReturnTimeout = 300000; private const int MinimumConnectedReconnectDelay = 1000; private readonly TapetiConnectionParams connectionParams; private readonly ITapetiConfig config; private readonly ILogger logger; /// /// Receives events when the connection state changes. /// public IConnectionEventListener ConnectionEventListener { get; set; } private readonly TapetiChannel consumeChannel; private readonly TapetiChannel publishChannel; private readonly HttpClient managementClient; // These fields must be locked using connectionLock private readonly object connectionLock = new(); private long connectionReference; private RabbitMQ.Client.IConnection connection; private IModel consumeChannelModel; private IModel publishChannelModel; private bool isClosing; private bool isReconnect; private DateTime connectedDateTime; // These fields are for use in a single TapetiChannel's queue only! private ulong lastDeliveryTag; private readonly HashSet deletedQueues = new(); // These fields must be locked using confirmLock, since the callbacks for BasicAck/BasicReturn can run in a different thread private readonly object confirmLock = new(); private readonly Dictionary confirmMessages = new(); private readonly Dictionary returnRoutingKeys = new(); private class ConfirmMessageInfo { public string ReturnKey; public TaskCompletionSource CompletionSource; } private class ReturnInfo { public uint RefCount; public int FirstReplyCode; } public TapetiClient(ITapetiConfig config, TapetiConnectionParams connectionParams) { this.config = config; this.connectionParams = connectionParams; logger = config.DependencyResolver.Resolve(); consumeChannel = new TapetiChannel(() => GetModel(TapetiChannelType.Consume)); publishChannel = new TapetiChannel(() => GetModel(TapetiChannelType.Publish)); var handler = new HttpClientHandler { Credentials = new NetworkCredential(connectionParams.Username, connectionParams.Password) }; managementClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; managementClient.DefaultRequestHeaders.Add("Connection", "close"); } /// public async Task Publish(byte[] body, IMessageProperties properties, string exchange, string routingKey, bool mandatory) { if (string.IsNullOrEmpty(routingKey)) throw new ArgumentNullException(nameof(routingKey)); await GetTapetiChannel(TapetiChannelType.Publish).QueueWithProvider(async channelProvider => { Task publishResultTask = null; var messageInfo = new ConfirmMessageInfo { ReturnKey = GetReturnKey(exchange, routingKey), CompletionSource = new TaskCompletionSource() }; channelProvider.WithRetryableChannel(channel => { DeclareExchange(channel, exchange); // The delivery tag is lost after a reconnect, register under the new tag if (config.Features.PublisherConfirms) { lastDeliveryTag++; Monitor.Enter(confirmLock); try { confirmMessages.Add(lastDeliveryTag, messageInfo); } finally { Monitor.Exit(confirmLock); } publishResultTask = messageInfo.CompletionSource.Task; } else mandatory = false; try { var publishProperties = new RabbitMQMessageProperties(channel.CreateBasicProperties(), properties); channel.BasicPublish(exchange ?? "", routingKey, mandatory, publishProperties.BasicProperties, body); } catch { messageInfo.CompletionSource.SetCanceled(); publishResultTask = null; throw; } }); if (publishResultTask == null) return; var delayCancellationTokenSource = new CancellationTokenSource(); var signalledTask = await Task.WhenAny( publishResultTask, Task.Delay(MandatoryReturnTimeout, delayCancellationTokenSource.Token)); if (signalledTask != publishResultTask) throw new TimeoutException( $"Timeout while waiting for basic.return for message with exchange '{exchange}' and routing key '{routingKey}'"); delayCancellationTokenSource.Cancel(); if (publishResultTask.IsCanceled) throw new NackException( $"Mandatory message with with exchange '{exchange}' and routing key '{routingKey}' was nacked"); var replyCode = publishResultTask.Result; switch (replyCode) { // There is no RabbitMQ.Client.Framing.Constants value for this "No route" reply code // at the time of writing... case 312: throw new NoRouteException( $"Mandatory message with exchange '{exchange}' and routing key '{routingKey}' does not have a route"); case > 0: throw new NoRouteException( $"Mandatory message with exchange '{exchange}' and routing key '{routingKey}' could not be delivered, reply code: {replyCode}"); } }); } /// public async Task Consume(CancellationToken cancellationToken, string queueName, IConsumer consumer) { if (deletedQueues.Contains(queueName)) return null; if (string.IsNullOrEmpty(queueName)) throw new ArgumentNullException(nameof(queueName)); long capturedConnectionReference = -1; string consumerTag = null; await GetTapetiChannel(TapetiChannelType.Consume).QueueRetryable(channel => { if (cancellationToken.IsCancellationRequested) return; capturedConnectionReference = Interlocked.Read(ref connectionReference); var basicConsumer = new TapetiBasicConsumer(consumer, capturedConnectionReference, Respond); consumerTag = channel.BasicConsume(queueName, false, basicConsumer); }); return new TapetiConsumerTag(capturedConnectionReference, consumerTag); } /// public async Task Cancel(TapetiConsumerTag consumerTag) { if (isClosing || string.IsNullOrEmpty(consumerTag.ConsumerTag)) return; var capturedConnectionReference = Interlocked.Read(ref connectionReference); // If the connection was re-established in the meantime, don't respond with an // invalid deliveryTag. The message will be requeued. if (capturedConnectionReference != consumerTag.ConnectionReference) return; // No need for a retryable channel here, if the connection is lost // so is the consumer. await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { // Check again as a reconnect may have occured in the meantime var currentConnectionReference = Interlocked.Read(ref connectionReference); if (currentConnectionReference != consumerTag.ConnectionReference) return; channel.BasicCancel(consumerTag.ConsumerTag); }); } private async Task Respond(long expectedConnectionReference, ulong deliveryTag, ConsumeResult result) { await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { // If the connection was re-established in the meantime, don't respond with an // invalid deliveryTag. The message will be requeued. var currentConnectionReference = Interlocked.Read(ref connectionReference); if (currentConnectionReference != connectionReference) return; // No need for a retryable channel here, if the connection is lost we can't // use the deliveryTag anymore. switch (result) { case ConsumeResult.Success: case ConsumeResult.ExternalRequeue: channel.BasicAck(deliveryTag, false); break; case ConsumeResult.Error: channel.BasicNack(deliveryTag, false, false); break; case ConsumeResult.Requeue: channel.BasicNack(deliveryTag, false, true); break; default: throw new ArgumentOutOfRangeException(nameof(result), result, null); } }); } private async Task GetDurableQueueDeclareRequired(string queueName) { var existingQueue = await GetQueueInfo(queueName); if (existingQueue == null) return true; if (!existingQueue.Durable || existingQueue.AutoDelete || existingQueue.Exclusive) throw new InvalidOperationException($"Durable queue {queueName} already exists with incompatible parameters, durable = {existingQueue.Durable} (expected True), autoDelete = {existingQueue.AutoDelete} (expected False), exclusive = {existingQueue.Exclusive} (expected False)"); if (existingQueue.Arguments.Count <= 0) return true; (logger as IBindingLogger)?.QueueExistsWarning(queueName, existingQueue.Arguments); return false; } /// public async Task DurableQueueDeclare(CancellationToken cancellationToken, string queueName, IEnumerable bindings) { var declareRequired = await GetDurableQueueDeclareRequired(queueName); var existingBindings = (await GetQueueBindings(queueName)).ToList(); var currentBindings = bindings.ToList(); var bindingLogger = logger as IBindingLogger; await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { if (cancellationToken.IsCancellationRequested) return; if (declareRequired) { bindingLogger?.QueueDeclare(queueName, true, false); channel.QueueDeclare(queueName, true, false, false); } foreach (var binding in currentBindings.Except(existingBindings)) { DeclareExchange(channel, binding.Exchange); bindingLogger?.QueueBind(queueName, true, binding.Exchange, binding.RoutingKey); channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); } foreach (var deletedBinding in existingBindings.Except(currentBindings)) { bindingLogger?.QueueUnbind(queueName, deletedBinding.Exchange, deletedBinding.RoutingKey); channel.QueueUnbind(queueName, deletedBinding.Exchange, deletedBinding.RoutingKey); } }); } /// public async Task DurableQueueVerify(CancellationToken cancellationToken, string queueName) { if (!await GetDurableQueueDeclareRequired(queueName)) return; await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { if (cancellationToken.IsCancellationRequested) return; (logger as IBindingLogger)?.QueueDeclare(queueName, true, true); channel.QueueDeclarePassive(queueName); }); } /// public async Task DurableQueueDelete(CancellationToken cancellationToken, string queueName, bool onlyIfEmpty = true) { if (!onlyIfEmpty) { uint deletedMessages = 0; await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { if (cancellationToken.IsCancellationRequested) return; deletedMessages = channel.QueueDelete(queueName); }); deletedQueues.Add(queueName); (logger as IBindingLogger)?.QueueObsolete(queueName, true, deletedMessages); return; } await GetTapetiChannel(TapetiChannelType.Consume).QueueWithProvider(async channelProvider => { bool retry; do { if (cancellationToken.IsCancellationRequested) break; retry = false; // Get queue information from the Management API, since the AMQP operations will // throw an error if the queue does not exist or still contains messages and resets // the connection. The resulting reconnect will cause subscribers to reset. var queueInfo = await GetQueueInfo(queueName); if (queueInfo == null) { deletedQueues.Add(queueName); return; } if (queueInfo.Messages == 0) { // Still pass onlyIfEmpty to prevent concurrency issues if a message arrived between // the call to the Management API and deleting the queue. Because the QueueWithRetryableChannel // includes the GetQueueInfo, the next time around it should have Messages > 0 try { channelProvider.WithChannel(channel => { channel.QueueDelete(queueName, false, true); }); deletedQueues.Add(queueName); (logger as IBindingLogger)?.QueueObsolete(queueName, true, 0); } catch (OperationInterruptedException e) { if (e.ShutdownReason.ReplyCode == Constants.PreconditionFailed) retry = true; else throw; } } else { // Remove all bindings instead var existingBindings = (await GetQueueBindings(queueName)).ToList(); if (existingBindings.Count > 0) { channelProvider.WithChannel(channel => { foreach (var binding in existingBindings) channel.QueueUnbind(queueName, binding.Exchange, binding.RoutingKey); }); } (logger as IBindingLogger)?.QueueObsolete(queueName, false, queueInfo.Messages); } } while (retry); }); } /// public async Task DynamicQueueDeclare(CancellationToken cancellationToken, string queuePrefix = null) { string queueName = null; var bindingLogger = logger as IBindingLogger; await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { if (cancellationToken.IsCancellationRequested) return; if (!string.IsNullOrEmpty(queuePrefix)) { queueName = queuePrefix + "." + Guid.NewGuid().ToString("N"); bindingLogger?.QueueDeclare(queueName, false, false); channel.QueueDeclare(queueName); } else { queueName = channel.QueueDeclare().QueueName; bindingLogger?.QueueDeclare(queueName, false, false); } }); return queueName; } /// public async Task DynamicQueueBind(CancellationToken cancellationToken, string queueName, QueueBinding binding) { await GetTapetiChannel(TapetiChannelType.Consume).Queue(channel => { if (cancellationToken.IsCancellationRequested) return; DeclareExchange(channel, binding.Exchange); (logger as IBindingLogger)?.QueueBind(queueName, false, binding.Exchange, binding.RoutingKey); channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); }); } /// public async Task Close() { IModel capturedConsumeModel; IModel capturedPublishModel; RabbitMQ.Client.IConnection capturedConnection; lock (connectionLock) { isClosing = true; capturedConsumeModel = consumeChannelModel; capturedPublishModel = publishChannelModel; capturedConnection = connection; consumeChannelModel = null; publishChannelModel = null; connection = null; } // Empty the queue await consumeChannel.Reset(); await publishChannel.Reset(); // No need to close the channels as the connection will be closed capturedConsumeModel?.Dispose(); capturedPublishModel?.Dispose(); // ReSharper disable once InvertIf if (capturedConnection != null) { try { capturedConnection.Close(); } finally { capturedConnection.Dispose(); } } } private static readonly List TransientStatusCodes = new() { HttpStatusCode.GatewayTimeout, HttpStatusCode.RequestTimeout, HttpStatusCode.ServiceUnavailable }; private class ManagementQueueInfo { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("vhost")] public string VHost { get; set; } [JsonProperty("durable")] public bool Durable { get; set; } [JsonProperty("auto_delete")] public bool AutoDelete { get; set; } [JsonProperty("exclusive")] public bool Exclusive { get; set; } [JsonProperty("arguments")] public Dictionary Arguments { get; set; } [JsonProperty("messages")] public uint Messages { get; set; } } private async Task GetQueueInfo(string queueName) { var virtualHostPath = Uri.EscapeDataString(connectionParams.VirtualHost); var queuePath = Uri.EscapeDataString(queueName); return await WithRetryableManagementAPI($"queues/{virtualHostPath}/{queuePath}", async response => { if (response.StatusCode == HttpStatusCode.NotFound) return null; response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(content); }); } private class ManagementBinding { [JsonProperty("source")] public string Source { get; set; } [JsonProperty("vhost")] public string Vhost { get; set; } [JsonProperty("destination")] public string Destination { get; set; } [JsonProperty("destination_type")] public string DestinationType { get; set; } [JsonProperty("routing_key")] public string RoutingKey { get; set; } [JsonProperty("arguments")] public Dictionary Arguments { get; set; } [JsonProperty("properties_key")] public string PropertiesKey { get; set; } } private async Task> GetQueueBindings(string queueName) { var virtualHostPath = Uri.EscapeDataString(connectionParams.VirtualHost); var queuePath = Uri.EscapeDataString(queueName); return await WithRetryableManagementAPI($"queues/{virtualHostPath}/{queuePath}/bindings", async response => { response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); var bindings = JsonConvert.DeserializeObject>(content); // Filter out the binding to an empty source, which is always present for direct-to-queue routing return bindings? .Where(binding => !string.IsNullOrEmpty(binding.Source)) .Select(binding => new QueueBinding(binding.Source, binding.RoutingKey)) ?? Enumerable.Empty(); }); } private static readonly TimeSpan[] ExponentialBackoff = { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(13), TimeSpan.FromSeconds(21), TimeSpan.FromSeconds(34), TimeSpan.FromSeconds(55) }; private async Task WithRetryableManagementAPI(string path, Func> handleResponse) { // Workaround for: https://github.com/dotnet/runtime/issues/23581#issuecomment-354391321 // "localhost" can cause a 1 second delay *per call*. Not an issue in production scenarios, but annoying while debugging. var hostName = connectionParams.HostName; if (hostName.Equals("localhost", StringComparison.InvariantCultureIgnoreCase)) hostName = "127.0.0.1"; var requestUri = new Uri($"http://{hostName}:{connectionParams.ManagementPort}/api/{path}"); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); var retryDelayIndex = 0; while (true) { try { var response = await managementClient.SendAsync(request); return await handleResponse(response); } catch (TimeoutException) { } catch (WebException e) { if (!(e.Response is HttpWebResponse response)) throw; if (!TransientStatusCodes.Contains(response.StatusCode)) throw; } await Task.Delay(ExponentialBackoff[retryDelayIndex]); if (retryDelayIndex < ExponentialBackoff.Length - 1) retryDelayIndex++; } } private readonly HashSet declaredExchanges = new(); private void DeclareExchange(IModel channel, string exchange) { if (string.IsNullOrEmpty(exchange)) return; if (declaredExchanges.Contains(exchange)) return; (logger as IBindingLogger)?.ExchangeDeclare(exchange); channel.ExchangeDeclare(exchange, "topic", true); declaredExchanges.Add(exchange); } private TapetiChannel GetTapetiChannel(TapetiChannelType channelType) { return channelType == TapetiChannelType.Publish ? publishChannel : consumeChannel; } /// /// Only call this from a task in the taskQueue to ensure IModel is only used /// by a single thread, as is recommended in the RabbitMQ .NET Client documentation. /// private IModel GetModel(TapetiChannelType channelType) { lock (connectionLock) { var channel = channelType == TapetiChannelType.Publish ? publishChannelModel : consumeChannelModel; if (channel != null && channel.IsOpen) return channel; } // If the Disconnect quickly follows the Connect (when an error occurs that is reported back by RabbitMQ // not related to the connection), wait for a bit to avoid spamming the connection if ((DateTime.UtcNow - connectedDateTime).TotalMilliseconds <= MinimumConnectedReconnectDelay) Thread.Sleep(ReconnectDelay); var connectionFactory = new ConnectionFactory { HostName = connectionParams.HostName, Port = connectionParams.Port, VirtualHost = connectionParams.VirtualHost, UserName = connectionParams.Username, Password = connectionParams.Password, AutomaticRecoveryEnabled = false, TopologyRecoveryEnabled = false, RequestedHeartbeat = TimeSpan.FromSeconds(30) }; if (connectionParams.ClientProperties != null) foreach (var pair in connectionParams.ClientProperties) { if (connectionFactory.ClientProperties.ContainsKey(pair.Key)) connectionFactory.ClientProperties[pair.Key] = Encoding.UTF8.GetBytes(pair.Value); else connectionFactory.ClientProperties.Add(pair.Key, Encoding.UTF8.GetBytes(pair.Value)); } while (true) { try { RabbitMQ.Client.IConnection capturedConnection; IModel capturedConsumeChannelModel; IModel capturedPublishChannelModel; lock (connectionLock) { capturedConnection = connection; } if (capturedConnection != null) { try { if (connection.IsOpen) connection.Close(); } catch (AlreadyClosedException) { } finally { connection.Dispose(); } connection = null; } logger.Connect(new ConnectContext(connectionParams, isReconnect)); Interlocked.Increment(ref connectionReference); lock (connectionLock) { connection = connectionFactory.CreateConnection(); capturedConnection = connection; consumeChannelModel = connection.CreateModel(); if (consumeChannel == null) throw new BrokerUnreachableException(null); publishChannelModel = connection.CreateModel(); if (publishChannel == null) throw new BrokerUnreachableException(null); capturedConsumeChannelModel = consumeChannelModel; capturedPublishChannelModel = publishChannelModel; } if (config.Features.PublisherConfirms) { lastDeliveryTag = 0; Monitor.Enter(confirmLock); try { foreach (var pair in confirmMessages) pair.Value.CompletionSource.SetCanceled(); confirmMessages.Clear(); } finally { Monitor.Exit(confirmLock); } capturedPublishChannelModel.ConfirmSelect(); } if (connectionParams.PrefetchCount > 0) capturedPublishChannelModel.BasicQos(0, connectionParams.PrefetchCount, false); capturedPublishChannelModel.ModelShutdown += (_, e) => { lock (connectionLock) { if (consumeChannelModel == null || consumeChannelModel != capturedConsumeChannelModel) return; consumeChannelModel = null; } ConnectionEventListener?.Disconnected(new DisconnectedEventArgs { ReplyCode = e.ReplyCode, ReplyText = e.ReplyText }); logger.Disconnect(new DisconnectContext(connectionParams, e.ReplyCode, e.ReplyText)); // Reconnect if the disconnect was unexpected if (!isClosing) GetTapetiChannel(TapetiChannelType.Consume).QueueRetryable(_ => { }); }; capturedPublishChannelModel.ModelShutdown += (_, _) => { lock (connectionLock) { if (publishChannelModel == null || publishChannelModel != capturedPublishChannelModel) return; publishChannelModel = null; } // No need to reconnect, the next Publish will }; capturedPublishChannelModel.BasicReturn += HandleBasicReturn; capturedPublishChannelModel.BasicAcks += HandleBasicAck; capturedPublishChannelModel.BasicNacks += HandleBasicNack; connectedDateTime = DateTime.UtcNow; var connectedEventArgs = new ConnectedEventArgs { ConnectionParams = connectionParams, LocalPort = capturedConnection.LocalPort }; if (isReconnect) ConnectionEventListener?.Reconnected(connectedEventArgs); else ConnectionEventListener?.Connected(connectedEventArgs); logger.ConnectSuccess(new ConnectContext(connectionParams, isReconnect, capturedConnection.LocalPort)); isReconnect = true; break; } catch (BrokerUnreachableException e) { logger.ConnectFailed(new ConnectContext(connectionParams, isReconnect, exception: e)); Thread.Sleep(ReconnectDelay); } } lock (connectionLock) { return channelType == TapetiChannelType.Publish ? publishChannelModel : consumeChannelModel; } } private void HandleBasicReturn(object sender, BasicReturnEventArgs e) { /* * "If the message is also published as mandatory, the basic.return is sent to the client before basic.ack." * - https://www.rabbitmq.com/confirms.html * * Because there is no delivery tag included in the basic.return message. This solution is modeled after * user OhJeez' answer on StackOverflow: * * "Since all messages with the same routing key are routed the same way. I assumed that once I get a * basic.return about a specific routing key, all messages with this routing key can be considered undelivered" * https://stackoverflow.com/questions/21336659/how-to-tell-which-amqp-message-was-not-routed-from-basic-return-response */ var key = GetReturnKey(e.Exchange, e.RoutingKey); if (!returnRoutingKeys.TryGetValue(key, out var returnInfo)) { returnInfo = new ReturnInfo { RefCount = 0, FirstReplyCode = e.ReplyCode }; returnRoutingKeys.Add(key, returnInfo); } returnInfo.RefCount++; } private void HandleBasicAck(object sender, BasicAckEventArgs e) { Monitor.Enter(confirmLock); try { foreach (var deliveryTag in GetDeliveryTags(e)) { if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) continue; if (returnRoutingKeys.TryGetValue(messageInfo.ReturnKey, out var returnInfo)) { messageInfo.CompletionSource.SetResult(returnInfo.FirstReplyCode); returnInfo.RefCount--; if (returnInfo.RefCount == 0) returnRoutingKeys.Remove(messageInfo.ReturnKey); } messageInfo.CompletionSource.SetResult(0); confirmMessages.Remove(deliveryTag); } } finally { Monitor.Exit(confirmLock); } } private void HandleBasicNack(object sender, BasicNackEventArgs e) { Monitor.Enter(confirmLock); try { foreach (var deliveryTag in GetDeliveryTags(e)) { if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) continue; messageInfo.CompletionSource.SetCanceled(); confirmMessages.Remove(e.DeliveryTag); } } finally { Monitor.Exit(confirmLock); } } private IEnumerable GetDeliveryTags(BasicAckEventArgs e) { return e.Multiple ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() : new[] { e.DeliveryTag }; } private IEnumerable GetDeliveryTags(BasicNackEventArgs e) { return e.Multiple ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() : new[] { e.DeliveryTag }; } private static string GetReturnKey(string exchange, string routingKey) { return exchange + ':' + routingKey; } private class ConnectContext : IConnectSuccessContext, IConnectFailedContext { public TapetiConnectionParams ConnectionParams { get; } public bool IsReconnect { get; } public int LocalPort { get; } public Exception Exception { get; } public ConnectContext(TapetiConnectionParams connectionParams, bool isReconnect, int localPort = 0, Exception exception = null) { ConnectionParams = connectionParams; IsReconnect = isReconnect; LocalPort = localPort; Exception = exception; } } private class DisconnectContext : IDisconnectContext { public TapetiConnectionParams ConnectionParams { get; } public ushort ReplyCode { get; } public string ReplyText { get; } public DisconnectContext(TapetiConnectionParams connectionParams, ushort replyCode, string replyText) { ConnectionParams = connectionParams; ReplyCode = replyCode; ReplyText = replyText; } } } }