Refactored all synchronizers to deal with some of the chronic issues.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail history ID expiration errors.
|
||||
/// When history is no longer available, resets the account's history ID to force a full resync.
|
||||
/// </summary>
|
||||
public class GmailHistoryExpiredHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailHistoryExpiredHandler>();
|
||||
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
||||
|
||||
public GmailHistoryExpiredHandler(IGmailChangeProcessor gmailChangeProcessor)
|
||||
{
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// Gmail returns 404 when history ID is no longer valid
|
||||
if (error.ErrorCode == 404)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
return message.Contains("history") || message.Contains("notfound");
|
||||
}
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
return errorMessage.Contains("history") ||
|
||||
errorMessage.Contains("not found") ||
|
||||
errorMessage.Contains("starthistoryid");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail history ID expired for account {AccountName} ({AccountId}). Resetting to force full sync.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Reset the account's synchronization identifier (history ID)
|
||||
if (error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(
|
||||
error.Account.Id, string.Empty).ConfigureAwait(false);
|
||||
|
||||
_logger.Information("Successfully reset Gmail history ID for account {AccountName}. Next sync will be full sync.",
|
||||
error.Account.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to reset Gmail history ID for account {AccountName}",
|
||||
error.Account.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API quota exceeded errors (HTTP 403 with quota error).
|
||||
/// This is a more severe rate limit that indicates daily quota exhaustion.
|
||||
/// </summary>
|
||||
public class GmailQuotaExceededHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailQuotaExceededHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
// Quota exceeded usually returns 403
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
var errorReason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return errorMessage.Contains("quota") ||
|
||||
errorMessage.Contains("limit exceeded") ||
|
||||
errorReason.Contains("quota") ||
|
||||
errorReason.Contains("ratelimitexceeded") ||
|
||||
errorReason.Contains("userlimitexceeded");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API quota exceeded for account {AccountName} ({AccountId}). Sync will be paused.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
// Quota exceeded is more severe - treat as fatal to prevent repeated failures
|
||||
// The user will be notified and sync will resume after quota resets
|
||||
error.Severity = SynchronizerErrorSeverity.Fatal;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Suggest a very long delay - quotas typically reset daily
|
||||
error.RetryDelay = TimeSpan.FromHours(1);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API rate limiting errors (HTTP 429 Too Many Requests).
|
||||
/// Marks the error as transient with appropriate backoff delay.
|
||||
/// </summary>
|
||||
public class GmailRateLimitHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailRateLimitHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.ErrorCode == 429)
|
||||
return true;
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
return googleEx.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests ||
|
||||
(googleEx.Error?.Code == 429);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}. Will retry with backoff.",
|
||||
error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A");
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Gmail rate limits are usually per-user, suggest a longer delay
|
||||
error.RetryDelay = TimeSpan.FromSeconds(10);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Security;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP authentication failures (AuthenticationException, SaslException).
|
||||
/// Marks the error as requiring re-authentication.
|
||||
/// </summary>
|
||||
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is AuthenticationException ||
|
||||
error.Exception is SaslException ||
|
||||
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
// Mark as requiring authentication - this will stop sync and notify user
|
||||
error.Severity = SynchronizerErrorSeverity.AuthRequired;
|
||||
error.Category = SynchronizerErrorCategory.Authentication;
|
||||
|
||||
// No point in retrying auth failures - credentials need to be updated
|
||||
error.RetryDelay = null;
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP connection loss errors (IOException, SocketException, ServiceNotConnectedException).
|
||||
/// Marks the error as transient for retry with backoff.
|
||||
/// </summary>
|
||||
public class ImapConnectionLostHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapConnectionLostHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is IOException ||
|
||||
error.Exception is SocketException ||
|
||||
error.Exception is ServiceNotConnectedException ||
|
||||
error.Exception?.InnerException is IOException ||
|
||||
error.Exception?.InnerException is SocketException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP connection lost for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Will retry.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A");
|
||||
|
||||
// Mark as transient - the RetryExecutor will handle the retry logic
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.Network;
|
||||
|
||||
// Suggest a reasonable retry delay for connection issues
|
||||
error.RetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP folder not found errors (FolderNotFoundException).
|
||||
/// Deletes the folder locally and allows sync to continue with other folders.
|
||||
/// </summary>
|
||||
public class ImapFolderNotFoundHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapFolderNotFoundHandler>();
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
|
||||
public ImapFolderNotFoundHandler(IImapChangeProcessor imapChangeProcessor)
|
||||
{
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is FolderNotFoundException ||
|
||||
error.ErrorCode == 404 ||
|
||||
(error.ErrorMessage?.Contains("folder not found", System.StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(error.ErrorMessage?.Contains("mailbox not found", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP folder not found for account {AccountName} ({AccountId}). Folder: {FolderName} ({FolderId}). Removing locally.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName, error.FolderId);
|
||||
|
||||
// Mark as recoverable - sync can continue with other folders
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Try to delete the folder locally if we have the folder ID
|
||||
if (error.FolderId.HasValue && error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the folder's remote ID from the exception if available
|
||||
var remoteId = error.Exception is FolderNotFoundException fnf ? fnf.FolderName : null;
|
||||
|
||||
if (!string.IsNullOrEmpty(remoteId))
|
||||
{
|
||||
await _imapChangeProcessor.DeleteFolderAsync(error.Account.Id, remoteId).ConfigureAwait(false);
|
||||
_logger.Information("Successfully deleted local folder {FolderName} after server deletion.",
|
||||
error.FolderName);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to delete local folder {FolderName} ({FolderId})",
|
||||
error.FolderName, error.FolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles generic IMAP protocol errors (ImapProtocolException, ImapCommandException).
|
||||
/// This is the catch-all handler for IMAP errors not handled by more specific handlers.
|
||||
/// </summary>
|
||||
public class ImapProtocolErrorHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapProtocolErrorHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// This is a catch-all for IMAP-related exceptions
|
||||
return error.Exception is ImapProtocolException ||
|
||||
error.Exception is ImapCommandException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
var severity = ClassifyProtocolError(error);
|
||||
var category = SynchronizerErrorCategory.ProtocolError;
|
||||
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP protocol error for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Severity: {Severity}",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A", severity);
|
||||
|
||||
error.Severity = severity;
|
||||
error.Category = category;
|
||||
|
||||
// For transient protocol errors, suggest a retry delay
|
||||
if (severity == SynchronizerErrorSeverity.Transient)
|
||||
{
|
||||
error.RetryDelay = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the protocol error to determine if it's transient, recoverable, or fatal.
|
||||
/// </summary>
|
||||
private static SynchronizerErrorSeverity ClassifyProtocolError(SynchronizerErrorContext error)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
var exMessage = error.Exception?.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// Check for rate limiting / throttling
|
||||
if (message.Contains("too many") || message.Contains("rate limit") ||
|
||||
message.Contains("throttl") || exMessage.Contains("too many"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for temporary server issues
|
||||
if (message.Contains("try again") || message.Contains("temporary") ||
|
||||
message.Contains("busy") || exMessage.Contains("try again"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for command-specific errors that are usually transient
|
||||
if (error.Exception is ImapCommandException cmdEx)
|
||||
{
|
||||
// NO response usually means the operation failed but can be retried
|
||||
if (cmdEx.Response == ImapCommandResponse.No)
|
||||
{
|
||||
// Unless it's a permanent failure indication
|
||||
if (message.Contains("permanent") || message.Contains("invalid"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// BAD response usually indicates a protocol violation - don't retry
|
||||
if (cmdEx.Response == ImapCommandResponse.Bad)
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol exceptions that indicate connection issues
|
||||
if (error.Exception is ImapProtocolException)
|
||||
{
|
||||
// Most protocol exceptions are connection-related and transient
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Default to recoverable for unknown protocol errors
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Graph.Models.ODataErrors;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
Reference in New Issue
Block a user