Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Source/Boxed.AspNetCore/ApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,35 @@ public static IApplicationBuilder UseHttpException(
return application.UseMiddleware<HttpExceptionMiddleware>(options);
}

/// <summary>
/// Handles <see cref="OperationCanceledException"/> caused by the HTTP request being aborted, then shortcuts and
/// returns an error status code.
/// See https://andrewlock.net/using-cancellationtokens-in-asp-net-core-minimal-apis/.
/// </summary>
/// <param name="application">The application builder.</param>
/// <returns>The same application builder.</returns>
public static IApplicationBuilder UseRequestCanceled(this IApplicationBuilder application) =>
UseRequestCanceled(application, null);

/// <summary>
/// Handles <see cref="OperationCanceledException"/> caused by the HTTP request being aborted, then shortcuts and
/// returns an error status code.
/// See https://andrewlock.net/using-cancellationtokens-in-asp-net-core-minimal-apis/.
/// </summary>
/// <param name="application">The application builder.</param>
/// <param name="configureOptions">The middleware options.</param>
/// <returns>The same application builder.</returns>
public static IApplicationBuilder UseRequestCanceled(
this IApplicationBuilder application,
Action<RequestCanceledMiddlewareOptions>? configureOptions)
{
ArgumentNullException.ThrowIfNull(application);

var options = new RequestCanceledMiddlewareOptions();
configureOptions?.Invoke(options);
return application.UseMiddleware<RequestCanceledMiddleware>(options);
}

/// <summary>
/// Measures the time the request takes to process and returns this in a Server-Timing trailing HTTP header.
/// It is used to surface any back-end server timing metrics (e.g. database read/write, CPU time, file system
Expand Down
6 changes: 6 additions & 0 deletions Source/Boxed.AspNetCore/LoggerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ internal static partial class LoggerExtensions
Level = LogLevel.Information,
Message = "Executing HttpExceptionMiddleware, setting HTTP status code {StatusCode}.")]
public static partial void SettingHttpStatusCode(this ILogger logger, Exception exception, int statusCode);

[LoggerMessage(
EventId = 4001,
Level = LogLevel.Information,
Message = "Request was canceled.")]
public static partial void RequestCanceled(this ILogger logger);
}
9 changes: 2 additions & 7 deletions Source/Boxed.AspNetCore/Middleware/HttpExceptionMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,14 @@ namespace Boxed.AspNetCore.Middleware;
/// <seealso cref="IMiddleware" />
public class HttpExceptionMiddleware : IMiddleware
{
private readonly RequestDelegate next;
private readonly HttpExceptionMiddlewareOptions options;

/// <summary>
/// Initializes a new instance of the <see cref="HttpExceptionMiddleware"/> class.
/// </summary>
/// <param name="next">The next.</param>
/// <param name="options">The options.</param>
public HttpExceptionMiddleware(RequestDelegate next, HttpExceptionMiddlewareOptions options)
{
this.next = next;
public HttpExceptionMiddleware(HttpExceptionMiddlewareOptions options) =>
this.options = options;
}

/// <inheritdoc/>
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
Expand All @@ -35,7 +30,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)

