diff --git a/ShipEngine.Tests/Helpers/MockShipEngineFixture.cs b/ShipEngine.Tests/Helpers/MockShipEngineFixture.cs index bfd91425..d5fca799 100644 --- a/ShipEngine.Tests/Helpers/MockShipEngineFixture.cs +++ b/ShipEngine.Tests/Helpers/MockShipEngineFixture.cs @@ -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; @@ -74,10 +75,13 @@ public void AssertRequest(HttpMethod method, string path, int numberOfCalls = 1) /// The HTTP path. /// The status code to return. /// The response body to return. - 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>( @@ -87,6 +91,7 @@ public void StubRequest(HttpMethod method, string path, HttpStatusCode status, s m.RequestUri.AbsolutePath == path), ItExpr.IsAny()) .Returns(Task.FromResult(responseMessage)); + return requestId; } } } \ No newline at end of file diff --git a/ShipEngine.Tests/ShipEngineClientTests.cs b/ShipEngine.Tests/ShipEngineClientTests.cs index cbff270e..844b0e93 100644 --- a/ShipEngine.Tests/ShipEngineClientTests.cs +++ b/ShipEngine.Tests/ShipEngineClientTests.cs @@ -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); @@ -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( + async () => await shipengine.SendHttpRequestAsync(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 = @"

Bad Gateway

"; - 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( + async () => await shipengine.SendHttpRequestAsync(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( + var ex = await Assert.ThrowsAsync( async () => await shipengine.SendHttpRequestAsync(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( + async () => await shipengine.SendHttpRequestAsync(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); } } } \ No newline at end of file diff --git a/ShipEngine/ShipEngineClient.cs b/ShipEngine/ShipEngineClient.cs index c76ac950..9a7ee8b9 100644 --- a/ShipEngine/ShipEngineClient.cs +++ b/ShipEngine/ShipEngineClient.cs @@ -88,6 +88,12 @@ public static HttpClient ConfigureHttpClient(HttpClient client, string apiKey, U private async Task DeserializedResultOrThrow(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) { @@ -101,36 +107,41 @@ private async Task DeserializedResultOrThrow(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(contentString, JsonSerializerOptions); + } + catch (JsonException) + { + throw new ShipEngineException("Unable to parse response", requestID: requestId, responseMessage: response); } - var result = JsonSerializer.Deserialize(contentString, JsonSerializerOptions); if (result != null) { return result; } - throw new ShipEngineException(message: "Unexpected Error"); + throw new ShipEngineException(message: "Unexpected null response", requestID: requestId, responseMessage: response); } @@ -156,8 +167,7 @@ public virtual async Task SendHttpRequestAsync(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(response); @@ -167,17 +177,12 @@ public virtual async Task SendHttpRequestAsync(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)) { @@ -188,14 +193,7 @@ public virtual async Task SendHttpRequestAsync(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)