Files
Wino-Mail/Wino.Core/Http/GraphRateLimitHandler.cs
T
2025-10-31 19:53:48 +01:00

147 lines
6.0 KiB
C#

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;
}
}
}