diff --git a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
index 4a53a07a61b4..9b5d4268cb21 100644
--- a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
+++ b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
@@ -11,28 +11,6 @@
true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
index ff0c54dadae1..ccdbcde598a7 100644
--- a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
+++ b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
@@ -53,6 +53,29 @@ Microsoft.AspNetCore.Http.HttpResponse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Middleware/RateLimiting/src/LeaseContext.cs b/src/Middleware/RateLimiting/src/LeaseContext.cs
index 1b78c3f8bf85..98068291bf34 100644
--- a/src/Middleware/RateLimiting/src/LeaseContext.cs
+++ b/src/Middleware/RateLimiting/src/LeaseContext.cs
@@ -22,4 +22,4 @@ internal enum RequestRejectionReason
EndpointLimiter,
GlobalLimiter,
RequestCanceled
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/RateLimiting/src/MetricsContext.cs b/src/Middleware/RateLimiting/src/MetricsContext.cs
new file mode 100644
index 000000000000..36995e5a53a2
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/MetricsContext.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+internal readonly struct MetricsContext
+{
+ public readonly string? PolicyName;
+ public readonly string? Method;
+ public readonly string? Route;
+ public readonly bool CurrentLeaseRequestsCounterEnabled;
+ public readonly bool CurrentRequestsQueuedCounterEnabled;
+
+ public MetricsContext(string? policyName, string? method, string? route, bool currentLeaseRequestsCounterEnabled, bool currentRequestsQueuedCounterEnabled)
+ {
+ PolicyName = policyName;
+ Method = method;
+ Route = route;
+ CurrentLeaseRequestsCounterEnabled = currentLeaseRequestsCounterEnabled;
+ CurrentRequestsQueuedCounterEnabled = currentRequestsQueuedCounterEnabled;
+ }
+}
diff --git a/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs b/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
index cd1ec7b82604..360c3baafb3a 100644
--- a/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
+++ b/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
@@ -2,7 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using Resources = Microsoft.AspNetCore.RateLimiting.Resources;
namespace Microsoft.AspNetCore.Builder;
@@ -20,6 +22,8 @@ public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
+ VerifyServicesAreRegistered(app);
+
return app.UseMiddleware();
}
@@ -34,6 +38,19 @@ public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app, R
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
+ VerifyServicesAreRegistered(app);
+
return app.UseMiddleware(Options.Create(options));
}
+
+ private static void VerifyServicesAreRegistered(IApplicationBuilder app)
+ {
+ var serviceProviderIsService = app.ApplicationServices.GetService();
+ if (serviceProviderIsService != null && !serviceProviderIsService.IsService(typeof(RateLimitingMetrics)))
+ {
+ throw new InvalidOperationException(Resources.FormatUnableToFindServices(
+ nameof(IServiceCollection),
+ nameof(RateLimiterServiceCollectionExtensions.AddRateLimiter)));
+ }
+ }
}
diff --git a/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs b/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
index ac3c6718000f..09f6f7ba7c5c 100644
--- a/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
+++ b/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
@@ -22,6 +22,8 @@ public static IServiceCollection AddRateLimiter(this IServiceCollection services
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
+ services.AddMetrics();
+ services.AddSingleton();
services.Configure(configureOptions);
return services;
}
diff --git a/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs b/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs
new file mode 100644
index 000000000000..cf7bbb533e7a
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs
@@ -0,0 +1,177 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.Metrics;
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+internal sealed class RateLimitingMetrics : IDisposable
+{
+ public const string MeterName = "Microsoft.AspNetCore.RateLimiting";
+
+ private readonly Meter _meter;
+ private readonly UpDownCounter _currentLeasedRequestsCounter;
+ private readonly Histogram _leasedRequestDurationCounter;
+ private readonly UpDownCounter _currentQueuedRequestsCounter;
+ private readonly Histogram _queuedRequestDurationCounter;
+ private readonly Counter _leaseFailedRequestsCounter;
+
+ public RateLimitingMetrics(IMeterFactory meterFactory)
+ {
+ _meter = meterFactory.CreateMeter(MeterName);
+
+ _currentLeasedRequestsCounter = _meter.CreateUpDownCounter(
+ "current-leased-requests",
+ description: "Number of HTTP requests that are currently active on the server that hold a rate limiting lease.");
+
+ _leasedRequestDurationCounter = _meter.CreateHistogram(
+ "leased-request-duration",
+ unit: "s",
+ description: "The duration of rate limiting leases held by HTTP requests on the server.");
+
+ _currentQueuedRequestsCounter = _meter.CreateUpDownCounter(
+ "current-queued-requests",
+ description: "Number of HTTP requests that are currently queued, waiting to acquire a rate limiting lease.");
+
+ _queuedRequestDurationCounter = _meter.CreateHistogram(
+ "queued-request-duration",
+ unit: "s",
+ description: "The duration of HTTP requests in a queue, waiting to acquire a rate limiting lease.");
+
+ _leaseFailedRequestsCounter = _meter.CreateCounter(
+ "lease-failed-requests",
+ description: "Number of HTTP requests that failed to acquire a rate limiting lease. Requests could be rejected by global or endpoint rate limiting policies. Or the request could be canceled while waiting for the lease.");
+ }
+
+ public bool CurrentLeasedRequestsCounterEnabled => _currentLeasedRequestsCounter.Enabled;
+ public bool CurrentQueuedRequestsCounterEnabled => _currentQueuedRequestsCounter.Enabled;
+
+ public void LeaseFailed(in MetricsContext metricsContext, RequestRejectionReason reason)
+ {
+ if (_leaseFailedRequestsCounter.Enabled)
+ {
+ LeaseFailedCore(metricsContext, reason);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void LeaseFailedCore(in MetricsContext metricsContext, RequestRejectionReason reason)
+ {
+ var tags = new TagList();
+ InitializeRateLimitingTags(ref tags, metricsContext);
+ tags.Add("reason", reason.ToString());
+ _leaseFailedRequestsCounter.Add(1, tags);
+ }
+
+ public void LeaseStart(in MetricsContext metricsContext)
+ {
+ if (metricsContext.CurrentLeaseRequestsCounterEnabled)
+ {
+ LeaseStartCore(metricsContext);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void LeaseStartCore(in MetricsContext metricsContext)
+ {
+ var tags = new TagList();
+ InitializeRateLimitingTags(ref tags, metricsContext);
+ _currentLeasedRequestsCounter.Add(1, tags);
+ }
+
+ public void LeaseEnd(in MetricsContext metricsContext, long startTimestamp, long currentTimestamp)
+ {
+ if (metricsContext.CurrentLeaseRequestsCounterEnabled || _leasedRequestDurationCounter.Enabled)
+ {
+ LeaseEndCore(metricsContext, startTimestamp, currentTimestamp);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void LeaseEndCore(in MetricsContext metricsContext, long startTimestamp, long currentTimestamp)
+ {
+ var tags = new TagList();
+ InitializeRateLimitingTags(ref tags, metricsContext);
+
+ if (metricsContext.CurrentLeaseRequestsCounterEnabled)
+ {
+ _currentLeasedRequestsCounter.Add(-1, tags);
+ }
+
+ if (_leasedRequestDurationCounter.Enabled)
+ {
+ var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
+ _leasedRequestDurationCounter.Record(duration.TotalSeconds, tags);
+ }
+ }
+
+ public void QueueStart(in MetricsContext metricsContext)
+ {
+ if (metricsContext.CurrentRequestsQueuedCounterEnabled)
+ {
+ QueueStartCore(metricsContext);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void QueueStartCore(in MetricsContext metricsContext)
+ {
+ var tags = new TagList();
+ InitializeRateLimitingTags(ref tags, metricsContext);
+ _currentQueuedRequestsCounter.Add(1, tags);
+ }
+
+ public void QueueEnd(in MetricsContext metricsContext, RequestRejectionReason? reason, long startTimestamp, long currentTimestamp)
+ {
+ if (metricsContext.CurrentRequestsQueuedCounterEnabled || _queuedRequestDurationCounter.Enabled)
+ {
+ QueueEndCore(metricsContext, reason, startTimestamp, currentTimestamp);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void QueueEndCore(in MetricsContext metricsContext, RequestRejectionReason? reason, long startTimestamp, long currentTimestamp)
+ {
+ var tags = new TagList();
+ InitializeRateLimitingTags(ref tags, metricsContext);
+
+ if (metricsContext.CurrentRequestsQueuedCounterEnabled)
+ {
+ _currentQueuedRequestsCounter.Add(-1, tags);
+ }
+
+ if (_queuedRequestDurationCounter.Enabled)
+ {
+ if (reason != null)
+ {
+ tags.Add("reason", reason.Value.ToString());
+ }
+ var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
+ _queuedRequestDurationCounter.Record(duration.TotalSeconds, tags);
+ }
+ }
+
+ public void Dispose()
+ {
+ _meter.Dispose();
+ }
+
+ private static void InitializeRateLimitingTags(ref TagList tags, in MetricsContext metricsContext)
+ {
+ if (metricsContext.PolicyName is not null)
+ {
+ tags.Add("policy", metricsContext.PolicyName);
+ }
+ if (metricsContext.Method is not null)
+ {
+ tags.Add("method", metricsContext.Method);
+ }
+ if (metricsContext.Route is not null)
+ {
+ tags.Add("route", metricsContext.Route);
+ }
+ }
+}
diff --git a/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
index d743f77feea6..2e494b8c5449 100644
--- a/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
+++ b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
@@ -1,8 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -16,6 +18,7 @@ internal sealed partial class RateLimitingMiddleware
private readonly RequestDelegate _next;
private readonly Func? _defaultOnRejected;
private readonly ILogger _logger;
+ private readonly RateLimitingMetrics _metrics;
private readonly PartitionedRateLimiter? _globalLimiter;
private readonly PartitionedRateLimiter _endpointLimiter;
private readonly int _rejectionStatusCode;
@@ -29,14 +32,17 @@ internal sealed partial class RateLimitingMiddleware
/// The used for logging.
/// The options for the middleware.
/// The service provider.
- public RateLimitingMiddleware(RequestDelegate next, ILogger logger, IOptions options, IServiceProvider serviceProvider)
+ /// The rate limiting metrics.
+ public RateLimitingMiddleware(RequestDelegate next, ILogger logger, IOptions options, IServiceProvider serviceProvider, RateLimitingMetrics metrics)
{
ArgumentNullException.ThrowIfNull(next);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(serviceProvider);
+ ArgumentNullException.ThrowIfNull(metrics);
_next = next;
_logger = logger;
+ _metrics = metrics;
_defaultOnRejected = options.Value.OnRejected;
_rejectionStatusCode = options.Value.RejectionStatusCode;
_policyMap = new Dictionary(options.Value.PolicyMap);
@@ -49,7 +55,6 @@ public RateLimitingMiddleware(RequestDelegate next, ILogger()?.Route;
+ var method = route is not null ? context.Request.Method : null;
+
+ return InvokeInternal(context, enableRateLimitingAttribute, method, route);
}
- private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribute? enableRateLimitingAttribute)
+ private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribute? enableRateLimitingAttribute, string? method, string? route)
{
- using var leaseContext = await TryAcquireAsync(context);
+ var policyName = enableRateLimitingAttribute?.PolicyName;
+
+ // Cache the up/down counter enabled state at the start of the middleware.
+ // This ensures that the state is consistent for the entire request.
+ // For example, if a meter listener starts after a request is queued, when the request exits the queue
+ // the requests queued counter won't go into a negative value.
+ var metricsContext = new MetricsContext(policyName, method, route,
+ _metrics.CurrentLeasedRequestsCounterEnabled, _metrics.CurrentQueuedRequestsCounterEnabled);
+
+ using var leaseContext = await TryAcquireAsync(context, metricsContext);
+
if (leaseContext.Lease?.IsAcquired == true)
{
- await _next(context);
+ var startTimestamp = Stopwatch.GetTimestamp();
+ var currentLeaseStart = _metrics.CurrentLeasedRequestsCounterEnabled;
+ try
+ {
+
+ _metrics.LeaseStart(metricsContext);
+ await _next(context);
+ }
+ finally
+ {
+ _metrics.LeaseEnd(metricsContext, startTimestamp, Stopwatch.GetTimestamp());
+ }
}
else
{
+ _metrics.LeaseFailed(metricsContext, leaseContext.RequestRejectionReason!.Value);
+
// If the request was canceled, do not call OnRejected, just return.
if (leaseContext.RequestRejectionReason == RequestRejectionReason.RequestCanceled)
{
@@ -107,7 +141,6 @@ private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribu
}
else
{
- var policyName = enableRateLimitingAttribute?.PolicyName;
if (policyName is not null && _policyMap.TryGetValue(policyName, out policy) && policy.OnRejected is not null)
{
thisRequestOnRejected = policy.OnRejected;
@@ -122,15 +155,32 @@ private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribu
}
}
- private ValueTask TryAcquireAsync(HttpContext context)
+ private async ValueTask TryAcquireAsync(HttpContext context, MetricsContext metricsContext)
{
var leaseContext = CombinedAcquire(context);
if (leaseContext.Lease?.IsAcquired == true)
{
- return ValueTask.FromResult(leaseContext);
+ return leaseContext;
+ }
+
+ var waitTask = CombinedWaitAsync(context, context.RequestAborted);
+ // If the task returns immediately then the request wasn't queued.
+ if (waitTask.IsCompleted)
+ {
+ return await waitTask;
}
- return CombinedWaitAsync(context, context.RequestAborted);
+ var startTimestamp = Stopwatch.GetTimestamp();
+ try
+ {
+ _metrics.QueueStart(metricsContext);
+ leaseContext = await waitTask;
+ return leaseContext;
+ }
+ finally
+ {
+ _metrics.QueueEnd(metricsContext, leaseContext.RequestRejectionReason, startTimestamp, Stopwatch.GetTimestamp());
+ }
}
private LeaseContext CombinedAcquire(HttpContext context)
@@ -253,4 +303,4 @@ private static partial class RateLimiterLog
[LoggerMessage(3, LogLevel.Debug, "The request was canceled.", EventName = "RequestCanceled")]
internal static partial void RequestCanceled(ILogger logger);
}
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/RateLimiting/src/Resources.resx b/src/Middleware/RateLimiting/src/Resources.resx
new file mode 100644
index 000000000000..f50493b9d4a9
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/Resources.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Unable to find the required services. Please add all the required services by calling '{0}.{1}' in the application startup code.
+
+
\ No newline at end of file
diff --git a/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
index 9712d186644a..22890a28eae9 100644
--- a/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
+++ b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
@@ -7,5 +7,8 @@
+
+
+
diff --git a/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
index 45afd2182a4d..55d4b0b3ab7b 100644
--- a/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
+++ b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
@@ -10,7 +10,6 @@ namespace Microsoft.AspNetCore.RateLimiting;
public class RateLimitingApplicationBuilderExtensionsTests : LoggedTest
{
-
[Fact]
public void UseRateLimiter_ThrowsOnNullAppBuilder()
{
@@ -24,6 +23,18 @@ public void UseRateLimiter_ThrowsOnNullOptions()
Assert.Throws(() => appBuilder.UseRateLimiter(null));
}
+ [Fact]
+ public void UseRateLimiter_RequireServices()
+ {
+ var services = new ServiceCollection();
+ var serviceProvider = services.BuildServiceProvider();
+ var appBuilder = new ApplicationBuilder(serviceProvider);
+
+ // Act
+ var ex = Assert.Throws(() => appBuilder.UseRateLimiter());
+ Assert.Equal("Unable to find the required services. Please add all the required services by calling 'IServiceCollection.AddRateLimiter' in the application startup code.", ex.Message);
+ }
+
[Fact]
public void UseRateLimiter_RespectsOptions()
{
@@ -34,7 +45,7 @@ public void UseRateLimiter_RespectsOptions()
// These should not get used
var services = new ServiceCollection();
- services.Configure(options =>
+ services.AddRateLimiter(options =>
{
options.GlobalLimiter = new TestPartitionedRateLimiter(new TestRateLimiter(false));
options.RejectionStatusCode = 404;
diff --git a/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs
new file mode 100644
index 000000000000..9f5f7388daf9
--- /dev/null
+++ b/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs
@@ -0,0 +1,347 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.Metrics;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Metrics;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+public class RateLimitingMetricsTests
+{
+ [Fact]
+ public async Task Metrics_Rejected()
+ {
+ // Arrange
+ var meterFactory = new TestMeterFactory();
+ var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+ var options = CreateOptionsAccessor();
+ options.Value.GlobalLimiter = new TestPartitionedRateLimiter(new TestRateLimiter(false));
+
+ var middleware = CreateTestRateLimitingMiddleware(options, meterFactory: meterFactory);
+ var meter = meterFactory.Meters.Single();
+
+ var context = new DefaultHttpContext();
+
+ using var leaseRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+ using var currentLeaseRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+ using var currentRequestsQueuedRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+ using var queuedRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+ using var leaseFailedRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+ // Act
+ await middleware.Invoke(context).DefaultTimeout();
+
+ // Assert
+ Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
+
+ Assert.Empty(currentLeaseRequestsRecorder.GetMeasurements());
+ Assert.Empty(leaseRequestDurationRecorder.GetMeasurements());
+ Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+ Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+ Assert.Collection(leaseFailedRequestsRecorder.GetMeasurements(),
+ m =>
+ {
+ Assert.Equal(1, m.Value);
+ Assert.Equal("GlobalLimiter", (string)m.Tags.ToArray().Single(t => t.Key == "reason").Value);
+ });
+ }
+
+ [Fact]
+ public async Task Metrics_Success()
+ {
+ // Arrange
+ var syncPoint = new SyncPoint();
+
+ var meterFactory = new TestMeterFactory();
+ var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+ var options = CreateOptionsAccessor();
+ options.Value.GlobalLimiter = new TestPartitionedRateLimiter(new TestRateLimiter(true));
+
+ var middleware = CreateTestRateLimitingMiddleware(
+ options,
+ meterFactory: meterFactory,
+ next: async c =>
+ {
+ await syncPoint.WaitToContinue();
+ });
+ var meter = meterFactory.Meters.Single();
+
+ var context = new DefaultHttpContext();
+ context.Request.Method = "GET";
+
+ using var leaseRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+ using var currentLeaseRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+ using var currentRequestsQueuedRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+ using var queuedRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+ using var leaseFailedRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+ // Act
+ var middlewareTask = middleware.Invoke(context);
+
+ await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+ Assert.Collection(currentLeaseRequestsRecorder.GetMeasurements(),
+ m => AssertCounter(m, 1, null, null, null));
+ Assert.Empty(leaseRequestDurationRecorder.GetMeasurements());
+
+ syncPoint.Continue();
+
+ await middlewareTask.DefaultTimeout();
+
+ // Assert
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+
+ Assert.Collection(currentLeaseRequestsRecorder.GetMeasurements(),
+ m => AssertCounter(m, 1, null, null, null),
+ m => AssertCounter(m, -1, null, null, null));
+ Assert.Collection(leaseRequestDurationRecorder.GetMeasurements(),
+ m => AssertDuration(m, null, null, null));
+ Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+ Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+ Assert.Empty(leaseFailedRequestsRecorder.GetMeasurements());
+ }
+
+ [Fact]
+ public async Task Metrics_ListenInMiddleOfRequest_CurrentLeasesNotDecreased()
+ {
+ // Arrange
+ var syncPoint = new SyncPoint();
+
+ var meterFactory = new TestMeterFactory();
+ var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+ var options = CreateOptionsAccessor();
+ options.Value.GlobalLimiter = new TestPartitionedRateLimiter(new TestRateLimiter(true));
+
+ var middleware = CreateTestRateLimitingMiddleware(
+ options,
+ meterFactory: meterFactory,
+ next: async c =>
+ {
+ await syncPoint.WaitToContinue();
+ });
+ var meter = meterFactory.Meters.Single();
+
+ var context = new DefaultHttpContext();
+ context.Request.Method = "GET";
+
+ // Act
+ var middlewareTask = middleware.Invoke(context);
+
+ await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+ using var leaseRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+ using var currentLeaseRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+ using var currentRequestsQueuedRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+ using var queuedRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+ using var leaseFailedRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+ syncPoint.Continue();
+
+ await middlewareTask.DefaultTimeout();
+
+ // Assert
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+
+ Assert.Empty(currentLeaseRequestsRecorder.GetMeasurements());
+ Assert.Collection(leaseRequestDurationRecorder.GetMeasurements(),
+ m => AssertDuration(m, null, null, null));
+ }
+
+ [Fact]
+ public async Task Metrics_Queued()
+ {
+ // Arrange
+ var syncPoint = new SyncPoint();
+
+ var meterFactory = new TestMeterFactory();
+ var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+ var services = new ServiceCollection();
+
+ services.AddRateLimiter(_ => _
+ .AddConcurrencyLimiter(policyName: "concurrencyPolicy", options =>
+ {
+ options.PermitLimit = 1;
+ options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ options.QueueLimit = 1;
+ }));
+ var serviceProvider = services.BuildServiceProvider();
+
+ var middleware = CreateTestRateLimitingMiddleware(
+ serviceProvider.GetRequiredService>(),
+ meterFactory: meterFactory,
+ next: async c =>
+ {
+ await syncPoint.WaitToContinue();
+ },
+ serviceProvider: serviceProvider);
+ var meter = meterFactory.Meters.Single();
+
+ var routeEndpointBuilder = new RouteEndpointBuilder(c => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
+ routeEndpointBuilder.Metadata.Add(new EnableRateLimitingAttribute("concurrencyPolicy"));
+ var endpoint = routeEndpointBuilder.Build();
+
+ using var leaseRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+ using var currentLeaseRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+ using var currentRequestsQueuedRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+ using var queuedRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+ using var leaseFailedRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+ // Act
+ var context1 = new DefaultHttpContext();
+ context1.Request.Method = "GET";
+ context1.SetEndpoint(endpoint);
+ var middlewareTask1 = middleware.Invoke(context1);
+
+ // Wait for first request to reach server and block it.
+ await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+ var context2 = new DefaultHttpContext();
+ context2.Request.Method = "GET";
+ context2.SetEndpoint(endpoint);
+ var middlewareTask2 = middleware.Invoke(context1);
+
+ // Assert second request is queued.
+ Assert.Collection(currentRequestsQueuedRecorder.GetMeasurements(),
+ m => AssertCounter(m, 1, "GET", "/", "concurrencyPolicy"));
+ Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+
+ // Allow both requests to finish.
+ syncPoint.Continue();
+
+ await middlewareTask1.DefaultTimeout();
+ await middlewareTask2.DefaultTimeout();
+
+ Assert.Collection(currentRequestsQueuedRecorder.GetMeasurements(),
+ m => AssertCounter(m, 1, "GET", "/", "concurrencyPolicy"),
+ m => AssertCounter(m, -1, "GET", "/", "concurrencyPolicy"));
+ Assert.Collection(queuedRequestDurationRecorder.GetMeasurements(),
+ m => AssertDuration(m, "GET", "/", "concurrencyPolicy"));
+ }
+
+ [Fact]
+ public async Task Metrics_ListenInMiddleOfQueued_CurrentQueueNotDecreased()
+ {
+ // Arrange
+ var syncPoint = new SyncPoint();
+
+ var meterFactory = new TestMeterFactory();
+ var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+ var services = new ServiceCollection();
+
+ services.AddRateLimiter(_ => _
+ .AddConcurrencyLimiter(policyName: "concurrencyPolicy", options =>
+ {
+ options.PermitLimit = 1;
+ options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ options.QueueLimit = 1;
+ }));
+ var serviceProvider = services.BuildServiceProvider();
+
+ var middleware = CreateTestRateLimitingMiddleware(
+ serviceProvider.GetRequiredService>(),
+ meterFactory: meterFactory,
+ next: async c =>
+ {
+ await syncPoint.WaitToContinue();
+ },
+ serviceProvider: serviceProvider);
+ var meter = meterFactory.Meters.Single();
+
+ var routeEndpointBuilder = new RouteEndpointBuilder(c => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
+ routeEndpointBuilder.Metadata.Add(new EnableRateLimitingAttribute("concurrencyPolicy"));
+ var endpoint = routeEndpointBuilder.Build();
+
+ // Act
+ var context1 = new DefaultHttpContext();
+ context1.Request.Method = "GET";
+ context1.SetEndpoint(endpoint);
+ var middlewareTask1 = middleware.Invoke(context1);
+
+ // Wait for first request to reach server and block it.
+ await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+ var context2 = new DefaultHttpContext();
+ context2.Request.Method = "GET";
+ context2.SetEndpoint(endpoint);
+ var middlewareTask2 = middleware.Invoke(context1);
+
+ // Start listening while the second request is queued.
+
+ using var leaseRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+ using var currentLeaseRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+ using var currentRequestsQueuedRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+ using var queuedRequestDurationRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+ using var leaseFailedRequestsRecorder = new InstrumentRecorder(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+ Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+ Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+
+ // Allow both requests to finish.
+ syncPoint.Continue();
+
+ await middlewareTask1.DefaultTimeout();
+ await middlewareTask2.DefaultTimeout();
+
+ Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+ Assert.Collection(queuedRequestDurationRecorder.GetMeasurements(),
+ m => AssertDuration(m, "GET", "/", "concurrencyPolicy"));
+ }
+
+ private static void AssertCounter(Measurement measurement, long value, string method, string route, string policy)
+ {
+ Assert.Equal(value, measurement.Value);
+ AssertTag(measurement.Tags, "method", method);
+ AssertTag(measurement.Tags, "route", route);
+ AssertTag(measurement.Tags, "policy", policy);
+ }
+
+ private static void AssertDuration(Measurement measurement, string method, string route, string policy)
+ {
+ Assert.True(measurement.Value > 0);
+ AssertTag(measurement.Tags, "method", method);
+ AssertTag(measurement.Tags, "route", route);
+ AssertTag(measurement.Tags, "policy", policy);
+ }
+
+ private static void AssertTag(ReadOnlySpan> tags, string tagName, T expected)
+ {
+ if (expected == null)
+ {
+ Assert.DoesNotContain(tags.ToArray(), t => t.Key == tagName);
+ }
+ else
+ {
+ Assert.Equal(expected, (T)tags.ToArray().Single(t => t.Key == tagName).Value);
+ }
+ }
+
+ private RateLimitingMiddleware CreateTestRateLimitingMiddleware(IOptions options, ILogger logger = null, IServiceProvider serviceProvider = null, IMeterFactory meterFactory = null, RequestDelegate next = null)
+ {
+ next ??= c => Task.CompletedTask;
+ return new RateLimitingMiddleware(
+ next,
+ logger ?? new NullLoggerFactory().CreateLogger(),
+ options,
+ serviceProvider ?? Mock.Of(),
+ new RateLimitingMetrics(meterFactory ?? new TestMeterFactory()));
+ }
+
+ private IOptions CreateOptionsAccessor() => Options.Create(new RateLimiterOptions());
+}
diff --git a/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
index 24c6ccd93990..b64c7a258195 100644
--- a/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
+++ b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
+using Microsoft.Extensions.Metrics;
using Microsoft.Extensions.Options;
using Moq;
@@ -25,7 +26,8 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
null,
new NullLoggerFactory().CreateLogger(),
options,
- Mock.Of()));
+ Mock.Of(),
+ new RateLimitingMetrics(new TestMeterFactory())));
Assert.Throws(() => new RateLimitingMiddleware(c =>
{
@@ -33,7 +35,8 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
},
null,
options,
- Mock.Of()));
+ Mock.Of(),
+ new RateLimitingMetrics(new TestMeterFactory())));
Assert.Throws(() => new RateLimitingMiddleware(c =>
{
@@ -41,6 +44,16 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
},
new NullLoggerFactory().CreateLogger(),
options,
+ null,
+ new RateLimitingMetrics(new TestMeterFactory())));
+
+ Assert.Throws(() => new RateLimitingMiddleware(c =>
+ {
+ return Task.CompletedTask;
+ },
+ new NullLoggerFactory().CreateLogger(),
+ options,
+ Mock.Of(),
null));
}
@@ -59,7 +72,8 @@ public async Task RequestsCallNextIfAccepted()
},
new NullLoggerFactory().CreateLogger(),
options,
- Mock.Of());
+ Mock.Of(),
+ new RateLimitingMetrics(new TestMeterFactory()));
// Act
await middleware.Invoke(new DefaultHttpContext());
@@ -637,7 +651,8 @@ private RateLimitingMiddleware CreateTestRateLimitingMiddleware(IOptions(),
options,
- serviceProvider ?? Mock.Of());
+ serviceProvider ?? Mock.Of(),
+ new RateLimitingMetrics(new TestMeterFactory()));
private IOptions CreateOptionsAccessor() => Options.Create(new RateLimiterOptions());
}
diff --git a/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
index fa19f79779b7..f84dc427e692 100644
--- a/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
+++ b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
@@ -12,18 +12,18 @@ namespace Microsoft.AspNetCore.RateLimiting;
internal class TestPartitionedRateLimiter : PartitionedRateLimiter
{
- private List limiters = new List();
+ private readonly List _limiters = new List();
public TestPartitionedRateLimiter() { }
public TestPartitionedRateLimiter(RateLimiter limiter)
{
- limiters.Add(limiter);
+ _limiters.Add(limiter);
}
public void AddLimiter(RateLimiter limiter)
{
- limiters.Add(limiter);
+ _limiters.Add(limiter);
}
public override RateLimiterStatistics GetStatistics(TResource resourceID)
@@ -36,9 +36,9 @@ protected override RateLimitLease AttemptAcquireCore(TResource resourceID, int p
if (permitCount != 1)
{
throw new ArgumentException("Tests only support 1 permit at a time");
- }
+ }
var leases = new List();
- foreach (var limiter in limiters)
+ foreach (var limiter in _limiters)
{
var lease = limiter.AttemptAcquire();
if (lease.IsAcquired)
@@ -64,7 +64,7 @@ protected override async ValueTask AcquireAsyncCore(TResource re
throw new ArgumentException("Tests only support 1 permit at a time");
}
var leases = new List();
- foreach (var limiter in limiters)
+ foreach (var limiter in _limiters)
{
leases.Add(await limiter.AcquireAsync());
}
@@ -77,9 +77,8 @@ protected override async ValueTask AcquireAsyncCore(TResource re
unusedLease.Dispose();
}
return new TestRateLimitLease(false, null);
- }
+ }
}
return new TestRateLimitLease(true, leases);
-
}
}
diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
index f41aa21dc843..e4b328c02a61 100644
--- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
+++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
@@ -20,8 +20,6 @@
-
-
diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
index 0109d41a2d42..0edf0ee3ea5d 100644
--- a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
+++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
@@ -115,6 +115,7 @@ public async Task GET_RequestReturnsLargeData_GracefulShutdownDuringRequest_Requ
private static async Task<(byte[], HttpResponseHeaders)> StartLongRunningRequestAsync(ILogger logger, IHost host, HttpMessageInvoker client)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{host.GetPort()}/");
+ request.Headers.Host = "localhost2";
request.Version = HttpVersion.Version20;
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;