Skip to content

Commit 90da124

Browse files
committed
Lower severity of logs caused by request body IOExceptions
1 parent ba35930 commit 90da124

File tree

2 files changed

+185
-2
lines changed

2 files changed

+185
-2
lines changed

src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Diagnostics;
77
using System.Globalization;
8+
using System.IO;
89
using System.Linq;
910
using System.Linq.Expressions;
1011
using System.Reflection;
@@ -14,6 +15,7 @@
1415
using Microsoft.AspNetCore.Http.Metadata;
1516
using Microsoft.Extensions.DependencyInjection;
1617
using Microsoft.Extensions.Internal;
18+
using Microsoft.Extensions.Logging;
1719

1820
namespace Microsoft.AspNetCore.Routing.Internal
1921
{
@@ -262,7 +264,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action)
262264
}
263265
else
264266
{
265-
bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!);
267+
try
268+
{
269+
bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!);
270+
}
271+
catch (IOException ex)
272+
{
273+
LogRequestBodyIOException(httpContext, ex);
274+
httpContext.Abort();
275+
return;
276+
}
266277
}
267278

268279
await invoker(target, httpContext, bodyValue);
@@ -277,7 +288,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action)
277288
{
278289
// Generating async code would just be insane so if the method needs the form populate it here
279290
// so the within the method it's cached
280-
await httpContext.Request.ReadFormAsync();
291+
try
292+
{
293+
await httpContext.Request.ReadFormAsync();
294+
}
295+
catch (IOException ex)
296+
{
297+
LogRequestBodyIOException(httpContext, ex);
298+
httpContext.Abort();
299+
return;
300+
}
281301

282302
await invoker(target, httpContext);
283303
};
@@ -296,6 +316,18 @@ public static RequestDelegate BuildRequestDelegate(Delegate action)
296316
};
297317
}
298318

319+
private static void LogRequestBodyIOException(HttpContext httpContext, IOException exception)
320+
{
321+
var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
322+
323+
// REVIEW: I'm not sure how we feel about using an internal type as a category name, but this is what was done in other
324+
// internal classes like DfaMather and DefaultLinkGenerator. My main concern is if we want to use the same category for
325+
// other MapAction implementations (e.g. Roslyn source generators)
326+
var logger = loggerFactory.CreateLogger(typeof(MapActionExpressionTreeBuilder).FullName!);
327+
328+
Log.RequestBodyIOException(logger, exception);
329+
}
330+
299331
private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string? name)
300332
{
301333
var key = name ?? parameter.Name;
@@ -398,5 +430,18 @@ private static void ThrowCannotReadBodyDirectlyAndAsForm()
398430
{
399431
throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method.");
400432
}
433+
434+
private static class Log
435+
{
436+
private static readonly Action<ILogger, Exception> _requestBodyIOException = LoggerMessage.Define(
437+
LogLevel.Debug,
438+
new EventId(1, "RequestBodyIOException"),
439+
"Reading the request body failed with an IOException.");
440+
441+
public static void RequestBodyIOException(ILogger logger, IOException exception)
442+
{
443+
_requestBodyIOException(logger, exception);
444+
}
445+
}
401446
}
402447
}

src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
using System.IO;
1010
using System.Text;
1111
using System.Text.Json;
12+
using System.Threading;
1213
using System.Threading.Tasks;
1314
using Microsoft.AspNetCore.Http;
15+
using Microsoft.AspNetCore.Http.Features;
1416
using Microsoft.AspNetCore.Http.Metadata;
1517
using Microsoft.Extensions.DependencyInjection;
18+
using Microsoft.Extensions.Logging;
19+
using Microsoft.Extensions.Logging.Testing;
1620
using Microsoft.Extensions.Primitives;
1721
using Xunit;
1822

