Skip to content

Commit

Permalink
Merge pull request #94 from ShipEngine/error-handling
Browse files Browse the repository at this point in the history
Handle unexpected HTTP responses
  • Loading branch information
joshuaflanagan authored Jun 28, 2024
2 parents 561e3af + 5997aaf commit 743d2d2
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 35 deletions.
7 changes: 6 additions & 1 deletion ShipEngine.Tests/Helpers/MockShipEngineFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace ShipEngineTest
using Moq;
using Moq.Protected;
using ShipEngineSDK;
using System;
using System.Net;
using System.Net.Http;
using System.Text.Json;
Expand Down Expand Up @@ -74,10 +75,13 @@ public void AssertRequest(HttpMethod method, string path, int numberOfCalls = 1)
/// <param name="path">The HTTP path.</param>
/// <param name="status">The status code to return.</param>
/// <param name="response">The response body to return.</param>
public void StubRequest(HttpMethod method, string path, HttpStatusCode status, string response)
public string StubRequest(HttpMethod method, string path, HttpStatusCode status, string response)
{
var requestId = Guid.NewGuid().ToString();
var responseMessage = new HttpResponseMessage(status);
responseMessage.Content = new StringContent(response);
responseMessage.Headers.Add("x-shipengine-requestid", requestId);
responseMessage.Headers.Add("request-id", requestId);

MockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
Expand All @@ -87,6 +91,7 @@ public void StubRequest(HttpMethod method, string path, HttpStatusCode status, s
m.RequestUri.AbsolutePath == path),
ItExpr.IsAny<CancellationToken>())
.Returns(Task.FromResult(responseMessage));
return requestId;
}
}
}
139 changes: 139 additions & 0 deletions ShipEngine.Tests/ShipEngineClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
namespace ShipEngineTest
{
using ShipEngineSDK;
using ShipEngineSDK.VoidLabelWithLabelId;
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

public class ShipEngineClientTests
{
[Fact]
public async Task FailureStatusWithShipengineContentThrowsPopulatedShipEngineException()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
var shipengine = mockShipEngineFixture.ShipEngine;


string requestId = "12345";
string message = "Request body cannot be empty.";
var responseBody = string.Format(
@"
{{
""request_id"": ""{0}"",
""errors"": [
{{
""error_source"": ""shipengine"",
""error_type"": ""validation"",
""error_code"": ""request_body_required"",
""message"": ""{1}""
}}
]
}}", requestId, message);


mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.BadRequest,
responseBody);
var ex = await Assert.ThrowsAsync<ShipEngineException>(
async () => await shipengine.SendHttpRequestAsync<Result>(HttpMethod.Post, "/v1/something", "",
mockShipEngineFixture.HttpClient, config)
);

Assert.Equal(requestId, ex.RequestId);
Assert.Equal(message, ex.Message);
Assert.Equal(ErrorSource.Shipengine, ex.ErrorSource);
Assert.Equal(ErrorType.Validation, ex.ErrorType);
Assert.Equal(ErrorCode.RequestBodyRequired, ex.ErrorCode);
Assert.NotNull(ex.ResponseMessage);
Assert.Equal(400, (int)ex.ResponseMessage.StatusCode);
}

[Fact]
public async Task FailureStatusWithoutShipEngineDetailsThrowsShipEngineExceptionWithOriginalResponse()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
var shipengine = mockShipEngineFixture.ShipEngine;

var responseBody = @"{""description"": ""valid JSON, but not what you expect""}";
var requestId = mockShipEngineFixture.StubRequest(HttpMethod.Get, "/v1/something", System.Net.HttpStatusCode.NotFound,
responseBody);
var ex = await Assert.ThrowsAsync<ShipEngineException>(
async () => await shipengine.SendHttpRequestAsync<Result>(HttpMethod.Get, "/v1/something", null,
mockShipEngineFixture.HttpClient, config)
);

Assert.NotNull(ex.ResponseMessage);
Assert.Equal(404, (int) ex.ResponseMessage.StatusCode);
Assert.Equal(requestId, ex.RequestId);
}

[Fact]
public async Task FailureStatusWithoutJsonContentThrowsShipEngineExceptionWithOriginalResponse()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
var shipengine = mockShipEngineFixture.ShipEngine;

var responseBody = @"<h1>Bad Gateway</h1>";
var requestId = mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.BadGateway,
responseBody);
var ex = await Assert.ThrowsAsync<ShipEngineException>(
async () => await shipengine.SendHttpRequestAsync<Result>(HttpMethod.Post, "/v1/something", "",
mockShipEngineFixture.HttpClient, config)
);

Assert.NotNull(ex.ResponseMessage);
Assert.Equal(502, (int) ex.ResponseMessage.StatusCode);
Assert.Equal(requestId, ex.RequestId);
}

[Fact]
public async Task SuccessResponseThatCannotBeParsedThrowsExceptionWithUnparsedResponse()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
var shipengine = mockShipEngineFixture.ShipEngine;

var responseBody = @"Unexpected response - not JSON";
var requestId = mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.OK,
responseBody);
var ex = await Assert.ThrowsAsync<ShipEngineException>(
async () => await shipengine.SendHttpRequestAsync<Result>(HttpMethod.Post, "/v1/something", "",
mockShipEngineFixture.HttpClient, config)
);
mockShipEngineFixture.AssertRequest(HttpMethod.Post, "/v1/something");

