Search code examples
c#asp.net-coreexceptionintegration-testing

GlobalExceptionHander Test failing Error while copying content to a stream


I created a GlobalExceptionHandler for a minimal API and some unit tests to verify the handler.

My handler looks like this:

internal class GlobalExceptionHandler(ILoggerWrapper<GlobalExceptionHandler> logger) : IExceptionHandler
{
    /// <inheritdoc cref="IExceptionHandler.TryHandleAsync"/>
    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        ProblemDetails problemDetails = new()
        {
            Status = StatusCodes.Status500InternalServerError,
            //TODO: Build a visible endpoint to describe the exception CV-27246
            Type = "https://httpstatuses.com/500",
            Detail = exception.StackTrace
        };
        Action<Exception> logAction = logger.LogCritical;
        switch (exception)
        {
            case OperationCanceledException:
                problemDetails.Title = "The operation has been canceled.";
                break;
            case OutOfMemoryException:
                problemDetails.Title = "There was an error, Out of memory. Please see logs for more information.";
                break;
            case StackOverflowException:
                problemDetails.Title = "There was an error, StackOverflow. Please see logs for more information.";
                break;
            case SEHException:
                problemDetails.Title = "There was an error, SEHException. Please see logs for more information.";
                break;
            default:
                logAction =  logger.LogError;
                problemDetails.Status = StatusCodes.Status417ExpectationFailed;
                //TODO: Build a visible endpoint to describe the exception CV-27246
                problemDetails.Type = "https://httpstatuses.com/417";
                problemDetails.Title = $"{exception.Message} Please see logs for more information.";
                break;
        }

        logAction(exception);
        httpContext.Response.StatusCode = problemDetails.Status.HasValue ?
            problemDetails.Status.Value :
            StatusCodes.Status500InternalServerError;
        httpContext.Response.ContentType = "application/json";
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        var canContinue = logAction != logger.LogCritical;
        return canContinue;
    }
}

Which is working and returning false when one of the above error are thrown.

In my integration test I've added:

protected override void ConfigureWebHost(IWebHostBuilder builder) {
    builder.Configure(app =>
    {
        app.UseExportService();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("throw-out-of-memory", UnhandledExceptionEndpoints.ThrowOutOfMemory);
            endpoints.MapGet("throw-stack-overflow", UnhandledExceptionEndpoints.ThrowStackOverflow);
            endpoints.MapGet("throw-seh", UnhandledExceptionEndpoints.ThrowSEHException);
            endpoints.MapGet("throw-handeled", UnhandledExceptionEndpoints.ThrowHandledException);
        });
    });
}

Which just throws the exception of what is asked for.

And the unit tests looks like:

[Fact]
public async Task OperationCanceledException_ShouldReturn500()
{
    //Arrange
    var client = _factory.CreateClient();
    HttpResponseMessage? response = null;

    //Act
    response = await client.GetAsync("throw-out-of-memory");
    var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();

    //Assert
    response.IsSuccessStatusCode.Should().BeFalse();
    problemDetails.Status.Should().Be(500);
    problemDetails.Type.Should().Be("https://httpstatuses.com/500");//TODO: Build a visible endpoint to describe the exception CV-27246
    problemDetails.Title.Should().Be("There was an error, Out of memory. please  see logs for more information.");
}

All of this was working correctly, until recently. It seems to run on the CI/CD just fine, but when I run it locally, I get the following error:

