Skip to content

Commit

Permalink
Merge branch 'main' into error-handling
Browse files Browse the repository at this point in the history
  • Loading branch information
jpill authored Jun 28, 2024
2 parents 3550b6e + 743d2d2 commit de447c4
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 39 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;
}
}
}
80 changes: 75 additions & 5 deletions ShipEngine.Tests/ShipEngineClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace ShipEngineTest
public class ShipEngineClientTests
{
[Fact]
public async Task FailureWithShipengineResponseThrowsPopulatedShipEngineException()
public async Task FailureStatusWithShipengineContentThrowsPopulatedShipEngineException()
{
var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5));
var mockShipEngineFixture = new MockShipEngineFixture(config);
Expand Down Expand Up @@ -46,24 +46,94 @@ public async Task FailureWithShipengineResponseThrowsPopulatedShipEngineExceptio
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 FailureWithoutShipengineResponseThrowsHttpException()
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>";
mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.BadGateway,
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<HttpRequestException>(
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);
}

Assert.Contains("502", ex.Message);
[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);
}
}
}
64 changes: 31 additions & 33 deletions ShipEngine/ShipEngineClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ 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)
{
Expand All @@ -101,36 +107,41 @@ private async Task<T> DeserializedResultOrThrow<T>(HttpResponseMessage response)
{
}

// Throw Generic HttpClient Error if unable to deserialize to a ShipEngineException
if (deserializedError == null)
{
response.EnsureSuccessStatusCode();
// in this case, the response body was not parseable JSON
throw new ShipEngineException("Unexpected HTTP status", requestID: requestId, responseMessage: response);
}

var error = deserializedError?.Errors[0];

if (error != null && error.Message != null && deserializedError?.RequestId != null)
{
throw new ShipEngineException(
error.Message,
error.ErrorSource,
error.ErrorType,
error.ErrorCode,
deserializedError.RequestId,
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 @@ -156,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 @@ -167,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 @@ -188,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 de447c4

Please sign in to comment.