1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 18:53:41 +00:00

[PM-17562] Add support for retries on event integrations (#5795)

* [PM-17562] Add support for retires on event integrations

* Add additional test coverage

* Fixed missing await call

* Remove debug organization id

* Respond to PR feedback

* Change NotBeforeUtc to DelayUntilDate. Adjust comments.

* Respond to PR feedback
This commit is contained in:
Brant DeBow
2025-05-27 08:28:50 -04:00
committed by GitHub
parent c989abdb82
commit f3e637cf2d
40 changed files with 1277 additions and 216 deletions

View File

@@ -0,0 +1,83 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
namespace Bit.Core.Services;
#nullable enable
public class EventIntegrationHandler<T>(
IntegrationType integrationType,
IIntegrationPublisher integrationPublisher,
IOrganizationIntegrationConfigurationRepository configurationRepository,
IUserRepository userRepository,
IOrganizationRepository organizationRepository)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
if (eventMessage.OrganizationId is not Guid organizationId)
{
return;
}
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
integrationType,
eventMessage.Type);
foreach (var configuration in configurations)
{
var template = configuration.Template ?? string.Empty;
var context = await BuildContextAsync(eventMessage, template);
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
var config = configuration.MergedConfiguration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}");
var message = new IntegrationMessage<T>
{
IntegrationType = integrationType,
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
DelayUntilDate = null
};
await integrationPublisher.PublishAsync(message);
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
{
var context = new IntegrationTemplateContext(eventMessage);
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
{
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
{
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue)
{
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value);
}
return context;
}
}

View File

@@ -1,8 +1,8 @@
using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
namespace Bit.Core.Services;

View File

@@ -29,7 +29,7 @@ public class RabbitMqEventListenerService : EventLoggingListenerService
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
_exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
_logger = logger;
_queueName = queueName;
}

View File

@@ -18,7 +18,7 @@ public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
_exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
}

View File

