From eec5fb2214fb87946ec8b51e2449b9e91d0ba039 Mon Sep 17 00:00:00 2001 From: Bert Jansen Date: Tue, 19 Nov 2024 20:25:29 +0100 Subject: [PATCH] Support for Rate-Limiting in the PnP Framework + unifiqation of the throttling implementation for all http requests made --- src/lib/CHANGELOG.md | 1 + .../Pages/ClientSidePagesTests.cs | 13 ++ src/lib/PnP.Framework.Test/TestCommon.cs | 2 +- .../PnP.Framework/AuthenticationManager.cs | 10 +- .../Extensions/ClientContextExtensions.cs | 2 + .../InternalClientContextExtensions.cs | 6 + src/lib/PnP.Framework/Http/PnPHttpClient.cs | 67 +++++--- src/lib/PnP.Framework/Http/RateLimiter.cs | 160 ++++++++++++++++++ src/lib/PnP.Framework/Http/RetryHandler.cs | 44 ++++- .../Utilities/PnPHttpProvider.cs | 2 + 10 files changed, 273 insertions(+), 34 deletions(-) create mode 100644 src/lib/PnP.Framework/Http/RateLimiter.cs diff --git a/src/lib/CHANGELOG.md b/src/lib/CHANGELOG.md index 7b759854d..7bf82e2cb 100644 --- a/src/lib/CHANGELOG.md +++ b/src/lib/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added - Support for .NET 9.0 [jansenbe - Bert Jansen] +- Support for Rate-Limiting in the PnP Framework + unifiqation of the throttling implementation for all http requests made [jansenbe - Bert Jansen] ### Changed diff --git a/src/lib/PnP.Framework.Test/Pages/ClientSidePagesTests.cs b/src/lib/PnP.Framework.Test/Pages/ClientSidePagesTests.cs index 835b9231f..f2271ba1b 100644 --- a/src/lib/PnP.Framework.Test/Pages/ClientSidePagesTests.cs +++ b/src/lib/PnP.Framework.Test/Pages/ClientSidePagesTests.cs @@ -3,6 +3,7 @@ using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client.Taxonomy; using Microsoft.VisualStudio.TestTools.UnitTesting; +using PnP.Core.Services; using PnP.Framework.ALM; using PnP.Framework.Provisioning.Connectors; using PnP.Framework.Provisioning.Model; @@ -45,6 +46,18 @@ public static void ClassCleanup() // } //} + [TestMethod] + public void Bert2() + { + using (var cc = TestCommon.CreateClientContext()) + { + cc.Load(cc.Web, p => p.Title); + cc.ExecuteQueryRetry(); + //cc.ExecuteQuery(); + Assert.IsTrue(cc.Web.Title != null); + } + } + [TestMethod] public void ExportPagesTest() { diff --git a/src/lib/PnP.Framework.Test/TestCommon.cs b/src/lib/PnP.Framework.Test/TestCommon.cs index 6a9aa5290..10db548db 100644 --- a/src/lib/PnP.Framework.Test/TestCommon.cs +++ b/src/lib/PnP.Framework.Test/TestCommon.cs @@ -332,7 +332,7 @@ private static ClientContext CreateContext(string contextUrl, AzureEnvironment a } else { - using (AuthenticationManager am = new AuthenticationManager(AppId, UserName, Password, null, azureEnvironment)) + using (AuthenticationManager am = new AuthenticationManager(AzureADClientId, UserName, Password, null, azureEnvironment)) { if (azureEnvironment == AzureEnvironment.Custom) diff --git a/src/lib/PnP.Framework/AuthenticationManager.cs b/src/lib/PnP.Framework/AuthenticationManager.cs index ea93b854e..659f9a47d 100644 --- a/src/lib/PnP.Framework/AuthenticationManager.cs +++ b/src/lib/PnP.Framework/AuthenticationManager.cs @@ -304,8 +304,10 @@ public static AuthenticationManager CreateWithPnPCoreSdk(PnPContext pnpContext) /// public AuthenticationManager() { +#if !NET9_0 // Set the TLS preference. Needed on some server os's to work when Office 365 removes support for TLS 1.0 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; +#endif } private AuthenticationManager(ACSTokenGenerator oAuthAuthenticationProvider) : this() @@ -722,7 +724,7 @@ private void ConfigureAuthenticationManagerEnvironmentSettings(PnPContext pnPCon }; } } - #endregion +#endregion #region Access Token Acquisition /// @@ -1238,6 +1240,8 @@ private ClientContext BuildClientContext(IClientApplicationBase application, str DisableReturnValueCache = true }; + clientContext.AddWebRequestExecutorFactory(); + clientContext.ExecutingWebRequest += (sender, args) => { AuthenticationResult ar = null; @@ -1500,6 +1504,8 @@ public ClientContext GetAccessTokenContext(string siteUrl, Func DisableReturnValueCache = true }; + clientContext.AddWebRequestExecutorFactory(); + clientContext.ExecutingWebRequest += (sender, args) => { Uri resourceUri = new Uri(siteUrl); @@ -1525,6 +1531,8 @@ public ClientContext GetAccessTokenContext(string siteUrl, string accessToken) DisableReturnValueCache = true }; + clientContext.AddWebRequestExecutorFactory(); + clientContext.ExecutingWebRequest += (sender, args) => { args.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken; diff --git a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs index 0734c529a..dbaf9aa93 100644 --- a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs @@ -102,8 +102,10 @@ private static async Task ExecuteQueryImplementation(ClientRuntimeContext client await new SynchronizationContextRemover(); +#if !NET9_0 // Set the TLS preference. Needed on some server os's to work when Office 365 removes support for TLS 1.0 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; +#endif var clientTag = string.Empty; if (clientContext is PnPClientContext) diff --git a/src/lib/PnP.Framework/Extensions/InternalClientContextExtensions.cs b/src/lib/PnP.Framework/Extensions/InternalClientContextExtensions.cs index 9416ca8ed..28270a8c6 100644 --- a/src/lib/PnP.Framework/Extensions/InternalClientContextExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/InternalClientContextExtensions.cs @@ -1,4 +1,5 @@ using PnP.Framework; +using PnP.Framework.Http; using PnP.Framework.Utilities.Context; using System; @@ -16,6 +17,11 @@ public static void AddContextSettings(this ClientRuntimeContext clientContext, C clientContext.StaticObjects[PnPSettingsKey] = contextData; } + public static void AddWebRequestExecutorFactory(this ClientRuntimeContext clientContext) + { + clientContext.WebRequestExecutorFactory = new HttpClientWebRequestExecutorFactory(PnPHttpClient.Instance.GetHttpClient((ClientContext)clientContext)); + } + public static ClientContextSettings GetContextSettings(this ClientRuntimeContext clientContext) { if (!clientContext.StaticObjects.TryGetValue(PnPSettingsKey, out object settingsObject)) diff --git a/src/lib/PnP.Framework/Http/PnPHttpClient.cs b/src/lib/PnP.Framework/Http/PnPHttpClient.cs index a6b315031..b01fe8db2 100644 --- a/src/lib/PnP.Framework/Http/PnPHttpClient.cs +++ b/src/lib/PnP.Framework/Http/PnPHttpClient.cs @@ -20,6 +20,7 @@ public class PnPHttpClient //private static Configuration configuration; private const string PnPHttpClientName = "PnPHttpClient"; private static readonly Lazy _lazyInstance = new Lazy(() => new PnPHttpClient(), true); + private static readonly SemaphoreSlim semaphoreSlimFactory = new SemaphoreSlim(1); private ServiceProvider serviceProvider; private static readonly ConcurrentDictionary credentialsHttpClients = new ConcurrentDictionary(); @@ -62,7 +63,7 @@ public HttpClient GetHttpClient(ClientContext context) // Create a new handler, do not dispose it since we're caching it var handler = new HttpClientHandler { - Credentials = context.Credentials + Credentials = context.Credentials, }; credentialsHttpClients.TryAdd(cacheKey, handler); @@ -125,35 +126,49 @@ public static void AuthenticateRequest(HttpRequestMessage request, string access private void BuildServiceFactory() { - // Use TLS 1.2 as default connection - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + try + { + // Ensure there's only one context factory building happening at any given time + semaphoreSlimFactory.Wait(); - // Create container - var serviceCollection = new ServiceCollection(); + // Use TLS 1.2 as default connection +#if !NET9_0 + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; +#endif - // Add http handlers - AddHttpHandlers(serviceCollection); + // Create container + var serviceCollection = new ServiceCollection(); - // get User Agent String - string userAgentFromConfig = null; - try - { - userAgentFromConfig = ConfigurationManager.AppSettings["SharePointPnPUserAgent"]; - } - catch // throws exception if being called from a .NET Standard 2.0 application - { + // Add http handlers + AddHttpHandlers(serviceCollection); + // Add services + AddPnPServices(serviceCollection); + + // get User Agent String + string userAgentFromConfig = null; + try + { + userAgentFromConfig = ConfigurationManager.AppSettings["SharePointPnPUserAgent"]; + } + catch // throws exception if being called from a .NET Standard 2.0 application + { + + } + if (string.IsNullOrWhiteSpace(userAgentFromConfig)) + { + userAgentFromConfig = Environment.GetEnvironmentVariable("SharePointPnPUserAgent", EnvironmentVariableTarget.Process); + } + + // Add http clients + AddHttpClients(serviceCollection, userAgentFromConfig); + // Build the container + serviceProvider = serviceCollection.BuildServiceProvider(); } - if (string.IsNullOrWhiteSpace(userAgentFromConfig)) + finally { - userAgentFromConfig = Environment.GetEnvironmentVariable("SharePointPnPUserAgent", EnvironmentVariableTarget.Process); + semaphoreSlimFactory.Release(); } - - // Add http clients - AddHttpClients(serviceCollection, userAgentFromConfig); - - // Build the container - serviceProvider = serviceCollection.BuildServiceProvider(); } private static TimeSpan GetHttpTimeout() @@ -224,5 +239,11 @@ private static IServiceCollection AddHttpHandlers(IServiceCollection collection) return collection; } + + private static IServiceCollection AddPnPServices(IServiceCollection collection) + { + return collection + .AddSingleton(); + } } } diff --git a/src/lib/PnP.Framework/Http/RateLimiter.cs b/src/lib/PnP.Framework/Http/RateLimiter.cs new file mode 100644 index 000000000..54d76cd94 --- /dev/null +++ b/src/lib/PnP.Framework/Http/RateLimiter.cs @@ -0,0 +1,160 @@ +using PnP.Framework.Diagnostics; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace PnP.Framework.Http +{ + public class RateLimiter + { + internal const string RATELIMIT_LIMIT = "RateLimit-Limit"; + internal const string RATELIMIT_REMAINING = "RateLimit-Remaining"; + internal const string RATELIMIT_RESET = "RateLimit-Reset"; + + /// + /// Lock for controlling Read/Write access to the variables. + /// + private readonly ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim(); + + /// + /// Maximum number of requests per window + /// + private int limit; + + /// + /// The time, in , when the current window gets reset + /// + private int reset; + + /// + /// The timestamp when current window will be reset, in . + /// + private long nextReset; + + /// + /// The remaining requests in the current window. + /// + private int remaining; + + /// + /// Minimum % of requests left before the next request will get delayed until the current window is reset. Defaults to 10, set to 0 to disable rate limiting + /// + internal int MinimumCapacityLeft { get; set; } = 10; + + /// + /// Default constructor + /// + public RateLimiter() + { + readerWriterLock.EnterWriteLock(); + try + { + _ = Interlocked.Exchange(ref limit, -1); + _ = Interlocked.Exchange(ref remaining, -1); + _ = Interlocked.Exchange(ref reset, -1); + _ = Interlocked.Exchange(ref nextReset, DateTime.UtcNow.Ticks); + } + finally + { + readerWriterLock.ExitWriteLock(); + } + } + + internal async Task WaitAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // We're not using the rate limiter + if (MinimumCapacityLeft == 0) + { + return; + } + + long delayInTicks = 0; + float capacityLeft = 0; + readerWriterLock.EnterReadLock(); + try + { + // Remaining = 0 means the request is throttled and there's a retry-after header that will be used + if (limit > 0 && remaining > 0) + { + // Calculate percentage requests left in the current window + capacityLeft = (float)remaining / limit * 100; + + // If getting below the minimum required capacity then lets wait until the current window is reset + if (capacityLeft <= MinimumCapacityLeft) + { + delayInTicks = nextReset - DateTime.UtcNow.Ticks; + } + } + } + finally + { + readerWriterLock.ExitReadLock(); + } + + if (delayInTicks > 0) + { + Log.Info(Constants.LOGGING_SOURCE, $"Delaying request for {new TimeSpan(delayInTicks).Seconds} seconds because remaining request capacity for the current window is at {capacityLeft}%, so below the {MinimumCapacityLeft}% threshold."); + + await Task.Delay(new TimeSpan(delayInTicks), cancellationToken).ConfigureAwait(false); + } + } + + internal void UpdateWindow(HttpResponseMessage response) + { + int rateLimit = -1; + int rateRemaining = -1; + int rateReset = -1; + + // We're not using the rate limiter + if (MinimumCapacityLeft == 0) + { + return; + } + + if (response != null) + { + if (response.Headers.TryGetValues(RATELIMIT_LIMIT, out IEnumerable limitValues)) + { + string rateString = limitValues.First(); + _ = int.TryParse(rateString, out rateLimit); + } + + if (response.Headers.TryGetValues(RATELIMIT_REMAINING, out IEnumerable remainingValues)) + { + string rateString = remainingValues.First(); + _ = int.TryParse(rateString, out rateRemaining); + } + + if (response.Headers.TryGetValues(RATELIMIT_RESET, out IEnumerable resetValues)) + { + string rateString = resetValues.First(); + _ = int.TryParse(rateString, out rateReset); + } + + readerWriterLock.EnterWriteLock(); + try + { + _ = Interlocked.Exchange(ref limit, rateLimit); + _ = Interlocked.Exchange(ref remaining, rateRemaining); + _ = Interlocked.Exchange(ref reset, rateReset); + + if (rateReset > -1) + { + // Track when the current window get's reset + _ = Interlocked.Exchange(ref nextReset, DateTime.UtcNow.Ticks + TimeSpan.FromSeconds(rateReset).Ticks); + } + } + finally + { + readerWriterLock.ExitWriteLock(); + } + } + } + + } +} diff --git a/src/lib/PnP.Framework/Http/RetryHandler.cs b/src/lib/PnP.Framework/Http/RetryHandler.cs index 940fec2d6..c6555c6e2 100644 --- a/src/lib/PnP.Framework/Http/RetryHandler.cs +++ b/src/lib/PnP.Framework/Http/RetryHandler.cs @@ -1,4 +1,5 @@ -using System; +using PnP.Framework.Diagnostics; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -15,13 +16,15 @@ namespace PnP.Framework.Http /// internal class RetryHandler : DelegatingHandler { + private readonly RateLimiter rateLimiter; private const string RETRY_AFTER = "Retry-After"; private const string RETRY_ATTEMPT = "Retry-Attempt"; internal const int MAXDELAY = 300; #region Construction - public RetryHandler() + public RetryHandler(RateLimiter limiter) { + rateLimiter = limiter; } #endregion @@ -37,12 +40,22 @@ protected override async Task SendAsync(HttpRequestMessage while (true) { - HttpResponseMessage response = null; + HttpResponseMessage response = null; + + // Throw an exception if we've requested to cancel the operation + cancellationToken.ThrowIfCancellationRequested(); try { + if (rateLimiter != null) + { + await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); + } + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + rateLimiter?.UpdateWindow(response); + if (!ShouldRetry(response.StatusCode)) { return response; @@ -63,6 +76,8 @@ protected override async Task SendAsync(HttpRequestMessage throw new Exception($"Too many http request retries: {retryCount}"); } + + Log.Info(Constants.LOGGING_SOURCE, $"Retrying request {request.RequestUri} due to status code {response.StatusCode}"); } catch (Exception ex) { @@ -75,10 +90,20 @@ protected override async Task SendAsync(HttpRequestMessage throw; } + // Hostname unknown error code 11001 should not be retried + if ((innermostEx as SocketException).ErrorCode == 11001) + { + throw; + } + if (retryCount >= MaxRetries) { throw; } + + string errorMessage = innermostEx.Message; + + Log.Info(Constants.LOGGING_SOURCE, $"Retrying request {request.RequestUri} due to exception {innermostEx.GetType()}: {innermostEx.Message}"); } // Drain response content to free connections. Need to perform this @@ -93,7 +118,9 @@ protected override async Task SendAsync(HttpRequestMessage } // Call Delay method to get delay time from response's Retry-After header or by exponential backoff - Task delay = Delay(response, retryCount, DelayInSeconds, cancellationToken); + TimeSpan delayTimeSpan = CalculateWaitTime(response, retryCount, DelayInSeconds); + Log.Info(Constants.LOGGING_SOURCE, $"Waiting {delayTimeSpan.Seconds} seconds before retrying"); + Task delay = Task.Delay(delayTimeSpan, cancellationToken); // general clone request with internal CloneAsync (see CloneAsync for details) extension method // do not dispose this request as that breaks the request cloning @@ -124,7 +151,7 @@ private void AddOrUpdateRetryAttempt(HttpRequestMessage request, int retryCount) } } - private Task Delay(HttpResponseMessage response, int retryCount, int delay, CancellationToken cancellationToken) + private TimeSpan CalculateWaitTime(HttpResponseMessage response, int retryCount, int delay) { double delayInSeconds = delay; @@ -132,7 +159,7 @@ private Task Delay(HttpResponseMessage response, int retryCount, int delay, Canc { // Can we use the provided retry-after header? string retryAfter = values.First(); - if (Int32.TryParse(retryAfter, out int delaySeconds)) + if (int.TryParse(retryAfter, out int delaySeconds)) { delayInSeconds = delaySeconds; } @@ -154,9 +181,8 @@ private Task Delay(HttpResponseMessage response, int retryCount, int delay, Canc } // If the delay goes beyond our max wait time for a delay then cap it - TimeSpan delayTimeSpan = TimeSpan.FromSeconds(Math.Min(delayInSeconds, RetryHandler.MAXDELAY)); - - return Task.Delay(delayTimeSpan, cancellationToken); + TimeSpan delayTimeSpan = TimeSpan.FromSeconds(Math.Min(delayInSeconds, MAXDELAY)); + return delayTimeSpan; } internal static bool ShouldRetry(HttpStatusCode statusCode) diff --git a/src/lib/PnP.Framework/Utilities/PnPHttpProvider.cs b/src/lib/PnP.Framework/Utilities/PnPHttpProvider.cs index 435c11820..87f10ded5 100644 --- a/src/lib/PnP.Framework/Utilities/PnPHttpProvider.cs +++ b/src/lib/PnP.Framework/Utilities/PnPHttpProvider.cs @@ -48,8 +48,10 @@ public PnPHttpProvider(HttpMessageHandler innerHandler, bool disposeHandler, int { this.userAgent = userAgent; +#if !NET9_0 // Use TLS 1.2 as default connection ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; +#endif this.retryHandler = new PnPHttpRetryHandler(retryCount, delay); }