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