Graph rate limit handler.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
@@ -12,7 +13,7 @@ public record MailListInitializationOptions(IEnumerable<IMailItemFolder> Folders
|
|||||||
bool CreateThreads,
|
bool CreateThreads,
|
||||||
bool? IsFocusedOnly,
|
bool? IsFocusedOnly,
|
||||||
string SearchQuery,
|
string SearchQuery,
|
||||||
HashSet<Guid> ExistingUniqueIds = null,
|
ConcurrentDictionary<Guid, bool> ExistingUniqueIds = null,
|
||||||
List<MailCopy> PreFetchMailCopies = null,
|
List<MailCopy> PreFetchMailCopies = null,
|
||||||
int Skip = 0,
|
int Skip = 0,
|
||||||
int Take = 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))]
|
[JsonSerializable(typeof(OutlookFileAttachment))]
|
||||||
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
|
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
|
||||||
///
|
///
|
||||||
@@ -118,6 +120,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
var handlers = GraphClientFactory.CreateDefaultHandlers();
|
var handlers = GraphClientFactory.CreateDefaultHandlers();
|
||||||
|
|
||||||
handlers.Add(GetMicrosoftImmutableIdHandler());
|
handlers.Add(GetMicrosoftImmutableIdHandler());
|
||||||
|
handlers.Add(GetGraphRateLimitHandler());
|
||||||
|
|
||||||
var httpClient = GraphClientFactory.Create(handlers);
|
var httpClient = GraphClientFactory.Create(handlers);
|
||||||
_graphClient = new GraphServiceClient(httpClient, new BaseBearerTokenAuthenticationProvider(tokenProvider));
|
_graphClient = new GraphServiceClient(httpClient, new BaseBearerTokenAuthenticationProvider(tokenProvider));
|
||||||
@@ -130,6 +133,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
private MicrosoftImmutableIdHandler GetMicrosoftImmutableIdHandler() => new();
|
private MicrosoftImmutableIdHandler GetMicrosoftImmutableIdHandler() => new();
|
||||||
|
|
||||||
|
private GraphRateLimitHandler GetGraphRateLimitHandler() => new();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +333,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
{
|
{
|
||||||
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
||||||
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
||||||
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
|
// config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -751,11 +756,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
var currentDeltaToken = folder.DeltaToken;
|
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("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
|
// Always use Delta endpoint with proper configuration
|
||||||
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
|
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
|
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -766,30 +772,24 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
DeltaGetResponse.CreateFromDiscriminatorValue,
|
DeltaGetResponse.CreateFromDiscriminatorValue,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
var newMailIds = new List<string>();
|
// Use PageIterator to process delta changes (both new messages and updates)
|
||||||
|
|
||||||
// Use PageIterator<DeltaGetResponse> for iterating through delta changes
|
|
||||||
var messageIterator = PageIterator<Message, DeltaGetResponse>
|
var messageIterator = PageIterator<Message, DeltaGetResponse>
|
||||||
.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
|
.CreatePageIterator(_graphClient, messageCollectionPage, async (message) =>
|
||||||
{
|
{
|
||||||
// Only process new messages, not deleted ones
|
try
|
||||||
if (!IsResourceDeleted(message.AdditionalData))
|
|
||||||
{
|
{
|
||||||
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);
|
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
|
// Update delta token for next sync - always store when there are no nextPageToken remaining
|
||||||
if (!string.IsNullOrEmpty(messageIterator.Deltalink))
|
if (!string.IsNullOrEmpty(messageIterator.Deltalink))
|
||||||
{
|
{
|
||||||
@@ -985,16 +985,19 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
if (isMailExists)
|
if (isMailExists)
|
||||||
{
|
{
|
||||||
// Some of the properties of the item are updated.
|
// 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)
|
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);
|
await _outlookChangeProcessor.ChangeMailReadStatusAsync(item.Id, item.IsRead.GetValueOrDefault()).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Flag?.FlagStatus != null)
|
if (item.Flag?.FlagStatus != null)
|
||||||
{
|
{
|
||||||
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged)
|
var isFlagged = item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged;
|
||||||
.ConfigureAwait(false);
|
_logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged);
|
||||||
|
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -792,11 +792,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
if (ActiveFolder == null)
|
if (ActiveFolder == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await ExecuteUIThread(() => {
|
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
||||||
IsInitializingFolder = true;
|
|
||||||
// Show initial loading progress
|
|
||||||
UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, "Loading emails...");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Folder is changed during initialization.
|
// Folder is changed during initialization.
|
||||||
// Just cancel the existing one and wait for new initialization.
|
// Just cancel the existing one and wait for new initialization.
|
||||||
@@ -887,22 +883,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
if (!listManipulationCancellationTokenSource.IsCancellationRequested)
|
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.
|
// Here they are already threaded if needed.
|
||||||
// We don't need to insert them one by one.
|
// We don't need to insert them one by one.
|
||||||
// Just create VMs and do bulk insert.
|
// Just create VMs and do bulk insert.
|
||||||
|
|
||||||
var viewModels = await PrepareMailViewModelsAsync(items, cancellationToken).ConfigureAwait(false);
|
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 MailCollection.AddRangeAsync(viewModels, clearIdCache: true);
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
@@ -937,7 +923,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
OnPropertyChanged(nameof(CanSynchronize));
|
OnPropertyChanged(nameof(CanSynchronize));
|
||||||
NotifyItemFoundState();
|
NotifyItemFoundState();
|
||||||
|
|
||||||
// Clear the loading message after completion
|
// Clear the loading message after completion
|
||||||
IsBarOpen = false;
|
IsBarOpen = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="58272BurakKSE.WinoMailPreview"
|
Name="58272BurakKSE.WinoMailPreview"
|
||||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
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"/>
|
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user