Graph rate limit handler.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user