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)