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;