diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 9e3db571e68f..9059d7fdf113 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +const Microsoft.AspNetCore.Http.StatusCodes.Status499ClientClosedRequest = 499 -> int *REMOVED*Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.DefaultEndpointFilterInvocationContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! arguments) -> void Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.DefaultEndpointFilterInvocationContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object?[]! arguments) -> void Microsoft.AspNetCore.Http.HttpResults.EmptyHttpResult diff --git a/src/Http/Http.Abstractions/src/StatusCodes.cs b/src/Http/Http.Abstractions/src/StatusCodes.cs index 32badfe8be76..97b452a2a9ed 100644 --- a/src/Http/Http.Abstractions/src/StatusCodes.cs +++ b/src/Http/Http.Abstractions/src/StatusCodes.cs @@ -283,6 +283,12 @@ public static class StatusCodes /// public const int Status451UnavailableForLegalReasons = 451; + /// + /// HTTP status code 499. This is an unofficial status code originally defined by Nginx and is commonly used + /// in logs when the client has disconnected. + /// + public const int Status499ClientClosedRequest = 499; + /// /// HTTP status code 500. /// diff --git a/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs b/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs index 8547a5926d9f..a70fec428d17 100644 --- a/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs +++ b/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs @@ -466,7 +466,7 @@ await writer.WriteAsync(new ProblemDetailsContext() [Theory] [InlineData(StatusCodes.Status400BadRequest, "Bad Request", "https://tools.ietf.org/html/rfc9110#section-15.5.1")] [InlineData(StatusCodes.Status418ImATeapot, "I'm a teapot", null)] - [InlineData(499, null, null)] + [InlineData(498, null, null)] public async Task WriteAsync_UsesStatusCode_FromProblemDetails_WhenSpecified( int statusCode, string title, diff --git a/src/Http/Http.Results/test/ResultsTests.cs b/src/Http/Http.Results/test/ResultsTests.cs index 2a7d13267ccd..c9abea1a6803 100644 --- a/src/Http/Http.Results/test/ResultsTests.cs +++ b/src/Http/Http.Results/test/ResultsTests.cs @@ -1076,7 +1076,7 @@ public void Problem_WithArgs_ResultHasCorrectValues() [Theory] [InlineData(StatusCodes.Status400BadRequest, "Bad Request", "https://tools.ietf.org/html/rfc9110#section-15.5.1")] [InlineData(StatusCodes.Status418ImATeapot, "I'm a teapot", null)] - [InlineData(499, null, null)] + [InlineData(498, null, null)] public void Problem_WithOnlyHttpStatus_ResultHasCorrectValues( int statusCode, string title, @@ -1084,7 +1084,6 @@ public void Problem_WithOnlyHttpStatus_ResultHasCorrectValues( { // Act var result = Results.Problem(statusCode: statusCode) as ProblemHttpResult; - // Assert Assert.Null(result.ProblemDetails.Detail); Assert.Null(result.ProblemDetails.Instance); diff --git a/src/Http/WebUtilities/src/ReasonPhrases.cs b/src/Http/WebUtilities/src/ReasonPhrases.cs index a9966c76bd9d..14318b457871 100644 --- a/src/Http/WebUtilities/src/ReasonPhrases.cs +++ b/src/Http/WebUtilities/src/ReasonPhrases.cs @@ -66,6 +66,7 @@ public static class ReasonPhrases { 429, "Too Many Requests" }, { 431, "Request Header Fields Too Large" }, { 451, "Unavailable For Legal Reasons" }, + { 499, "Client Closed Request" }, { 500, "Internal Server Error" }, { 501, "Not Implemented" }, diff --git a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs index bb34c6d64597..4869e667122d 100644 --- a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs +++ b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs @@ -101,6 +101,18 @@ public async Task Invoke(HttpContext context) } catch (Exception ex) { + if ((ex is OperationCanceledException || ex is IOException) && context.RequestAborted.IsCancellationRequested) + { + _logger.RequestAbortedException(); + + if (!context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status499ClientClosedRequest; + } + + return; + } + _logger.UnhandledException(ex); if (context.Response.HasStarted) diff --git a/src/Middleware/Diagnostics/src/DiagnosticsLoggerExtensions.cs b/src/Middleware/Diagnostics/src/DiagnosticsLoggerExtensions.cs index ae02ae992c90..af14fe7b01cc 100644 --- a/src/Middleware/Diagnostics/src/DiagnosticsLoggerExtensions.cs +++ b/src/Middleware/Diagnostics/src/DiagnosticsLoggerExtensions.cs @@ -11,6 +11,9 @@ internal static partial class DiagnosticsLoggerExtensions [LoggerMessage(1, LogLevel.Error, "An unhandled exception has occurred while executing the request.", EventName = "UnhandledException")] public static partial void UnhandledException(this ILogger logger, Exception exception); + [LoggerMessage(4, LogLevel.Debug, "The request was aborted by the client.", EventName = "RequestAborted")] + public static partial void RequestAbortedException(this ILogger logger); + // ExceptionHandlerMiddleware [LoggerMessage(2, LogLevel.Warning, "The response has already started, the error handler will not be executed.", EventName = "ResponseStarted")] public static partial void ResponseStartedErrorHandler(this ILogger logger); diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs index c6413f9f5c4c..65d6e7028dbb 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs @@ -112,6 +112,18 @@ static async Task Awaited(ExceptionHandlerMiddlewareImpl middleware, HttpContext private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi) { + if ((edi.SourceException is OperationCanceledException || edi.SourceException is IOException) && context.RequestAborted.IsCancellationRequested) + { + _logger.RequestAbortedException(); + + if (!context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status499ClientClosedRequest; + } + + return; + } + _logger.UnhandledException(edi.SourceException); // We can't do anything if the response has already started, just abort. if (context.Response.HasStarted) diff --git a/src/Servers/HttpSys/src/LoggerEventIds.cs b/src/Servers/HttpSys/src/LoggerEventIds.cs index f803811a7970..d550618eab1e 100644 --- a/src/Servers/HttpSys/src/LoggerEventIds.cs +++ b/src/Servers/HttpSys/src/LoggerEventIds.cs @@ -53,4 +53,5 @@ internal static class LoggerEventIds public const int ListenerDisposing = 46; public const int RequestValidationFailed = 47; public const int CreateDisconnectTokenError = 48; + public const int RequestAborted = 49; } diff --git a/src/Servers/HttpSys/src/RequestProcessing/RequestContextLog.cs b/src/Servers/HttpSys/src/RequestProcessing/RequestContextLog.cs index 453be47afc4c..d9e72b6f4e26 100644 --- a/src/Servers/HttpSys/src/RequestProcessing/RequestContextLog.cs +++ b/src/Servers/HttpSys/src/RequestProcessing/RequestContextLog.cs @@ -15,4 +15,7 @@ internal static partial class RequestContextLog [LoggerMessage(LoggerEventIds.RequestsDrained, LogLevel.Information, "All requests drained.", EventName = "RequestsDrained")] public static partial void RequestsDrained(ILogger logger); + + [LoggerMessage(LoggerEventIds.RequestAborted, LogLevel.Debug, "The request was aborted by the client.", EventName = "RequestAborted")] + public static partial void RequestAborted(ILogger logger); } diff --git a/src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs b/src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs index 6d7eecab8204..f661bf1fd018 100644 --- a/src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs +++ b/src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs @@ -56,7 +56,14 @@ public override async Task ExecuteAsync() { applicationException = ex; - Log.RequestProcessError(Logger, ex); + if ((ex is OperationCanceledException || ex is IOException) && DisconnectToken.IsCancellationRequested) + { + Log.RequestAborted(Logger); + } + else + { + Log.RequestProcessError(Logger, ex); + } if (Response.HasStarted) { // Otherwise the default is Cancel = 0x8 (h2) or 0x010c (h3). @@ -84,6 +91,10 @@ public override async Task ExecuteAsync() { SetFatalResponse(badHttpRequestException.StatusCode); } + else if ((ex is OperationCanceledException || ex is IOException) && DisconnectToken.IsCancellationRequested) + { + SetFatalResponse(StatusCodes.Status499ClientClosedRequest); + } else { SetFatalResponse(StatusCodes.Status500InternalServerError); diff --git a/src/Servers/IIS/IIS/src/Core/IISHttpContext.Log.cs b/src/Servers/IIS/IIS/src/Core/IISHttpContext.Log.cs index ac684b30b2e4..74c687c6f52d 100644 --- a/src/Servers/IIS/IIS/src/Core/IISHttpContext.Log.cs +++ b/src/Servers/IIS/IIS/src/Core/IISHttpContext.Log.cs @@ -24,5 +24,8 @@ public static void ConnectionBadRequest(ILogger logger, string connectionId, Mic [LoggerMessage(4, LogLevel.Debug, @"Connection id ""{ConnectionId}"" bad request data: ""{message}""", EventName = nameof(ConnectionBadRequest))] private static partial void ConnectionBadRequest(ILogger logger, string connectionId, string message, Microsoft.AspNetCore.Http.BadHttpRequestException ex); + + [LoggerMessage(5, LogLevel.Debug, @"Connection ID ""{ConnectionId}"", Request ID ""{TraceIdentifier}"": The request was aborted by the client.", EventName = "RequestAborted")] + public static partial void RequestAborted(ILogger logger, string connectionId, string traceIdentifier); } } diff --git a/src/Servers/IIS/IIS/src/Core/IISHttpContext.cs b/src/Servers/IIS/IIS/src/Core/IISHttpContext.cs index df3417c5171c..6fe706a6973c 100644 --- a/src/Servers/IIS/IIS/src/Core/IISHttpContext.cs +++ b/src/Servers/IIS/IIS/src/Core/IISHttpContext.cs @@ -719,6 +719,11 @@ protected void ReportApplicationError(Exception ex) Log.ApplicationError(_logger, ((IHttpConnectionFeature)this).ConnectionId, ((IHttpRequestIdentifierFeature)this).TraceIdentifier, ex); } + protected void ReportRequestAborted() + { + Log.RequestAborted(_logger, ((IHttpConnectionFeature)this).ConnectionId, ((IHttpRequestIdentifierFeature)this).TraceIdentifier); + } + public void PostCompletion(NativeMethods.REQUEST_NOTIFICATION_STATUS requestNotificationStatus) { NativeMethods.HttpSetCompletionStatus(_requestNativeHandle, requestNotificationStatus); diff --git a/src/Servers/IIS/IIS/src/Core/IISHttpContextOfT.cs b/src/Servers/IIS/IIS/src/Core/IISHttpContextOfT.cs index d282b11c15ca..1bc6fd879044 100644 --- a/src/Servers/IIS/IIS/src/Core/IISHttpContextOfT.cs +++ b/src/Servers/IIS/IIS/src/Core/IISHttpContextOfT.cs @@ -4,6 +4,7 @@ using System.Buffers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.IIS.Core; @@ -43,7 +44,15 @@ public override async Task ProcessRequestAsync() } catch (Exception ex) { - ReportApplicationError(ex); + if ((ex is OperationCanceledException || ex is IOException) && ClientDisconnected) + { + ReportRequestAborted(); + } + else + { + ReportApplicationError(ex); + } + success = false; } @@ -82,9 +91,8 @@ public override async Task ProcessRequestAsync() } else if (!HasResponseStarted && _requestRejectedException == null) { - // If the request was aborted and no response was sent, there's no - // meaningful status code to log. - StatusCode = 0; + // If the request was aborted and no response was sent, we use status code 499 for logging + StatusCode = ClientDisconnected ? StatusCodes.Status499ClientClosedRequest : 0; success = false; } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs index 1ae6112104d9..f2f1c8109fd5 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs @@ -698,7 +698,14 @@ private async Task ProcessRequests(IHttpApplication applicat } catch (Exception ex) { - ReportApplicationError(ex); + if ((ex is OperationCanceledException || ex is IOException) && _connectionAborted) + { + Log.RequestAborted(ConnectionId, TraceIdentifier); + } + else + { + ReportApplicationError(ex); + } } KestrelEventSource.Log.RequestStop(this); @@ -739,9 +746,8 @@ private async Task ProcessRequests(IHttpApplication applicat } else if (!HasResponseStarted) { - // If the request was aborted and no response was sent, there's no - // meaningful status code to log. - StatusCode = 0; + // If the request was aborted and no response was sent, we use status code 499 for logging + StatusCode = StatusCodes.Status499ClientClosedRequest; } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/ReasonPhrases.cs b/src/Servers/Kestrel/Core/src/Internal/Http/ReasonPhrases.cs index c461770c198a..e2a5c82e66fc 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/ReasonPhrases.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/ReasonPhrases.cs @@ -64,6 +64,7 @@ internal static class ReasonPhrases private static readonly byte[] _bytesStatus429 = CreateStatusBytes(StatusCodes.Status429TooManyRequests); private static readonly byte[] _bytesStatus431 = CreateStatusBytes(StatusCodes.Status431RequestHeaderFieldsTooLarge); private static readonly byte[] _bytesStatus451 = CreateStatusBytes(StatusCodes.Status451UnavailableForLegalReasons); + private static readonly byte[] _bytesStatus499 = CreateStatusBytes(StatusCodes.Status499ClientClosedRequest); private static readonly byte[] _bytesStatus500 = CreateStatusBytes(StatusCodes.Status500InternalServerError); private static readonly byte[] _bytesStatus501 = CreateStatusBytes(StatusCodes.Status501NotImplemented); @@ -149,6 +150,7 @@ public static byte[] ToStatusBytes(int statusCode, string? reasonPhrase = null) StatusCodes.Status429TooManyRequests => _bytesStatus429, StatusCodes.Status431RequestHeaderFieldsTooLarge => _bytesStatus431, StatusCodes.Status451UnavailableForLegalReasons => _bytesStatus451, + StatusCodes.Status499ClientClosedRequest => _bytesStatus499, StatusCodes.Status500InternalServerError => _bytesStatus500, StatusCodes.Status501NotImplemented => _bytesStatus501, diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelTrace.General.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelTrace.General.cs index 8ec255283718..689459c9ec99 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelTrace.General.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelTrace.General.cs @@ -64,6 +64,11 @@ public void Http3DisabledWithHttp1AndNoTls(EndPoint endPoint) GeneralLog.Http3DisabledWithHttp1AndNoTls(_generalLogger, endPoint); } + public void RequestAborted(string connectionId, string traceIdentifier) + { + GeneralLog.RequestAbortedException(_generalLogger, connectionId, traceIdentifier); + } + private static partial class GeneralLog { [LoggerMessage(13, LogLevel.Error, @"Connection id ""{ConnectionId}"", Request id ""{TraceIdentifier}"": An unhandled exception was thrown by the application.", EventName = "ApplicationError")] @@ -99,6 +104,9 @@ private static partial class GeneralLog [LoggerMessage(65, LogLevel.Warning, "HTTP/3 is not enabled for {Endpoint}. HTTP/3 requires TLS. Connections to this endpoint will use HTTP/1.1.", EventName = "Http3DisabledWithHttp1AndNoTls")] public static partial void Http3DisabledWithHttp1AndNoTls(ILogger logger, EndPoint endPoint); - // Highest shared ID is 65. New consecutive IDs start at 66 + [LoggerMessage(66, LogLevel.Debug, @"Connection id ""{ConnectionId}"", Request id ""{TraceIdentifier}"": The request was aborted by the client.", EventName = "RequestAborted")] + public static partial void RequestAbortedException(ILogger logger, string connectionId, string traceIdentifier); + + // Highest shared ID is 66. New consecutive IDs start at 67 } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs index 91325faa9c3a..27f51aed3faa 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs @@ -294,7 +294,7 @@ public Task ResponseStatusCodeSetBeforeHttpContextDisposeRequestAborted() return Task.CompletedTask; }, expectedClientStatusCode: null, - expectedServerStatusCode: 0); + expectedServerStatusCode: (HttpStatusCode)499); } [Fact] @@ -309,7 +309,7 @@ public Task ResponseStatusCodeSetBeforeHttpContextDisposeRequestAbortedAppExcept throw new Exception(); }, expectedClientStatusCode: null, - expectedServerStatusCode: 0); + expectedServerStatusCode: (HttpStatusCode)499); } [Fact]