Graph rate limit handler.

This commit is contained in:
Burak Kaan Köse
2025-10-31 19:53:48 +01:00
parent 37b8a382a8
commit 4d0d2ff099
5 changed files with 175 additions and 38 deletions
@@ -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<IMailItemFolder> Folders
bool CreateThreads,
bool? IsFocusedOnly,
string SearchQuery,
HashSet<Guid> ExistingUniqueIds = null,
ConcurrentDictionary<Guid, bool> ExistingUniqueIds = null,
List<MailCopy> PreFetchMailCopies = null,
int Skip = 0,
int Take = 0);
+147
View File
@@ -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;
/// <summary>
/// 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);
/// </summary>
public class GraphRateLimitHandler : DelegatingHandler
{
private static readonly ILogger _logger = Log.ForContext<GraphRateLimitHandler>();
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<HttpResponseMessage> 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");
}
/// <summary>
/// Extracts the retry delay from the Retry-After header.
/// Supports both seconds (integer) and HTTP date formats.
/// </summary>
/// <param name="response">The HTTP response containing Retry-After header</param>
/// <returns>Number of seconds to wait, or 0 if header is missing or invalid</returns>
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;
}
}
}
+23 -20
View File
@@ -48,6 +48,8 @@ namespace Wino.Core.Synchronizers.Mail;
[JsonSerializable(typeof(OutlookFileAttachment))]
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
/// <summary>
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
///
@@ -118,6 +120,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var handlers = GraphClientFactory.CreateDefaultHandlers();
handlers.Add(GetMicrosoftImmutableIdHandler());
handlers.Add(GetGraphRateLimitHandler());
var httpClient = GraphClientFactory.Create(handlers);
_graphClient = new GraphServiceClient(httpClient, new BaseBearerTokenAuthenticationProvider(tokenProvider));
@@ -130,6 +133,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
private MicrosoftImmutableIdHandler GetMicrosoftImmutableIdHandler() => new();
private GraphRateLimitHandler GetGraphRateLimitHandler() => new();
#endregion
@@ -328,7 +333,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
// config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
}, cancellationToken).ConfigureAwait(false);
}
else
@@ -751,11 +756,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var currentDeltaToken = folder.DeltaToken;
_logger.Debug("Processing delta changes for folder {FolderName} with token {DeltaToken}", folder.FolderName, currentDeltaToken.Substring(0, Math.Min(10, currentDeltaToken.Length)) + "...");
_logger.Debug("Delta sync will include all message properties to detect updates (IsRead, Flag, etc.)");
// Always use Delta endpoint with proper configuration
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
{
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<RequestInformation, Message,
DeltaGetResponse.CreateFromDiscriminatorValue,
cancellationToken: cancellationToken);
var newMailIds = new List<string>();
// Use PageIterator<DeltaGetResponse> for iterating through delta changes
// Use PageIterator to process delta changes (both new messages and updates)
var messageIterator = PageIterator<Message, DeltaGetResponse>
.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<RequestInformation, Message,
if (isMailExists)
{
// Some of the properties of the item are updated.
_logger.Debug("Processing delta update for existing mail {MessageId} in folder {FolderName}", item.Id, folder.FolderName);
if (item.IsRead != null)
{
_logger.Debug("Updating read status for mail {MessageId}: IsRead={IsRead}", item.Id, item.IsRead.GetValueOrDefault());
await _outlookChangeProcessor.ChangeMailReadStatusAsync(item.Id, item.IsRead.GetValueOrDefault()).ConfigureAwait(false);
}
if (item.Flag?.FlagStatus != null)
{
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged)
.ConfigureAwait(false);
var isFlagged = item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged;
_logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged);
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
}
}
else
+2 -16
View File
@@ -792,11 +792,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
if (ActiveFolder == null)
return;
await ExecuteUIThread(() => {
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;
});
+1 -1
View File
@@ -20,7 +20,7 @@
<Identity
Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="0.0.7.0" />
Version="0.0.8.0" />
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>