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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/StackExchange.Utils.Http/Extensions.Modifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ public static IRequestBuilder WithoutLogging(this IRequestBuilder builder, HttpS
return builder;
}

/// <summary>
/// Logs error response body as part of the httpClientException data when the response's HTTP status code is any of the <paramref name="statusCodes"/>.
/// </summary>
/// <param name="builder">The builder we're working on.</param>
/// <param name="statusCodes">HTTP error status codes to log for.</param>
/// <returns>The request builder for chaining.</returns>
public static IRequestBuilder WithErrorResponseBodyLogging(this IRequestBuilder builder, params HttpStatusCode[] statusCodes)
{
builder.LogErrorResponseBodyStatuses = statusCodes;
return builder;
}

/// <summary>
/// Adds an event handler for this request, for appending additional information to the logged exception for example.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/StackExchange.Utils.Http/Http.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
Expand Down Expand Up @@ -68,6 +69,14 @@ internal static async Task<HttpCallResponse<T>> SendAsync<T>(IRequestBuilder<T>
if (!response.IsSuccessStatusCode && !builder.Inner.IgnoredResponseStatuses.Contains(response.StatusCode))
{
exception = new HttpClientException($"Response code was {(int)response.StatusCode} ({response.StatusCode}) from {response.RequestMessage.RequestUri}: {response.ReasonPhrase}", response.StatusCode, response.RequestMessage.RequestUri);

if (builder.Inner.LogErrorResponseBodyStatuses.Contains(response.StatusCode))
{
using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
exception.AddLoggedData("Response.Body", await streamReader.ReadToEndAsync());
}

stackTraceString.SetValue(exception, new StackTrace(true).ToString());
}
else
Expand Down
1 change: 1 addition & 0 deletions src/StackExchange.Utils.Http/HttpBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal class HttpBuilder : IRequestBuilder
public HttpRequestMessage Message { get; }
public bool LogErrors { get; set; } = true;
public IEnumerable<HttpStatusCode> IgnoredResponseStatuses { get; set; } = Enumerable.Empty<HttpStatusCode>();
public IEnumerable<HttpStatusCode> LogErrorResponseBodyStatuses { get; set; } = Enumerable.Empty<HttpStatusCode>();
public TimeSpan Timeout { get; set; }
public IWebProxy Proxy { get; set; }
public bool BufferResponse { get; set; } = true;
Expand Down
6 changes: 6 additions & 0 deletions src/StackExchange.Utils.Http/IRequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public interface IRequestBuilder
[EditorBrowsable(EditorBrowsableState.Never)]
bool LogErrors { get; set; }

/// <summary>
/// Whether to log error response body on a given http status code.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
IEnumerable<HttpStatusCode> LogErrorResponseBodyStatuses { get; set; }

/// <summary>
/// Which <see cref="HttpStatusCode"/>s to ignore as errors on responses.
/// </summary>
Expand Down
137 changes: 137 additions & 0 deletions tests/StackExchange.Utils.Tests/HttpLogErrorResponseBodyTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System.Net;
using System.Threading.Tasks;
using HttpMock;
using Xunit;

namespace StackExchange.Utils.Tests
{
public class HttpLogErrorResponseBodyTest
{
private readonly IHttpServer _stubHttp = HttpMockRepository.At("http://localhost:9191");

[Fact]
public async Task WithErrorResponseBodyLogging_IfStatusCodeMatchesTheGivenOne_IncludeResponseBodyInException()
{
const string errorResponseBody = "{'Foo': 'Bar'}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(errorResponseBody)
.WithStatus(HttpStatusCode.UnprocessableEntity);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.WithErrorResponseBodyLogging(HttpStatusCode.UnprocessableEntity)
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.UnprocessableEntity,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Equal(errorResponseBody, httpCallResponses.Error.Data[Http.DefaultSettings.ErrorDataPrefix + "Response.Body"]);
}

[Fact]
public async Task WithErrorResponseBodyLogging_IfStatusCodeMatchesOneOfTheGiven_IncludeResponseBodyInException()
{
const string errorResponseBody = "{'Foo': 'Bar'}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(errorResponseBody)
.WithStatus(HttpStatusCode.UnprocessableEntity);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.WithErrorResponseBodyLogging(HttpStatusCode.NotAcceptable, HttpStatusCode.UnprocessableEntity)
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.UnprocessableEntity,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Equal(errorResponseBody, httpCallResponses.Error.Data[Http.DefaultSettings.ErrorDataPrefix + "Response.Body"]);
}

[Fact]
public async Task WithErrorResponseBodyLogging_IfStatusCodeDoesNotMatchAnyOfTheGiven_DoesNotIncludeResponseBodyInException()
{
const string errorResponseBody = "{'Foo': 'Bar'}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(errorResponseBody)
.WithStatus(HttpStatusCode.UnprocessableEntity);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.WithErrorResponseBodyLogging(HttpStatusCode.NotAcceptable, HttpStatusCode.BadRequest)
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.UnprocessableEntity,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Null(httpCallResponses.Error.Data[Http.DefaultSettings.ErrorDataPrefix + "Response.Body"]);
}

[Fact]
public async Task WithErrorResponseBodyLogging_IfNoStatusCodesGiven_DoesNotIncludeResponseBodyInException()
{
const string errorResponseBody = "{'Foo': 'Bar'}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(errorResponseBody)
.WithStatus(HttpStatusCode.UnprocessableEntity);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.WithErrorResponseBodyLogging()
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.UnprocessableEntity,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Null(httpCallResponses.Error.Data[Http.DefaultSettings.ErrorDataPrefix + "Response.Body"]);
}

[Fact]
public async Task WithErrorResponseBodyLogging_WithoutCallingWithErrorResponseBodyLogging_DoesNotIncludeResponseBodyInException()
{
const string errorResponseBody = "{'Foo': 'Bar'}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(errorResponseBody)
.WithStatus(HttpStatusCode.UnprocessableEntity);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.UnprocessableEntity,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Null(httpCallResponses.Error.Data[Http.DefaultSettings.ErrorDataPrefix + "Response.Body"]);
}

[Fact]
public async Task WithErrorResponseBodyLogging_IfResponseSuccess_DoesNotIncludeResponseBodyInExceptionAndDeserializesCorrectly()
{
const string successResponseBody = @"{""SomeAttribute"": ""some value""}";
_stubHttp.Stub(x => x.Get("/some-endpoint"))
.Return(successResponseBody)
.WithStatus(HttpStatusCode.OK);

var response = await Http
.Request("http://localhost:9191/some-endpoint")
.WithErrorResponseBodyLogging(HttpStatusCode.UnprocessableEntity)
.ExpectJson<SomeResponseObject>()
.GetAsync();

Assert.Equal(HttpStatusCode.OK,response.StatusCode);

var httpCallResponses = Assert.IsType<HttpCallResponse<SomeResponseObject>>(response);
Assert.Null(httpCallResponses.Error);
Assert.Equal("some value", httpCallResponses.Data.SomeAttribute);
}
}

public class SomeResponseObject
{
public string SomeAttribute { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HttpMock" Version="2.3.1" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
<ProjectReference Include="../../src/StackExchange.Utils.Http/StackExchange.Utils.Http.csproj" />
Expand Down