From 4d0d2ff09946612c1599536e7c804ad7cd609401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 31 Oct 2025 19:53:48 +0100 Subject: [PATCH] Graph rate limit handler. --- .../MailItem/MailListInitializationOptions.cs | 3 +- Wino.Core/Http/GraphRateLimitHandler.cs | 147 ++++++++++++++++++ .../Synchronizers/OutlookSynchronizer.cs | 43 ++--- Wino.Mail.ViewModels/MailListPageViewModel.cs | 18 +-- Wino.Mail.WinUI/Package.appxmanifest | 2 +- 5 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 Wino.Core/Http/GraphRateLimitHandler.cs diff --git a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs index c938941f..7d97e19d 100644 --- a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs +++ b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -12,7 +13,7 @@ public record MailListInitializationOptions(IEnumerable Folders bool CreateThreads, bool? IsFocusedOnly, string SearchQuery, - HashSet ExistingUniqueIds = null, + ConcurrentDictionary ExistingUniqueIds = null, List PreFetchMailCopies = null, int Skip = 0, int Take = 0); diff --git a/Wino.Core/Http/GraphRateLimitHandler.cs b/Wino.Core/Http/GraphRateLimitHandler.cs new file mode 100644 index 00000000..7503b701 --- /dev/null +++ b/Wino.Core/Http/GraphRateLimitHandler.cs @@ -0,0 +1,147 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Serilog; + +namespace Wino.Core.Http; + +/// +/// DelegatingHandler that automatically handles Microsoft Graph API 429 rate limiting responses. +/// Integrates directly with the Graph SDK HTTP pipeline to provide transparent retry functionality. +/// +/// Features: +/// - Intercepts 429 (Too Many Requests) HTTP responses before they become ServiceExceptions +/// - Respects Retry-After header from responses (both seconds and HTTP date formats) +/// - Maximum 3 retry attempts to prevent infinite loops +/// - Caps retry delays to 5 minutes maximum +/// - Uses 60-second default delay if no Retry-After header is provided +/// - Comprehensive logging for debugging and monitoring +/// - Thread-safe and cancellation token aware +/// - Integrates seamlessly with existing Graph SDK error handling +/// +/// Usage: +/// Add to GraphServiceClient handlers in OutlookSynchronizer constructor: +/// +/// var handlers = GraphClientFactory.CreateDefaultHandlers(); +/// handlers.Add(new MicrosoftImmutableIdHandler()); +/// handlers.Add(new GraphRateLimitHandler()); +/// var httpClient = GraphClientFactory.Create(handlers); +/// +public class GraphRateLimitHandler : DelegatingHandler +{ + private static readonly ILogger _logger = Log.ForContext(); + private const int MaxRetryAttempts = 3; + private const int MaxDelaySeconds = 300; // 5 minutes cap + private const int DefaultDelaySeconds = 60; // Default delay when no Retry-After header + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var attempt = 0; + + while (attempt <= MaxRetryAttempts) + { + HttpResponseMessage response; + + try + { + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error(ex, "Error sending request to {Uri} on attempt {Attempt}", request.RequestUri, attempt + 1); + throw; + } + + // Check if we got a 429 Too Many Requests response + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + if (attempt == MaxRetryAttempts) + { + _logger.Warning("Max retry attempts ({MaxAttempts}) reached for rate limited request to {Uri}", + MaxRetryAttempts, request.RequestUri); + return response; // Return the 429 response after max attempts + } + + // Get the Retry-After header value + var retryAfterSeconds = GetRetryAfterSeconds(response); + + if (retryAfterSeconds > 0) + { + // Cap the delay to a reasonable maximum + var cappedDelay = Math.Min(retryAfterSeconds, MaxDelaySeconds); + + _logger.Information("Rate limited (429) - waiting {RetrySeconds} seconds before retry attempt {Attempt}/{MaxAttempts} for {Uri}", + cappedDelay, attempt + 1, MaxRetryAttempts, request.RequestUri); + + await Task.Delay(TimeSpan.FromSeconds(cappedDelay), cancellationToken).ConfigureAwait(false); + } + else + { + _logger.Warning("Rate limited (429) but no valid Retry-After header found for {Uri} - using default {DefaultDelay} second delay", + request.RequestUri, DefaultDelaySeconds); + + // Use a default delay if no Retry-After header is provided + await Task.Delay(TimeSpan.FromSeconds(DefaultDelaySeconds), cancellationToken).ConfigureAwait(false); + } + + attempt++; + response.Dispose(); // Dispose the 429 response before retry + continue; + } + + // Success or other error - return the response + return response; + } + + // This should never be reached, but just in case + throw new InvalidOperationException("Rate limiting retry logic error"); + } + + /// + /// Extracts the retry delay from the Retry-After header. + /// Supports both seconds (integer) and HTTP date formats. + /// + /// The HTTP response containing Retry-After header + /// Number of seconds to wait, or 0 if header is missing or invalid + private int GetRetryAfterSeconds(HttpResponseMessage response) + { + try + { + // Check if Retry-After header exists + if (response.Headers.RetryAfter == null) + { + _logger.Debug("No Retry-After header found in response"); + return 0; + } + + // Handle retry-after-seconds (integer) + if (response.Headers.RetryAfter.Delta.HasValue) + { + var seconds = (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds; + _logger.Debug("Found Retry-After delta: {Seconds} seconds", seconds); + return seconds; + } + + // Handle retry-after-date (HTTP date) + if (response.Headers.RetryAfter.Date.HasValue) + { + var retryAfterTime = response.Headers.RetryAfter.Date.Value; + var delaySeconds = (int)(retryAfterTime - DateTimeOffset.UtcNow).TotalSeconds; + _logger.Debug("Found Retry-After date: {Date}, calculated delay: {Seconds} seconds", retryAfterTime, delaySeconds); + + // Ensure we don't have a negative delay + return Math.Max(0, delaySeconds); + } + + _logger.Debug("Retry-After header present but no valid value found"); + return 0; + } + catch (Exception ex) + { + _logger.Warning(ex, "Error parsing Retry-After header"); + return 0; + } + } +} \ No newline at end of file diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index c18340a6..8afb5ac2 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -48,6 +48,8 @@ namespace Wino.Core.Synchronizers.Mail; [JsonSerializable(typeof(OutlookFileAttachment))] public partial class OutlookSynchronizerJsonContext : JsonSerializerContext; + + /// /// Outlook synchronizer implementation with queue-based metadata-only synchronization. /// @@ -118,6 +120,7 @@ public class OutlookSynchronizer : WinoSynchronizer new(); + private GraphRateLimitHandler GetGraphRateLimitHandler() => new(); + #endregion @@ -328,7 +333,7 @@ public class OutlookSynchronizer : WinoSynchronizer { - config.QueryParameters.Select = ["Id"]; // Only get IDs + config.QueryParameters.Select = outlookMessageSelectParameters; // Include all necessary fields for detecting updates config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc }); @@ -766,30 +772,24 @@ public class OutlookSynchronizer : WinoSynchronizer(); - - // Use PageIterator for iterating through delta changes + // Use PageIterator to process delta changes (both new messages and updates) var messageIterator = PageIterator - .CreatePageIterator(_graphClient, messageCollectionPage, (message) => + .CreatePageIterator(_graphClient, messageCollectionPage, async (message) => { - // Only process new messages, not deleted ones - if (!IsResourceDeleted(message.AdditionalData)) + try { - newMailIds.Add(message.Id); + await HandleItemRetrievedAsync(message, folder, downloadedMessageIds, cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to handle delta item {MessageId} for folder {FolderName}", message.Id, folder.FolderName); + return true; // Continue processing other items } - return true; }); await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false); - // Download new mails with metadata only (no MIME) - if (newMailIds.Any()) - { - _logger.Information("Downloading {Count} new mails from delta sync for folder {FolderName} (metadata only)", newMailIds.Count, folder.FolderName); - var deltaDownloadedIds = await DownloadMessageMetadataBatchAsync(newMailIds, folder, true, cancellationToken).ConfigureAwait(false); - downloadedMessageIds.AddRange(deltaDownloadedIds); - } - // Update delta token for next sync - always store when there are no nextPageToken remaining if (!string.IsNullOrEmpty(messageIterator.Deltalink)) { @@ -985,16 +985,19 @@ public class OutlookSynchronizer : WinoSynchronizer { - IsInitializingFolder = true; - // Show initial loading progress - UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, "Loading emails..."); - }); + await ExecuteUIThread(() => { IsInitializingFolder = true; }); // Folder is changed during initialization. // Just cancel the existing one and wait for new initialization. @@ -887,22 +883,12 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (!listManipulationCancellationTokenSource.IsCancellationRequested) { - // Update progress: Creating view models - await ExecuteUIThread(() => { - UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, $"Processing {items.Count} emails..."); - }); - // Here they are already threaded if needed. // We don't need to insert them one by one. // Just create VMs and do bulk insert. var viewModels = await PrepareMailViewModelsAsync(items, cancellationToken).ConfigureAwait(false); - // Update progress: Adding to collection - await ExecuteUIThread(() => { - UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, "Finalizing..."); - }); - await MailCollection.AddRangeAsync(viewModels, clearIdCache: true); await ExecuteUIThread(() => @@ -937,7 +923,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, OnPropertyChanged(nameof(CanSynchronize)); NotifyItemFoundState(); - + // Clear the loading message after completion IsBarOpen = false; }); diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index 9c09a49e..660b7214 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -20,7 +20,7 @@ + Version="0.0.8.0" />