System.Net.Http.HttpRequestException Error while copying content to a stream.    
    at System.Net.Http.HttpContent.LoadIntoBufferAsyncCore(Task serializeToStreamTask, MemoryStream tempBuffer)
    at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
    at ExportService.Tests.Intergration.Middleware.GlobalExceptionHandlerTests.OperationCanceledException_ShouldReturn500() in C:\Users\james.tays\source\repos\Components\src\ExportService\ExportService.Tests.Intergration\Middleware\GlobalExceptionHandlerTests.cs:line 72 
    at Xunit.Sdk.TestInvoker`1.<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext() in /_/src/xunit.execution/Sdk/Frameworks/Runners/TestInvoker.cs:line 259

--- End of stack trace from previous location ---    
    at Xunit.Sdk.ExecutionTimer.AggregateAsync(Func`1 asyncAction) in /_/src/xunit.execution/Sdk/Frameworks/ExecutionTimer.cs:line 48
    at Xunit.Sdk.ExceptionAggregator.RunAsync(Func`1 code) in /_/src/xunit.core/Sdk/ExceptionAggregator.cs:line 90

System.IO.IOException 
    at Microsoft.AspNetCore.TestHost.ResponseBodyReaderStream.CheckAborted()
    at Microsoft.AspNetCore.TestHost.ResponseBodyReaderStream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
    at System.IO.Stream.<CopyToAsync>g__Core|27_0(Stream source, Stream destination, Int32 bufferSize, CancellationToken cancellationToken)
    at System.Net.Http.HttpContent.LoadIntoBufferAsyncCore(Task serializeToStreamTask, MemoryStream tempBuffer)

System.OutOfMemoryException OutOfMemory
    at ExportService.Tests.Intergration.Endpoints.UnhandledExceptionEndpoints.ThrowOutOfMemory() in C:\Users\james.tays\source\repos\Components\src\ExportService\ExportService.Tests.Intergration\Endpoints\UnhandledExceptionEndpoints.cs:line 15
    at lambda_method19(Closure, Object, HttpContext)
    at Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke(HttpContext httpContext)
    at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.Invoke(HttpContext context)

--- End of stack trace from previous location ---
    at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.HandleException(HttpContext context, ExceptionDispatchInfo edi)
    at Microsoft.AspNetCore.TestHost.HttpContextBuilder.<>c__DisplayClass23_0.<<SendAsync>g__RunRequestAsync|0>d.MoveNext()

While debugging that I am never getting to:

var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();

Can you please explain why this exception is not being handled? I have just returned true in the handler and it still has the same behavior. And in my services I do have the following:

services.AddProblemDetails();
services.AddExceptionHandler<GlobalExceptionHandler>();

Solution

  • Solution

    Your implementation of IExceptionHandler is incorrect. The result of the TryHandleAsync() method should be a bool flag indicating whether the next exception handler should be called and attempt to handle the exception, not whether the request pipeline can continue based on error severity.

    So, if you return:

    • false - that means your exception handler did not handle the exception. This can be useful if you want to just log some stuff about the exception and are not interested in handling it.
    • true - that means your exception handler succeeded in handling the exception and no further handling is needed. No other exception handlers will be executed beyond this point.

    Additionally:

    If an exception isn't handled by any exception handler, then control falls back to the default behavior and options from the middleware. ~ ASP.NET Core Docs

    You should always return true if you wrote to the response:

    await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
    
    return true;
    

    How it all happened

    The default behavior is what was happening in your case, although I must admit it took me a while to unveil it. When you look at this piece of source code of the implementation of ExceptionHandlerMiddleware (full source code at GitHub):

    string? handler = null;
    var handled = false;
    foreach (var exceptionHandler in _exceptionHandlers)
    {
        handled = await exceptionHandler.TryHandleAsync(context, edi.SourceException, context.RequestAborted);
        if (handled)
        {
            handler = exceptionHandler.GetType().FullName;
            break;
        }
    }
    

    You can see that the logic here iterates through exception handlers. But since there is only one handler registered, and that is your custom one, we exit the for each with handled variable set to false.

    That allows to satisfy below if statement and fall into it:

    if (!handled)
    {
        if (_options.ExceptionHandler is not null)
        {
            await _options.ExceptionHandler!(context);
        }
        else
        {
            handled = await _problemDetailsService!.TryWriteAsync(new()
            {
                HttpContext = context,
                AdditionalMetadata = exceptionHandlerFeature.Endpoint?.Metadata,
                ProblemDetails = { Status = DefaultStatusCode },
                Exception = edi.SourceException,
            });
            if (handled)
            {
                handler = _problemDetailsService.GetType().FullName;
            }
        }
    }
    

    Exception handler in options is null, so control falls back to default behavior and attempts to write standard problem details to the response. But it fails to do so because, at this point, a response has already been written. Your custom exception handler GlobalExceptionHandler already set the response status code, response headers and wrote to response stream. In consequence, another exception is raised:

    System.InvalidOperationException: The response headers cannot be modified because the response has already started.

    But, since the middleware suppresses the secondary exception (and is right to do so!) we're back to square one with the original exception. And that one, wrapped in some internal framework structure, is finally being thrown:

    exception wrapped in edi