From 59042729c122a353bf39295edff86427b2dde735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 6 Mar 2026 13:43:16 +0100 Subject: [PATCH] "Outlook error" handling for 429 status code. --- Wino.Core/CoreContainerSetup.cs | 1 + ...OutlookSynchronizerErrorHandlingFactory.cs | 4 +- .../Errors/Outlook/OutlookRateLimitHandler.cs | 38 ++++++ .../Synchronizers/OutlookSynchronizer.cs | 109 +++++++++--------- .../Controls/ImagePreviewControl.cs | 2 +- 5 files changed, 99 insertions(+), 55 deletions(-) create mode 100644 Wino.Core/Synchronizers/Errors/Outlook/OutlookRateLimitHandler.cs diff --git a/Wino.Core/CoreContainerSetup.cs b/Wino.Core/CoreContainerSetup.cs index 44c36cac..02fcc7d1 100644 --- a/Wino.Core/CoreContainerSetup.cs +++ b/Wino.Core/CoreContainerSetup.cs @@ -41,6 +41,7 @@ public static class CoreContainerSetup // Register Outlook error handlers services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Register Gmail error handlers services.AddTransient(); diff --git a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs index 087b5102..5b9a0c08 100644 --- a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs @@ -8,8 +8,10 @@ public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandling { public OutlookSynchronizerErrorHandlingFactory(ObjectCannotBeDeletedHandler objectCannotBeDeleted, EntityNotFoundHandler entityNotFoundHandler, - DeltaTokenExpiredHandler deltaTokenExpiredHandler) + DeltaTokenExpiredHandler deltaTokenExpiredHandler, + OutlookRateLimitHandler outlookRateLimitHandler) { + RegisterHandler(outlookRateLimitHandler); RegisterHandler(objectCannotBeDeleted); RegisterHandler(entityNotFoundHandler); RegisterHandler(deltaTokenExpiredHandler); diff --git a/Wino.Core/Synchronizers/Errors/Outlook/OutlookRateLimitHandler.cs b/Wino.Core/Synchronizers/Errors/Outlook/OutlookRateLimitHandler.cs new file mode 100644 index 00000000..82456e66 --- /dev/null +++ b/Wino.Core/Synchronizers/Errors/Outlook/OutlookRateLimitHandler.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Graph.Models.ODataErrors; +using Microsoft.Kiota.Abstractions; +using Serilog; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; + +namespace Wino.Core.Synchronizers.Errors.Outlook; + +/// +/// Handles Microsoft Graph throttling responses for Outlook synchronization. +/// +public class OutlookRateLimitHandler : ISynchronizerErrorHandler +{ + private readonly ILogger _logger = Log.ForContext(); + + public bool CanHandle(SynchronizerErrorContext error) + { + return error.ErrorCode == 429 || + (error.Exception is ODataError oDataError && oDataError.ResponseStatusCode == 429) || + (error.Exception is ApiException apiException && apiException.ResponseStatusCode == 429); + } + + public Task HandleAsync(SynchronizerErrorContext error) + { + _logger.Warning(error.Exception, + "Microsoft Graph rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}.", + error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A"); + + error.Severity = SynchronizerErrorSeverity.Transient; + error.Category = SynchronizerErrorCategory.RateLimit; + error.RetryDelay = TimeSpan.FromSeconds(10); + + return Task.FromResult(true); + } +} diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 07279b8a..e12639a5 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -18,7 +18,6 @@ using Microsoft.Graph.Models; using Microsoft.Graph.Models.ODataErrors; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Authentication; -using Microsoft.Kiota.Abstractions.Serialization; using MimeKit; using MoreLinq.Extensions; using Serilog; @@ -1141,78 +1140,82 @@ public class OutlookSynchronizer : WinoSynchronizer DeserializeGraphBatchResponseAsync(BatchResponseContentCollection collection, string requestId, CancellationToken cancellationToken = default) where T : IParsable, new() + private async Task GetSpecialFolderIdsAsync(CancellationToken cancellationToken) { - // This deserialization may throw generalException in case of failure. - // Bug: https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/2010 - // This is a workaround for the bug to retrieve the actual exception. - // All generic batch response deserializations must go under this method. + var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); + var cachedSpecialFolders = TryGetSpecialFolderIdsFromLocalFolders(localFolders); + if (cachedSpecialFolders != null) + { + _logger.Debug("Using cached Outlook special folder ids for {AccountName}", Account.Name); + return cachedSpecialFolders; + } + + _logger.Information("Cached Outlook special folder ids are incomplete for {AccountName}. Fetching from Microsoft Graph.", Account.Name); + + return new OutlookSpecialFolderIdInformation( + await GetWellKnownFolderIdAsync(INBOX_NAME, cancellationToken).ConfigureAwait(false), + await GetWellKnownFolderIdAsync(DELETED_NAME, cancellationToken).ConfigureAwait(false), + await GetWellKnownFolderIdAsync(JUNK_NAME, cancellationToken).ConfigureAwait(false), + await GetWellKnownFolderIdAsync(DRAFTS_NAME, cancellationToken).ConfigureAwait(false), + await GetWellKnownFolderIdAsync(SENT_NAME, cancellationToken).ConfigureAwait(false), + await GetWellKnownFolderIdAsync(ARCHIVE_NAME, cancellationToken).ConfigureAwait(false)); + } + + private async Task GetWellKnownFolderIdAsync(string wellKnownFolderName, CancellationToken cancellationToken) + { try { - return await collection.GetResponseByIdAsync(requestId); + var folder = await _graphClient.Me.MailFolders[wellKnownFolderName] + .GetAsync(requestConfiguration => + { + requestConfiguration.QueryParameters.Select = ["id"]; + }, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(folder?.Id)) + { + throw new SynchronizerException($"Outlook special folder '{wellKnownFolderName}' returned no id."); + } + + return folder.Id; } - catch (ODataError) + catch (OperationCanceledException) { throw; } - //catch (ServiceException retryAfterException) when (retryAfterException.ResponseStatusCode == 429 && retryAfterException.ResponseHeaders.Contains("Retry-After")) - //{ - // // This request must be retried after some time. - // var retryAfterValue = retryAfterException.ResponseHeaders.GetValues("Retry-After").FirstOrDefault(); - - // if (int.TryParse(retryAfterValue, out int seconds)) - // { - // await Task.Delay(seconds); - // } - //} - catch (ServiceException serviceException) + catch (Exception ex) { - // TODO: AOT Comaptible inner exception deserialization. - - // Actual exception is hidden inside ServiceException. - // ODataError errorResult = await KiotaJsonSerializer.DeserializeAsync(serviceException.RawResponseBody, cancellationToken); - - throw new SynchronizerException("Outlook Error", serviceException); + _logger.Warning(ex, "Failed to fetch Outlook special folder id for {FolderName}", wellKnownFolderName); + throw; } } - private async Task GetSpecialFolderIdsAsync(CancellationToken cancellationToken) + private static OutlookSpecialFolderIdInformation TryGetSpecialFolderIdsFromLocalFolders(IEnumerable localFolders) { - var wellKnownFolderIdBatch = new BatchRequestContentCollection(_graphClient); - var folderRequests = new Dictionary + if (localFolders == null) { - { INBOX_NAME, _graphClient.Me.MailFolders[INBOX_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) }, - { SENT_NAME, _graphClient.Me.MailFolders[SENT_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) }, - { DELETED_NAME, _graphClient.Me.MailFolders[DELETED_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) }, - { JUNK_NAME, _graphClient.Me.MailFolders[JUNK_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) }, - { DRAFTS_NAME, _graphClient.Me.MailFolders[DRAFTS_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) }, - { ARCHIVE_NAME, _graphClient.Me.MailFolders[ARCHIVE_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; }) } - }; - - var batchIds = new Dictionary(); - foreach (var request in folderRequests) - { - batchIds[request.Key] = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(request.Value); + return null; } - var returnedResponse = await _graphClient.Batch.PostAsync(wellKnownFolderIdBatch, cancellationToken).ConfigureAwait(false); + var inboxId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Inbox); + var deletedId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Deleted); + var junkId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Junk); + var draftId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Draft); + var sentId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Sent); + var archiveId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Archive); - var folderIds = new Dictionary(); - foreach (var batchId in batchIds) + if (new[] { inboxId, deletedId, junkId, draftId, sentId, archiveId }.Any(string.IsNullOrWhiteSpace)) { - folderIds[batchId.Key] = (await DeserializeGraphBatchResponseAsync(returnedResponse, batchId.Value, cancellationToken)).Id; + return null; } - return new OutlookSpecialFolderIdInformation( - folderIds[INBOX_NAME], - folderIds[DELETED_NAME], - folderIds[JUNK_NAME], - folderIds[DRAFTS_NAME], - folderIds[SENT_NAME], - folderIds[ARCHIVE_NAME]); + return new OutlookSpecialFolderIdInformation(inboxId, deletedId, junkId, draftId, sentId, archiveId); } + private static string GetSpecialFolderRemoteId(IEnumerable localFolders, SpecialFolderType specialFolderType) + => localFolders.FirstOrDefault(folder => folder.SpecialFolderType == specialFolderType && !string.IsNullOrWhiteSpace(folder.RemoteFolderId))?.RemoteFolderId; + private async Task GetDeltaFoldersAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier)) @@ -1865,8 +1868,8 @@ public class OutlookSynchronizer : WinoSynchronizer