Skip to content

Commit

Permalink
Support for Rate-Limiting in the PnP Framework + unifiqation of the t…
Browse files Browse the repository at this point in the history
…hrottling implementation for all http requests made
  • Loading branch information
jansenbe committed Nov 19, 2024
1 parent 58164c0 commit eec5fb2
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 34 deletions.
1 change: 1 addition & 0 deletions src/lib/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/lib/PnP.Framework.Test/Pages/ClientSidePagesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
2 changes: 1 addition & 1 deletion src/lib/PnP.Framework.Test/TestCommon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion src/lib/PnP.Framework/AuthenticationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,10 @@ public static AuthenticationManager CreateWithPnPCoreSdk(PnPContext pnpContext)
/// </summary>
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()
Expand Down Expand Up @@ -722,7 +724,7 @@ private void ConfigureAuthenticationManagerEnvironmentSettings(PnPContext pnPCon
};
}
}
#endregion
#endregion

#region Access Token Acquisition
/// <summary>
Expand Down Expand Up @@ -1238,6 +1240,8 @@ private ClientContext BuildClientContext(IClientApplicationBase application, str
DisableReturnValueCache = true
};

clientContext.AddWebRequestExecutorFactory();

clientContext.ExecutingWebRequest += (sender, args) =>
{
AuthenticationResult ar = null;
Expand Down Expand Up @@ -1500,6 +1504,8 @@ public ClientContext GetAccessTokenContext(string siteUrl, Func<string, string>
DisableReturnValueCache = true
};

clientContext.AddWebRequestExecutorFactory();

clientContext.ExecutingWebRequest += (sender, args) =>
{
Uri resourceUri = new Uri(siteUrl);
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using PnP.Framework;
using PnP.Framework.Http;
using PnP.Framework.Utilities.Context;
using System;

Expand All @@ -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))
Expand Down
67 changes: 44 additions & 23 deletions src/lib/PnP.Framework/Http/PnPHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class PnPHttpClient
//private static Configuration configuration;
private const string PnPHttpClientName = "PnPHttpClient";
private static readonly Lazy<PnPHttpClient> _lazyInstance = new Lazy<PnPHttpClient>(() => new PnPHttpClient(), true);
private static readonly SemaphoreSlim semaphoreSlimFactory = new SemaphoreSlim(1);
private ServiceProvider serviceProvider;
private static readonly ConcurrentDictionary<string, HttpClientHandler> credentialsHttpClients = new ConcurrentDictionary<string, HttpClientHandler>();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -224,5 +239,11 @@ private static IServiceCollection AddHttpHandlers(IServiceCollection collection)

return collection;
}

private static IServiceCollection AddPnPServices(IServiceCollection collection)
{
return collection
.AddSingleton<RateLimiter>();
}
}
}
160 changes: 160 additions & 0 deletions src/lib/PnP.Framework/Http/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -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";

/// <summary>
/// Lock for controlling Read/Write access to the variables.
/// </summary>
private readonly ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim();

/// <summary>
/// Maximum number of requests per window
/// </summary>
private int limit;

/// <summary>
/// The time, in <see cref="TimeSpan.Seconds"/>, when the current window gets reset
/// </summary>
private int reset;

/// <summary>
/// The timestamp when current window will be reset, in <see cref="TimeSpan.Ticks"/>.
/// </summary>
private long nextReset;

/// <summary>
/// The remaining requests in the current window.
/// </summary>
private int remaining;

/// <summary>
/// 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
/// </summary>
internal int MinimumCapacityLeft { get; set; } = 10;

/// <summary>
/// Default constructor
/// </summary>
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<string> limitValues))
{
string rateString = limitValues.First();
_ = int.TryParse(rateString, out rateLimit);
}

if (response.Headers.TryGetValues(RATELIMIT_REMAINING, out IEnumerable<string> remainingValues))
{
string rateString = remainingValues.First();
_ = int.TryParse(rateString, out rateRemaining);
}

if (response.Headers.TryGetValues(RATELIMIT_RESET, out IEnumerable<string> 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();
}
}
}

}
}
Loading

0 comments on commit eec5fb2

Please sign in to comment.