Assert.NotNull(ex.ResponseMessage);
Assert.Equal(200, (int)ex.ResponseMessage.StatusCode);
Assert.Equal(responseBody, await ex.ResponseMessage.Content.ReadAsStringAsync());
Assert.Equal(requestId, ex.RequestId);
}

[Fact]
public async Task SuccessResponseWithNullContentThrowsShipEngineExceptionWithUnparsedResponse()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
var shipengine = mockShipEngineFixture.ShipEngine;

// this scenario is similar to unparseable JSON - except that it is valid JSON
var responseBody = @"null";
var requestId = mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.OK,
responseBody);
var ex = await Assert.ThrowsAsync<ShipEngineException>(
async () => await shipengine.SendHttpRequestAsync<Result>(HttpMethod.Post, "/v1/something", "",
mockShipEngineFixture.HttpClient, config)
);
mockShipEngineFixture.AssertRequest(HttpMethod.Post, "/v1/something");

Assert.NotNull(ex.ResponseMessage);
Assert.Equal("Unexpected null response", ex.Message);
Assert.Equal(200, (int)ex.ResponseMessage.StatusCode);
Assert.Equal(responseBody, await ex.ResponseMessage.Content.ReadAsStringAsync());
Assert.Equal(requestId, ex.RequestId);
}
}
}
74 changes: 40 additions & 34 deletions ShipEngine/ShipEngineClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,41 +88,60 @@ public static HttpClient ConfigureHttpClient(HttpClient client, string apiKey, U
private async Task<T> DeserializedResultOrThrow<T>(HttpResponseMessage response)
{
var contentString = await response.Content.ReadAsStringAsync();
string? requestId = null;
if (response.Headers.TryGetValues("x-shipengine-requestid", out var requestIdValues))
{
requestId = requestIdValues.FirstOrDefault();
}


if (!response.IsSuccessStatusCode)
{
var deserializedError = JsonSerializer.Deserialize<ShipEngineAPIError>(contentString, JsonSerializerOptions);

// Throw Generic HttpClient Error if unable to deserialize to a ShipEngineException
if (deserializedError == null)
ShipEngineAPIError? deserializedError = null;
try
{
deserializedError =
JsonSerializer.Deserialize<ShipEngineAPIError>(contentString, JsonSerializerOptions);
}
catch (JsonException)
{
response.EnsureSuccessStatusCode();
}

var error = deserializedError?.Errors[0];

if (error != null && error.Message != null && deserializedError?.RequestId != null)
if (deserializedError == null)
{
throw new ShipEngineException(
error.Message,
error.ErrorSource,
error.ErrorType,
error.ErrorCode,
deserializedError.RequestId,
response
);
// in this case, the response body was not parseable JSON
throw new ShipEngineException("Unexpected HTTP status", requestID: requestId, responseMessage: response);
}

var error = deserializedError.Errors?.FirstOrDefault(e => e.Message != null);
// if error is null, it means we got back a JSON response, but it wasn't the format we expected
throw new ShipEngineException(
error?.Message ?? response.ReasonPhrase,
error?.ErrorSource ?? ErrorSource.Shipengine,
error?.ErrorType ?? ErrorType.System,
error?.ErrorCode ?? ErrorCode.Unspecified,
deserializedError.RequestId ?? requestId,
response
);
}

T? result;
try
{
result = JsonSerializer.Deserialize<T>(contentString, JsonSerializerOptions);
}
catch (JsonException)
{
throw new ShipEngineException("Unable to parse response", requestID: requestId, responseMessage: response);
}

var result = JsonSerializer.Deserialize<T>(contentString, JsonSerializerOptions);

if (result != null)
{
return result;
}

throw new ShipEngineException(message: "Unexpected Error");
throw new ShipEngineException(message: "Unexpected null response", requestID: requestId, responseMessage: response);
}


Expand All @@ -148,8 +167,7 @@ public virtual async Task<T> SendHttpRequestAsync<T>(HttpMethod method, string p
try
{
var request = BuildRequest(method, path, jsonContent);
var streamTask = client.SendAsync(request, CancellationToken);
response = await streamTask;
response = await client.SendAsync(request, CancellationToken);

var deserializedResult = await DeserializedResultOrThrow<T>(response);

Expand All @@ -159,17 +177,12 @@ public virtual async Task<T> SendHttpRequestAsync<T>(HttpMethod method, string p
{
if (e.ErrorCode != ErrorCode.RateLimitExceeded)
{
throw e;
throw;
}

requestException = e;
}

catch (Exception e)
{
throw e;
}


if (!ShouldRetry(retry, response?.StatusCode, response?.Headers, config))
{
Expand All @@ -180,14 +193,7 @@ public virtual async Task<T> SendHttpRequestAsync<T>(HttpMethod method, string p
await WaitAndRetry(response, config, requestException);
}

if (requestException != null)
{
throw requestException;
}
else
{
throw new ShipEngineException(message: "Unexpected Error");
}
throw requestException;
}

private async Task WaitAndRetry(HttpResponseMessage? response, Config config, ShipEngineException ex)
Expand Down

0 comments on commit 743d2d2

Please sign in to comment.