diff --git a/src/Microsoft.AspNetCore.Server.HttpSys/Http503VerbosityLevel .cs b/src/Microsoft.AspNetCore.Server.HttpSys/Http503VerbosityLevel .cs new file mode 100644 index 0000000..09da208 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.HttpSys/Http503VerbosityLevel .cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Server.HttpSys +{ + /// + /// Enum declaring the allowed values for the verbosity level when http.sys reject requests due to throttling. + /// + public enum Http503VerbosityLevel : long + { + /// + /// A 503 response is not sent; the connection is reset. This is the default HTTP Server API behavior. + /// + Basic = 0, + + /// + /// The HTTP Server API sends a 503 response with a "Service Unavailable" reason phrase. + /// + Limited = 1, + + /// + /// The HTTP Server API sends a 503 response with a detailed reason phrase. + /// + Full = 2 + } +} diff --git a/src/Microsoft.AspNetCore.Server.HttpSys/HttpSysOptions.cs b/src/Microsoft.AspNetCore.Server.HttpSys/HttpSysOptions.cs index cf34e6e..93d6e2d 100644 --- a/src/Microsoft.AspNetCore.Server.HttpSys/HttpSysOptions.cs +++ b/src/Microsoft.AspNetCore.Server.HttpSys/HttpSysOptions.cs @@ -2,18 +2,21 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; using Microsoft.AspNetCore.Http.Features; namespace Microsoft.AspNetCore.Server.HttpSys { public class HttpSysOptions { + private const Http503VerbosityLevel DefaultRejectionVerbosityLevel = Http503VerbosityLevel.Basic; // Http.sys default. private const long DefaultRequestQueueLength = 1000; // Http.sys default. internal static readonly int DefaultMaxAccepts = 5 * Environment.ProcessorCount; // Matches the default maxAllowedContentLength in IIS (~28.6 MB) // https://www.iis.net/configreference/system.webserver/security/requestfiltering/requestlimits#005 private const long DefaultMaxRequestBodySize = 30000000; + private Http503VerbosityLevel _rejectionVebosityLevel = DefaultRejectionVerbosityLevel; // The native request queue private long _requestQueueLength = DefaultRequestQueueLength; private long? _maxConnections; @@ -137,6 +140,38 @@ namespace Microsoft.AspNetCore.Server.HttpSys /// public bool AllowSynchronousIO { get; set; } = true; + /// + /// Gets or sets a value that controls how http.sys reacts when rejecting requests due to throttling conditions - like when the request + /// queue limit is reached. The default in http.sys is "Basic" which means http.sys is just resetting the TCP connection. IIS uses Limited + /// as its default behavior which will result in sending back a 503 - Service Unavailable back to the client. + /// + public Http503VerbosityLevel Http503Verbosity + { + get + { + return _rejectionVebosityLevel; + } + set + { + if (value < Http503VerbosityLevel.Basic || value > Http503VerbosityLevel.Full) + { + string message = String.Format( + CultureInfo.InvariantCulture, + "The value must be one of the values defined in the '{0}' enum.", + typeof(Http503VerbosityLevel).Name); + + throw new ArgumentOutOfRangeException(nameof(value), value, message); + } + + if (_requestQueue != null) + { + _requestQueue.SetRejectionVerbosity(value); + } + // Only store it if it succeeds or hasn't started yet + _rejectionVebosityLevel = value; + } + } + internal void Apply(UrlGroup urlGroup, RequestQueue requestQueue) { _urlGroup = urlGroup; @@ -152,6 +187,11 @@ namespace Microsoft.AspNetCore.Server.HttpSys _requestQueue.SetLengthLimit(_requestQueueLength); } + if (_rejectionVebosityLevel != DefaultRejectionVerbosityLevel) + { + _requestQueue.SetRejectionVerbosity(_rejectionVebosityLevel); + } + Authentication.SetUrlGroupSecurity(urlGroup); Timeouts.SetUrlGroupTimeouts(urlGroup); } diff --git a/src/Microsoft.AspNetCore.Server.HttpSys/NativeInterop/RequestQueue.cs b/src/Microsoft.AspNetCore.Server.HttpSys/NativeInterop/RequestQueue.cs index 29f43b6..1d3546f 100644 --- a/src/Microsoft.AspNetCore.Server.HttpSys/NativeInterop/RequestQueue.cs +++ b/src/Microsoft.AspNetCore.Server.HttpSys/NativeInterop/RequestQueue.cs @@ -99,6 +99,21 @@ namespace Microsoft.AspNetCore.Server.HttpSys } } + // The listener must be active for this to work. + internal unsafe void SetRejectionVerbosity(Http503VerbosityLevel verbosity) + { + CheckDisposed(); + + var result = HttpApi.HttpSetRequestQueueProperty(Handle, + HttpApiTypes.HTTP_SERVER_PROPERTY.HttpServer503VerbosityProperty, + new IntPtr((void*)&verbosity), (uint)Marshal.SizeOf(), 0, IntPtr.Zero); + + if (result != 0) + { + throw new HttpSysException((int)result); + } + } + public void Dispose() { if (_disposed) diff --git a/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/Listener/ServerTests.cs b/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/Listener/ServerTests.cs index c15d47f..e70e3de 100644 --- a/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/Listener/ServerTests.cs +++ b/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/Listener/ServerTests.cs @@ -245,6 +245,22 @@ namespace Microsoft.AspNetCore.Server.HttpSys.Listener } } + [ConditionalFact] + public async Task Server_SetRejectionVerbosityLevel_Success() + { + using (var server = Utilities.CreateHttpServer(out string address)) + { + server.Options.Http503Verbosity = Http503VerbosityLevel.Limited; + var responseTask = SendRequestAsync(address); + + var context = await server.AcceptAsync(Utilities.DefaultTimeout); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(string.Empty, response); + } + } + [ConditionalFact] public async Task Server_HotAddPrefix_Success() { diff --git a/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/ServerTests.cs b/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/ServerTests.cs index 4a451bd..e0ecbc7 100644 --- a/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/ServerTests.cs +++ b/test/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests/ServerTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text; @@ -298,6 +299,44 @@ namespace Microsoft.AspNetCore.Server.HttpSys } } + [ConditionalFact] + public async Task Server_SetHttp503VebosittHittingThrottle_Success() + { + // This is just to get a dynamic port + string address; + using (Utilities.CreateHttpServer(out address, httpContext => Task.FromResult(0))) { } + + var server = Utilities.CreatePump(); + server.Listener.Options.UrlPrefixes.Add(UrlPrefix.Create(address)); + Assert.Null(server.Listener.Options.MaxConnections); + server.Listener.Options.MaxConnections = 3; + server.Listener.Options.Http503Verbosity = Http503VerbosityLevel.Limited; + + using (server) + { + await server.StartAsync(new DummyApplication(), CancellationToken.None); + + using (var client1 = await SendHungRequestAsync("GET", address)) + using (var client2 = await SendHungRequestAsync("GET", address)) + { + using (var client3 = await SendHungRequestAsync("GET", address)) + { + using (HttpClient client4 = new HttpClient()) + { + // Maxed out, refuses connection should return 503 + HttpResponseMessage response = await client4.GetAsync(address); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + } + + // A connection has been closed, try again. + string responseText = await SendRequestAsync(address); + Assert.Equal(string.Empty, responseText); + } + } + } + [ConditionalFact] public void Server_SetConnectionLimitArgumentValidation_Success() {