diff --git a/README.md b/README.md index d698aa7..7f14bf3 100644 --- a/README.md +++ b/README.md @@ -758,6 +758,26 @@ are rejected over HTTP GET connections. Derive from `GraphQLHttpMiddleware` and As would be expected, subscription requests are only allowed over WebSocket channels. +### Excessive `OperationCanceledException`s + +When hosting a WebSockets endpoint, it may be common for clients to simply disconnect rather +than gracefully terminating the connection — most specifically when the client is a web browser. +If you log exceptions, you may notice an `OperationCanceledException` logged any time this occurs. + +In some scenarios you may wish to log these exceptions — for instance, when the GraphQL endpoint is +used in server-to-server communications — but if you wish to ignore these exceptions, simply call +`app.UseIgnoreDisconnections();` immediately after any exception handling or logging configuration calls. +This will consume any `OperationCanceledException`s when `HttpContext.RequestAborted` is signaled — for +a WebSocket request or any other request. + +```csharp +var app = builder.Build(); +app.UseDeveloperExceptionPage(); +app.UseIgnoreDisconnections(); +app.UseWebSockets(); +app.UseGraphQL(); +``` + ### Handling form data for POST requests The GraphQL over HTTP specification does not outline a procedure for transmitting GraphQL requests via diff --git a/src/GraphQL.AspNetCore3/GraphQLHttpApplicationBuilderExtensions.cs b/src/GraphQL.AspNetCore3/GraphQLHttpApplicationBuilderExtensions.cs index 28821a0..4ab40c1 100644 --- a/src/GraphQL.AspNetCore3/GraphQLHttpApplicationBuilderExtensions.cs +++ b/src/GraphQL.AspNetCore3/GraphQLHttpApplicationBuilderExtensions.cs @@ -89,4 +89,24 @@ public static IApplicationBuilder UseGraphQL(this IApplicationBuild context => context.Request.Path.Equals(path), b => b.UseMiddleware(args)); } + + /// + /// Ignores exceptions when + /// is signaled. + /// + /// + /// Place this immediately after exception handling or logging middleware, such as + /// UseDeveloperExceptionPage. + /// + public static IApplicationBuilder UseIgnoreDisconnections(this IApplicationBuilder builder) + { + return builder.Use(static next => { + return async context => { + try { + await next(context); + } catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { + } + }; + }); + } } diff --git a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt index 102f5f0..68eee28 100644 --- a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt +++ b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt @@ -108,6 +108,7 @@ namespace GraphQL.AspNetCore3 where TSchema : GraphQL.Types.ISchema { } public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseGraphQL(this Microsoft.AspNetCore.Builder.IApplicationBuilder builder, string path = "/graphql", params object[] args) where TMiddleware : GraphQL.AspNetCore3.GraphQLHttpMiddleware { } + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseIgnoreDisconnections(this Microsoft.AspNetCore.Builder.IApplicationBuilder builder) { } } public static class GraphQLHttpEndpointRouteBuilderExtensions { diff --git a/src/Tests/BuilderMethodTests.cs b/src/Tests/BuilderMethodTests.cs index f28c0de..eb61e08 100644 --- a/src/Tests/BuilderMethodTests.cs +++ b/src/Tests/BuilderMethodTests.cs @@ -86,6 +86,41 @@ public async Task Basic(string url) await VerifyAsync(url); } + [Fact] + public async Task Basic_WithUseIgnoreDisconnections() + { + _hostBuilder.Configure(app => { + app.UseIgnoreDisconnections(); + app.UseWebSockets(); + app.UseGraphQL(); + }); + await VerifyAsync(); + } + + [Fact] + public async Task UseIgnoreDisconnections_Fail() + { + using var cts = new CancellationTokenSource(); + RequestDelegate func = next => throw new OperationCanceledException(); + var builderMock = new Mock(); + builderMock.Setup(x => x.Use(It.IsAny>())).Returns>( + d => { + func = d(func); + return builderMock.Object; + }); + builderMock.Object.UseIgnoreDisconnections(); + var context = new DefaultHttpContext() { + RequestAborted = cts.Token, + }; + + // cts is not canceled here so OCE should pass through + await Should.ThrowAsync(() => func(context)); + + cts.Cancel(); + // cts is canceled so OCE should be consumed + await func(context); + } + [Theory] [InlineData("/graphql/")] [InlineData("/graphql/more")]