try
{
await this.next.Invoke(context).ConfigureAwait(false);
await next.Invoke(context).ConfigureAwait(false);
}
catch (HttpException httpException)
{
Expand Down
48 changes: 48 additions & 0 deletions Source/Boxed.AspNetCore/Middleware/RequestCanceledMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace Boxed.AspNetCore.Middleware;

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

/// <summary>
/// A middleware which handles <see cref="OperationCanceledException"/> caused by the HTTP request being aborted, then
/// shortcuts and returns an error status code.
/// </summary>
/// <seealso cref="IMiddleware" />
public class RequestCanceledMiddleware : IMiddleware
{
private readonly ILogger<RequestCanceledMiddleware> logger;
private readonly RequestCanceledMiddlewareOptions options;

/// <summary>
/// Initializes a new instance of the <see cref="RequestCanceledMiddleware"/> class.
/// </summary>
/// <param name="options">The middleware options.</param>
/// <param name="logger">A logger.</param>
public RequestCanceledMiddleware(
RequestCanceledMiddlewareOptions options,
ILogger<RequestCanceledMiddleware> logger)
{
this.options = options;
this.logger = logger;
}

/// <inheritdoc/>
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);

try
{
await next(context).ConfigureAwait(false);
}
catch (OperationCanceledException operationCanceledException)
when (operationCanceledException.CancellationToken == context.RequestAborted)
{
this.logger.RequestCanceled();
context.Response.StatusCode = this.options.StatusCode;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Boxed.AspNetCore.Middleware;

/// <summary>
/// Options controlling <see cref="RequestCanceledMiddleware"/>.
/// </summary>
public class RequestCanceledMiddlewareOptions
{
/// <summary>
/// The non-standard 499 status code 'Client Closed Request' used by NGINX to signify an aborted/cancelled request.
/// </summary>
public const int ClientClosedRequest = 499;

/// <summary>
/// Gets or sets the status code to return for a cancelled request. The default is the non-standard 499
/// 'Client Closed Request' used by NGINX.
/// See https://stackoverflow.com/questions/46234679/what-is-the-correct-http-status-code-for-a-cancelled-request.
/// </summary>
public int StatusCode { get; set; } = ClientClosedRequest;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace Boxed.AspNetCore.Test.Middleware;

using System;
using System.Threading;
using System.Threading.Tasks;
using Boxed.AspNetCore.Middleware;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;

public class RequestCanceledMiddlewareTest
{
private readonly DefaultHttpContext context;
private RequestDelegate next;

public RequestCanceledMiddlewareTest()
{
this.context = new DefaultHttpContext();
this.next = x => Task.CompletedTask;
}

[Fact]
public void InvokeAsync_NullContext_ThrowsArgumentNullException() =>
Assert.ThrowsAsync<ArgumentNullException>(() => new ServerTimingMiddleware().InvokeAsync(null!, this.next));

[Fact]
public void InvokeAsync_NullNext_ThrowsArgumentNullException() =>
Assert.ThrowsAsync<ArgumentNullException>(() => new ServerTimingMiddleware().InvokeAsync(this.context, null!));

[Fact]
public async Task InvokeAsync_RequestNotCanceled_RunsNextMiddlewareAsync()
{
await new RequestCanceledMiddleware(
new RequestCanceledMiddlewareOptions(),
new Mock<ILogger<RequestCanceledMiddleware>>().Object)
.InvokeAsync(this.context, this.next)
.ConfigureAwait(false);

Assert.Equal(200, this.context.Response.StatusCode);
}

[Fact]
public async Task InvokeAsync_OperationCanceledExceptionThrownNotCanceled_RunsNextMiddlewareAsync()
{
using var cancellationTokenSource1 = new CancellationTokenSource();
using var cancellationTokenSource2 = new CancellationTokenSource();
cancellationTokenSource2.Cancel();
this.context.RequestAborted = cancellationTokenSource1.Token;
this.next = x => Task.FromException(new OperationCanceledException(cancellationTokenSource2.Token));

await Assert
.ThrowsAsync<OperationCanceledException>(() =>
new RequestCanceledMiddleware(
new RequestCanceledMiddlewareOptions(),
new Mock<ILogger<RequestCanceledMiddleware>>().Object)
.InvokeAsync(this.context, this.next))
.ConfigureAwait(false);
}

[Fact]
public async Task InvokeAsync_RequestCanceled_Returns499ClientClosedRequestAsync()
{
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
this.context.RequestAborted = cancellationTokenSource.Token;
this.next = x => Task.FromCanceled(cancellationTokenSource.Token);

await new RequestCanceledMiddleware(
new RequestCanceledMiddlewareOptions(),
new Mock<ILogger<RequestCanceledMiddleware>>().Object)
.InvokeAsync(this.context, this.next)
.ConfigureAwait(false);

Assert.Equal(RequestCanceledMiddlewareOptions.ClientClosedRequest, this.context.Response.StatusCode);
}
}