namespace HrynCo.NotificationService.Worker.Services.Push; using HrynCo.NotificationService.Contracts.Messages; using HrynCo.NotificationService.DAL.Abstract.Push; using Hrynco.RabbitMq; public sealed class PushDeliveryService(IPushDeliveryRepository deliveries, IPushSender sender, IRabbitMqPublisher publisher) { public async Task ProcessAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken) { PushDeliveryOutcome outcome = await DeliverWithRetryAsync(messageId, message.Data, cancellationToken); await PublishResultAsync(message, outcome, cancellationToken); } private async Task DeliverWithRetryAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken) { for (int attempt = 0; attempt < 3; attempt++) { try { return await deliveries.DeliverOnceAsync(messageId, data.ServiceName, ct => SendAsync(messageId, data, ct), cancellationToken); } catch (PushRetryException exception) { var delay = RetryDelay(attempt, exception.RetryAfter); if (attempt == 2 || DateTimeOffset.UtcNow.Add(delay) >= data.ExpiresAt) break; PushDeliveryMetrics.RecordRetry(); // The failed transaction has been disposed; do not hold a DB lock while backing off. await Task.Delay(delay, cancellationToken); } } return await deliveries.DeliverOnceAsync(messageId, data.ServiceName, _ => Task.FromResult(new PushDeliveryOutcome("Push delivery retries exhausted or message expired.")), cancellationToken); } private async Task SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken) { var outcome = await sender.SendAsync(messageId, data, cancellationToken); PushDeliveryMetrics.RecordOutcome(outcome.Error is null); return outcome; } internal static TimeSpan RetryDelay(int attempt, TimeSpan retryAfter) => TimeSpan.FromSeconds(Math.Max(60 * Math.Pow(2, attempt), retryAfter.TotalSeconds) + Random.Shared.NextDouble() * 5); public async Task CompleteFailureAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken) { var outcome = await deliveries.DeliverOnceAsync(messageId, message.Data.ServiceName, _ => Task.FromResult(new PushDeliveryOutcome("Push processing retries exhausted.")), cancellationToken); await PublishResultAsync(message, outcome, cancellationToken); } public Task PublishResultAsync(SendPushMessage message, PushDeliveryOutcome outcome, CancellationToken cancellationToken) { var replyTo = message.CorrelationContext.ReplyTo; if (string.IsNullOrWhiteSpace(replyTo)) return Task.CompletedTask; return publisher.PublishAsync(replyTo, new NotificationResultMessage { CorrelationContext = message.CorrelationContext with { ReplyTo = null }, Data = new NotificationResultData { ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic", Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error, PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId, PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration } }, cancellationToken); } }