fix(notification-service): resolve NotificationService-002/003/004 — error classification by SMTP status code, single SMTP client
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
@@ -76,7 +79,12 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
|
||||
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (IsTransientSmtpError(ex))
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// NS-002: a caller-requested cancellation propagates; it is not buffered.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
||||
{
|
||||
// WP-12: Transient SMTP failure — hand to S&F
|
||||
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
|
||||
@@ -172,8 +180,9 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = _smtpClientFactory() as IDisposable;
|
||||
// NS-004: create exactly one client and dispose the one actually used.
|
||||
var smtp = _smtpClientFactory();
|
||||
using var disposable = smtp as IDisposable;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -195,32 +204,80 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
|
||||
await smtp.DisconnectAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not SmtpPermanentException && !IsTransientSmtpError(ex))
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Classify unrecognized SMTP exceptions
|
||||
if (ex.Message.Contains("5.", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("550", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("553", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("554", StringComparison.Ordinal))
|
||||
{
|
||||
throw new SmtpPermanentException(ex.Message, ex);
|
||||
}
|
||||
|
||||
// Default: treat as transient
|
||||
// NS-002: A deliberately cancelled token must propagate as a cancellation,
|
||||
// not be misclassified as a transient SMTP failure and buffered for retry.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Permanent
|
||||
&& ex is not SmtpPermanentException)
|
||||
{
|
||||
// NS-003: Permanent SMTP failure (5xx) — surface a typed permanent exception.
|
||||
throw new SmtpPermanentException(ex.Message, ex);
|
||||
}
|
||||
// Transient and SmtpPermanentException both propagate unchanged: SendAsync's
|
||||
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
|
||||
}
|
||||
|
||||
private static bool IsTransientSmtpError(Exception ex)
|
||||
private enum SmtpErrorClass
|
||||
{
|
||||
return ex is TimeoutException
|
||||
or OperationCanceledException
|
||||
or System.Net.Sockets.SocketException
|
||||
or IOException
|
||||
|| ex.Message.Contains("4.", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("421", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("450", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("451", StringComparison.Ordinal);
|
||||
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
|
||||
Unknown,
|
||||
/// <summary>Retryable failure (4xx, connection/socket/protocol error, timeout).</summary>
|
||||
Transient,
|
||||
/// <summary>Non-retryable failure (5xx) — must be returned to the script.</summary>
|
||||
Permanent,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and
|
||||
/// the numeric <see cref="SmtpStatusCode"/> rather than locale-dependent substring
|
||||
/// matching on the exception message. A cancellation requested by the caller is
|
||||
/// never treated as a transient SMTP error.
|
||||
/// </summary>
|
||||
private static SmtpErrorClass ClassifySmtpError(Exception ex, CancellationToken cancellationToken)
|
||||
{
|
||||
// A deliberate cancellation is not an SMTP error at all.
|
||||
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return SmtpErrorClass.Unknown;
|
||||
}
|
||||
|
||||
// MailKit reports SMTP command failures with the real status code; the
|
||||
// SmtpStatusCode enum's underlying value is the numeric SMTP reply code.
|
||||
if (ex is SmtpCommandException command)
|
||||
{
|
||||
var code = (int)command.StatusCode;
|
||||
if (code >= 400 && code < 500)
|
||||
{
|
||||
return SmtpErrorClass.Transient;
|
||||
}
|
||||
|
||||
if (code >= 500 && code < 600)
|
||||
{
|
||||
return SmtpErrorClass.Permanent;
|
||||
}
|
||||
|
||||
return SmtpErrorClass.Unknown;
|
||||
}
|
||||
|
||||
// Protocol errors, a dropped/unavailable service, socket failures and
|
||||
// timeouts are all retryable — the message has not been rejected.
|
||||
if (ex is SmtpProtocolException
|
||||
or ServiceNotConnectedException
|
||||
or SocketException
|
||||
or TimeoutException)
|
||||
{
|
||||
return SmtpErrorClass.Transient;
|
||||
}
|
||||
|
||||
return SmtpErrorClass.Unknown;
|
||||
}
|
||||
|
||||
private static bool IsTransientSmtpError(Exception ex, CancellationToken cancellationToken)
|
||||
{
|
||||
return ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Transient;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user