@@ -24,6 +28,7 @@ public class MapActionExpressionTreeBuilderTest
2428
public async Task RequestDelegateInvokesAction()
2529
{
2630
var invoked = false;
31+
2732
void TestAction()
2833
{
2934
invoked = true;
@@ -87,6 +92,10 @@ public async Task UsesDefaultValueIfNoMatchingRouteValue()
8792
{
8893
const string unmatchedName = "value";
8994
const int unmatchedRouteParam = 42;
95+
var structToBeZeroed = new BodyStruct
96+
{
97+
Id = 42
98+
};
9099

91100
int? deserializedRouteParam = null;
92101

@@ -246,6 +255,42 @@ void TestAction([FromBody(AllowEmpty = true)] BodyStruct bodyStruct)
246255
Assert.Equal(default, structToBeZeroed);
247256
}
248257

258+
[Fact]
259+
public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebug()
260+
{
261+
var invoked = false;
262+
263+
var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName);
264+
var testLoggerFactory = new TestLoggerFactory(sink, enabled: true);
265+
266+
void TestAction([FromBody] Todo todo)
267+
{
268+
invoked = true;
269+
}
270+
271+
var ioException = new IOException();
272+
var serviceCollection = new ServiceCollection();
273+
serviceCollection.AddSingleton<ILoggerFactory>(testLoggerFactory);
274+
275+
var httpContext = new DefaultHttpContext();
276+
httpContext.Request.Headers["Content-Type"] = "application/json";
277+
httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(ioException);
278+
httpContext.Features.Set<IHttpRequestLifetimeFeature>(new TestHttpRequestLifetimeFeature());
279+
httpContext.RequestServices = serviceCollection.BuildServiceProvider();
280+
281+
var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action<Todo>)TestAction);
282+
283+
await requestDelegate(httpContext);
284+
285+
Assert.False(invoked);
286+
Assert.True(httpContext.RequestAborted.IsCancellationRequested);
287+
288+
var logMessage = Assert.Single(sink.Writes);
289+
Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId);
290+
Assert.Equal(LogLevel.Debug, logMessage.LogLevel);
291+
Assert.Same(ioException, logMessage.Exception);
292+
}
293+
249294
[Fact]
250295
public async Task RequestDelegatePopulatesFromFormParameterBasedOnParameterName()
251296
{
@@ -274,6 +319,42 @@ void TestAction([FromForm] int value)
274319
Assert.Equal(originalQueryParam, deserializedRouteParam);
275320
}
276321

322+
[Fact]
323+
public async Task RequestDelegateLogsFromFormIOExceptionsAsDebug()
324+
{
325+
var invoked = false;
326+
327+
var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName);
328+
var testLoggerFactory = new TestLoggerFactory(sink, enabled: true);
329+
330+
void TestAction([FromForm] int value)
331+
{
332+
invoked = true;
333+
}
334+
335+
var ioException = new IOException();
336+
var serviceCollection = new ServiceCollection();
337+
serviceCollection.AddSingleton<ILoggerFactory>(testLoggerFactory);
338+
339+
var httpContext = new DefaultHttpContext();
340+
httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded";
341+
httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(ioException);
342+
httpContext.Features.Set<IHttpRequestLifetimeFeature>(new TestHttpRequestLifetimeFeature());
343+
httpContext.RequestServices = serviceCollection.BuildServiceProvider();
344+
345+
var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action<int>)TestAction);
346+
347+
await requestDelegate(httpContext);
348+
349+
Assert.False(invoked);
350+
Assert.True(httpContext.RequestAborted.IsCancellationRequested);
351+
352+
var logMessage = Assert.Single(sink.Writes);
353+
Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId);
354+
Assert.Equal(LogLevel.Debug, logMessage.LogLevel);
355+
Assert.Same(ioException, logMessage.Exception);
356+
}
357+
277358
[Fact]
278359
public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenBothFromBodyAndFromFormOnDifferentParameters()
279360
{
@@ -463,5 +544,62 @@ public Task ExecuteAsync(HttpContext httpContext)
463544
return httpContext.Response.WriteAsync(_resultString);
464545
}
465546
}
547+
548+
private class IOExceptionThrowingRequestBodyStream : Stream
549+
{
550+
private readonly Exception _exceptionToThrow;
551+
552+
public IOExceptionThrowingRequestBodyStream(Exception exceptionToThrow)
553+
{
554+
_exceptionToThrow = exceptionToThrow;
555+
}
556+
557+
public override bool CanRead => true;
558+
559+
public override bool CanSeek => false;
560+
561+
public override bool CanWrite => false;
562+
563+
public override long Length => throw new NotImplementedException();
564+
565+
public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
566+
567+
public override void Flush()
568+
{
569+
throw new NotImplementedException();
570+
}
571+
572+
public override int Read(byte[] buffer, int offset, int count)
573+
{
574+
throw _exceptionToThrow;
575+
}
576+
577+
public override long Seek(long offset, SeekOrigin origin)
578+
{
579+
throw new NotImplementedException();
580+
}
581+
582+
public override void SetLength(long value)
583+
{
584+
throw new NotImplementedException();
585+
}
586+
587+
public override void Write(byte[] buffer, int offset, int count)
588+
{
589+
throw new NotImplementedException();
590+
}
591+
}
592+
593+
private class TestHttpRequestLifetimeFeature : IHttpRequestLifetimeFeature
594+
{
595+
private readonly CancellationTokenSource _requestAbortedCts = new CancellationTokenSource();
596+
597+
public CancellationToken RequestAborted { get => _requestAbortedCts.Token; set => throw new NotImplementedException(); }
598+
599+
public void Abort()
600+
{
601+
_requestAbortedCts.Cancel();
602+
}
603+
}
466604
}
467605
}

0 commit comments

Comments
 (0)