@@ -0,0 +1,191 @@
using System.Text;
using Bit.Core.Settings;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace Bit.Core.Services;
public class RabbitMqIntegrationListenerService : BackgroundService
{
private const string _deadLetterRoutingKey = "dead-letter";
private IChannel _channel;
private IConnection _connection;
private readonly string _exchangeName;
private readonly string _queueName;
private readonly string _retryQueueName;
private readonly string _deadLetterQueueName;
private readonly string _routingKey;
private readonly string _retryRoutingKey;
private readonly int _maxRetries;
private readonly IIntegrationHandler _handler;
private readonly ConnectionFactory _factory;
private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
private readonly int _retryTiming;
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
string routingKey,
string queueName,
string retryQueueName,
string deadLetterQueueName,
GlobalSettings globalSettings,
ILogger<RabbitMqIntegrationListenerService> logger)
{
_handler = handler;
_routingKey = routingKey;
_retryRoutingKey = $"{_routingKey}-retry";
_queueName = queueName;
_retryQueueName = retryQueueName;
_deadLetterQueueName = deadLetterQueueName;
_logger = logger;
_exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
_maxRetries = globalSettings.EventLogging.RabbitMq.MaxRetries;
_retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming;
_factory = new ConnectionFactory
{
HostName = globalSettings.EventLogging.RabbitMq.HostName,
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_connection = await _factory.CreateConnectionAsync(cancellationToken);
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
await _channel.ExchangeDeclareAsync(exchange: _exchangeName,
type: ExchangeType.Direct,
durable: true,
cancellationToken: cancellationToken);
// Declare main queue
await _channel.QueueDeclareAsync(queue: _queueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null,
cancellationToken: cancellationToken);
await _channel.QueueBindAsync(queue: _queueName,
exchange: _exchangeName,
routingKey: _routingKey,
cancellationToken: cancellationToken);
// Declare retry queue (Configurable TTL, dead-letters back to main queue)
await _channel.QueueDeclareAsync(queue: _retryQueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: new Dictionary<string, object>
{
{ "x-dead-letter-exchange", _exchangeName },
{ "x-dead-letter-routing-key", _routingKey },
{ "x-message-ttl", _retryTiming }
},
cancellationToken: cancellationToken);
await _channel.QueueBindAsync(queue: _retryQueueName,
exchange: _exchangeName,
routingKey: _retryRoutingKey,
cancellationToken: cancellationToken);
// Declare dead letter queue
await _channel.QueueDeclareAsync(queue: _deadLetterQueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null,
cancellationToken: cancellationToken);
await _channel.QueueBindAsync(queue: _deadLetterQueueName,
exchange: _exchangeName,
routingKey: _deadLetterRoutingKey,
cancellationToken: cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
var consumer = new AsyncEventingBasicConsumer(_channel);
consumer.ReceivedAsync += async (_, ea) =>
{
var json = Encoding.UTF8.GetString(ea.Body.Span);
try
{
var result = await _handler.HandleAsync(json);
var message = result.Message;
if (result.Success)
{
// Successful integration send. Acknowledge message delivery and return
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
return;
}
if (result.Retryable)
{
// Integration failed, but is retryable - apply delay and check max retries
message.ApplyRetry(result.DelayUntilDate);
if (message.RetryCount < _maxRetries)
{
// Publish message to the retry queue. It will be re-published for retry after a delay
await _channel.BasicPublishAsync(
exchange: _exchangeName,
routingKey: _retryRoutingKey,
body: Encoding.UTF8.GetBytes(message.ToJson()),
cancellationToken: cancellationToken);
}
else
{
// Exceeded the max number of retries; fail and send to dead letter queue
await PublishToDeadLetterAsync(message.ToJson());
_logger.LogWarning("Max retry attempts reached. Sent to DLQ.");
}
}
else
{
// Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries
await PublishToDeadLetterAsync(message.ToJson());
_logger.LogWarning("Non-retryable failure. Sent to DLQ.");
}
// Message has been sent to retry or dead letter queues.
// Acknowledge receipt so Rabbit knows it's been processed
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
// Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error
_logger.LogError(ex, "Unhandled error processing integration message.");
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
};
await _channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken);
}
private async Task PublishToDeadLetterAsync(string json)
{
await _channel.BasicPublishAsync(
exchange: _exchangeName,
routingKey: _deadLetterRoutingKey,
body: Encoding.UTF8.GetBytes(json));
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await _channel.CloseAsync(cancellationToken);
await _connection.CloseAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
public override void Dispose()
{
_channel.Dispose();
_connection.Dispose();
base.Dispose();
}
}

View File

@@ -0,0 +1,54 @@
using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Settings;
using RabbitMQ.Client;
namespace Bit.Core.Services;
public class RabbitMqIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable
{
private readonly ConnectionFactory _factory;
private readonly Lazy<Task<IConnection>> _lazyConnection;
private readonly string _exchangeName;
public RabbitMqIntegrationPublisher(GlobalSettings globalSettings)
{
_factory = new ConnectionFactory
{
HostName = globalSettings.EventLogging.RabbitMq.HostName,
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
_exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
}
public async Task PublishAsync(IIntegrationMessage message)
{
var routingKey = message.IntegrationType.ToRoutingKey();
var connection = await _lazyConnection.Value;
await using var channel = await connection.CreateChannelAsync();
await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Direct, durable: true);
var body = Encoding.UTF8.GetBytes(message.ToJson());
await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: routingKey, body: body);
}
public async ValueTask DisposeAsync()
{
if (_lazyConnection.IsValueCreated)
{
var connection = await _lazyConnection.Value;
await connection.DisposeAsync();
}
}
private async Task<IConnection> CreateConnectionAsync()
{
return await _factory.CreateConnectionAsync();
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
namespace Bit.Core.Services;
public class SlackIntegrationHandler(
ISlackService slackService)
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
{
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{
await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.token,
message.RenderedTemplate,
message.Configuration.channelId
);
return new IntegrationHandlerResult(success: true, message: message);
}
}

View File

@@ -1,8 +1,8 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
@@ -25,7 +25,7 @@ public class WebhookEventHandler(
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration,
string renderedTemplate)
{
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetails>();
if (config is null || string.IsNullOrEmpty(config.url))
{
return;

View File

@@ -0,0 +1,61 @@
using System.Globalization;
using System.Net;
using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations;
#nullable enable
namespace Bit.Core.Services;
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
public const string HttpClientName = "WebhookIntegrationHandlerHttpClient";
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(message.Configuration.url, content);
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
switch (response.StatusCode)
{
case HttpStatusCode.TooManyRequests:
case HttpStatusCode.RequestTimeout:
case HttpStatusCode.InternalServerError:
case HttpStatusCode.BadGateway:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
result.Retryable = true;
result.FailureReason = response.ReasonPhrase;
if (response.Headers.TryGetValues("Retry-After", out var values))
{
var value = values.FirstOrDefault();
if (int.TryParse(value, out var seconds))
{
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds);
}
else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var retryDate))
{
// Retry-after was specified as a date. Adjust DelayUntilDate to the specified date.
result.DelayUntilDate = retryDate.UtcDateTime;
}
}
break;
default:
result.Retryable = false;
result.FailureReason = response.ReasonPhrase;
break;
}
return result;
}
}