Generic 404 handler for synchronizers.

This commit is contained in:
Burak Kaan Köse
2026-02-08 22:20:38 +01:00
parent 1747ed84a8
commit e559a79506
15 changed files with 258 additions and 35 deletions
@@ -0,0 +1,89 @@
using System;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors;
/// <summary>
/// Generic handler for 404 (Not Found) errors across all synchronizers.
/// When a resource is already gone on the server, this handler applies
/// the intended change locally instead of throwing.
/// Works for all mail actions, folder actions, and batch operations.
/// </summary>
public class EntityNotFoundHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<EntityNotFoundHandler>();
private readonly IMailService _mailService;
private readonly IFolderService _folderService;
public EntityNotFoundHandler(IMailService mailService, IFolderService folderService)
{
_mailService = mailService;
_folderService = folderService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.ErrorCode != 404) return false;
if (error.RequestBundle == null) return false;
return true;
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
error.Severity = SynchronizerErrorSeverity.Recoverable;
error.Category = SynchronizerErrorCategory.ResourceNotFound;
var uiRequest = error.RequestBundle.UIChangeRequest;
// --- Folder actions ---
if (uiRequest is IFolderActionRequest folderAction)
{
_logger.Warning("Entity not found (404) for folder operation {Op} on {RemoteFolderId}. Deleting locally.",
folderAction.Operation, folderAction.Folder.RemoteFolderId);
try
{
await _folderService.DeleteFolderAsync(
folderAction.Folder.MailAccountId,
folderAction.Folder.RemoteFolderId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete folder locally after 404.");
}
return true;
}
// --- Individual mail actions ---
if (uiRequest is IMailActionRequest mailAction && error.Account != null)
{
_logger.Warning("Entity not found (404) for mail operation {Op} on {MailId}. Deleting locally.",
mailAction.Operation, mailAction.Item.Id);
// Revert optimistic UI change (e.g. mark-read/flag toggle) before deleting
error.RequestBundle.UIChangeRequest?.RevertUIChanges();
try
{
await _mailService.DeleteMailAsync(
error.Account.Id, mailAction.Item.Id).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete mail locally after 404.");
}
return true;
}
// --- Batch requests (can't identify specific item) ---
// Mark as recoverable. Next sync will clean up stale items.
_logger.Warning("Entity not found (404) for batch operation. Marking as recoverable.");
return true;
}
}
+21 -12
View File
@@ -1318,19 +1318,28 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
var request = _gmailService.Users.Messages.Get("me", mailItem.Id);
request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var mimeMessage = gmailMessage.GetGmailMimeMessage();
if (mimeMessage == null)
try
{
_logger.Warning("Tried to download Gmail Raw Mime with {Id} id and server responded without a data.", mailItem.Id);
return;
}
var request = _gmailService.Users.Messages.Get("me", mailItem.Id);
request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var mimeMessage = gmailMessage.GetGmailMimeMessage();
if (mimeMessage == null)
{
_logger.Warning("Tried to download Gmail Raw Mime with {Id} id and server responded without a data.", mailItem.Id);
return;
}
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.Warning("Gmail message {MailId} not found (404) during MIME download. Deleting locally.", mailItem.Id);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
throw new SynchronizerEntityNotFoundException(ex.Message);
}
}
public override async Task DownloadCalendarAttachmentAsync(
@@ -1472,12 +1481,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create error context
var errorContext = new SynchronizerErrorContext
{
Account = Account,
ErrorCode = error.Code,
ErrorMessage = error.Message,
RequestBundle = bundle,
AdditionalData = new Dictionary<string, object>
{
{ "Account", Account },
{ "Error", error }
}
};
+43 -12
View File
@@ -235,18 +235,36 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
var remoteFolderId = folder.RemoteFolderId;
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
var remoteFolder = await client.GetFolderAsync(remoteFolderId, cancellationToken).ConfigureAwait(false);
var uniqueId = new UniqueId(MailkitClientExtensions.ResolveUid(mailItem.Id));
try
{
var remoteFolder = await client.GetFolderAsync(remoteFolderId, cancellationToken).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
var uniqueId = new UniqueId(MailkitClientExtensions.ResolveUid(mailItem.Id));
var message = await remoteFolder.GetMessageAsync(uniqueId, cancellationToken, transferProgress).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
await _imapChangeProcessor.SaveMimeFileAsync(mailItem.FileId, message, Account.Id).ConfigureAwait(false);
await remoteFolder.CloseAsync(false, cancellationToken).ConfigureAwait(false);
var message = await remoteFolder.GetMessageAsync(uniqueId, cancellationToken, transferProgress).ConfigureAwait(false);
_clientPool.Release(client);
await _imapChangeProcessor.SaveMimeFileAsync(mailItem.FileId, message, Account.Id).ConfigureAwait(false);
await remoteFolder.CloseAsync(false, cancellationToken).ConfigureAwait(false);
}
catch (FolderNotFoundException ex)
{
_logger.Warning("IMAP folder {FolderId} not found during MIME download for {MailId}. Deleting locally.", remoteFolderId, mailItem.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
throw new SynchronizerEntityNotFoundException(ex.Message);
}
catch (ImapCommandException ex) when (ex.Response == ImapCommandResponse.No)
{
_logger.Warning("IMAP message {MailId} not found during MIME download (NO response). Deleting locally.", mailItem.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
throw new SynchronizerEntityNotFoundException(ex.Message);
}
finally
{
_clientPool.Release(client);
}
}
public override Task DownloadCalendarAttachmentAsync(
@@ -500,16 +518,29 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
}
// TODO: Retry pattern.
// TODO: Error handling.
try
{
await item.NativeRequest.IntegratorTask(executorClient, item.Request).ConfigureAwait(false);
}
catch (Exception)
catch (Exception ex)
{
item.Request.RevertUIChanges();
throw;
var errorContext = new SynchronizerErrorContext
{
Account = Account,
ErrorCode = ex is FolderNotFoundException ? 404 : null,
ErrorMessage = ex.Message,
Exception = ex,
RequestBundle = item,
OperationType = "RequestExecution"
};
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
if (!handled)
{
item.Request.RevertUIChanges();
throw;
}
}
finally
{
+11 -2
View File
@@ -1430,8 +1430,17 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
var mimeMessage = await DownloadMimeMessageAsync(mailItem.Id, cancellationToken).ConfigureAwait(false);
await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
try
{
var mimeMessage = await DownloadMimeMessageAsync(mailItem.Id, cancellationToken).ConfigureAwait(false);
await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
catch (ODataError ex) when (ex.ResponseStatusCode == 404)
{
_logger.Warning("Outlook message {MailId} not found (404) during MIME download. Deleting locally.", mailItem.Id);
await _outlookChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
throw new SynchronizerEntityNotFoundException(ex.Message);
}
}
public override async Task DownloadCalendarAttachmentAsync(