Skip to content
Merged
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
114 changes: 114 additions & 0 deletions GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Net;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GraphQLSharp.Tests;

[TestClass]
public class GraphQLHttpExceptionTests
{
private const string Query = "{ shop { id } }";

private static GraphQLCLient CreateClient(HttpStatusCode statusCode, string body, string mediaType)
{
return new GraphQLCLient(new GraphQLClientOptions(new Uri("https://example.com/graphql"))
{
HttpClient = new TestHttpClient(_ => new HttpResponseMessage(statusCode)
{
Content = new StringContent(body, Encoding.UTF8, mediaType)
})
});
}

[TestMethod]
public async Task GraphQLErrorResponseIsParsed()
{
var client = CreateClient(HttpStatusCode.BadRequest,
"""{"errors":[{"message":"Field 'foo' doesn't exist","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}""",
"application/graphql-response+json");

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => client.ExecuteAsync(Query));

Assert.AreEqual(HttpStatusCode.BadRequest, ex.HttpResponse.StatusCode);
Assert.IsNotNull(ex.Response);
Assert.AreEqual(1, ex.Response.errors.Count);
Assert.AreEqual("Field 'foo' doesn't exist", ex.Response.errors[0].message);
Assert.AreEqual("GRAPHQL_VALIDATION_FAILED", ex.Response.errors[0].extensions["code"].GetString());
Assert.AreSame(ex.Request, ex.Response.Request);
Assert.AreSame(ex.HttpResponse, ex.Response.HttpResponse);
StringAssert.Contains(ex.Message, "Field 'foo' doesn't exist");
}

[TestMethod]
public async Task NonArrayErrorsIsNotParsed()
{
//the spec requires "errors" to be a list of error objects, anything else is not a GraphQL response
var body = """{"errors":"Invalid access token"}""";
var client = CreateClient(HttpStatusCode.Unauthorized, body, "application/json");

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => client.ExecuteAsync(Query));

Assert.IsNull(ex.Response);
Assert.AreEqual(body, ex.ResponseBody);
}

[TestMethod]
public async Task JsonWithoutGraphQLShapeIsNotParsed()
{
var body = """{"error":"Not found"}""";
var client = CreateClient(HttpStatusCode.NotFound, body, "application/json");

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => client.ExecuteAsync(Query));

Assert.IsNull(ex.Response);
Assert.AreEqual(body, ex.ResponseBody);
}

[TestMethod]
public async Task NonJsonBodyIsNotParsedAndTruncatedInMessage()
{
var body = $"<html><body>{new string('x', 5000)}</body></html>";
var client = CreateClient(HttpStatusCode.BadGateway, body, "text/html");

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => client.ExecuteAsync(Query));

Assert.IsNull(ex.Response);
Assert.AreEqual(body, ex.ResponseBody);
StringAssert.Contains(ex.Message, "(truncated)");
Assert.IsTrue(ex.Message.Length < body.Length);
}

[TestMethod]
public async Task EmptyBody()
{
var client = CreateClient(HttpStatusCode.ServiceUnavailable, "", "text/plain");

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => client.ExecuteAsync(Query));

Assert.IsNull(ex.Response);
Assert.IsNull(ex.ResponseBody);
}

[TestMethod]
public async Task ErrorBodyStillRetried()
{
int tries = 0;
var options = new GraphQLClientOptions(new Uri("https://example.com/graphql"))
{
Interceptor = new RetryInterceptor<GraphQLRequest, GraphQLClientOptions>(),
HttpClient = new TestHttpClient(_ =>
{
tries++;
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("""{"errors":[{"message":"Service unavailable"}]}""", Encoding.UTF8, "application/json")
};
})
};

var ex = await Assert.ThrowsExceptionAsync<GraphQLHttpException>(() => new GraphQLCLient(options).ExecuteAsync(Query));

Assert.AreEqual(RetryInterceptor<GraphQLRequest, GraphQLClientOptions>.MAX_TRIES, tries);
Assert.AreEqual("Service unavailable", ex.Response.errors[0].message);
}
}
47 changes: 43 additions & 4 deletions GraphQLSharp/Client/Exceptions/GraphQLHttpException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,50 @@ namespace GraphQLSharp;

public class GraphQLHttpException : GraphQLException
{
private const int MaxResponseBodyLengthInMessage = 1000;

/// <summary>
/// Raw body of the non-success response, or null when it was empty or could not be read.
/// </summary>
public readonly string ResponseBody;

/// <summary>
/// GraphQL response parsed from the body when the server returned a JSON GraphQL response
/// (e.g. a 400 with <c>{"errors":[{"message":...}]}</c> as described by the GraphQL over HTTP spec), otherwise null.
/// Bodies that are not GraphQL responses (proxy error pages, vendor-specific error shapes) are only available in <see cref="ResponseBody"/>.
/// </summary>
public readonly GraphQLResponse Response;

public GraphQLHttpException(GraphQLRequest request, HttpResponse httpResponse, Exception ex)
: base(request, httpResponse, $"""
: this(request, httpResponse, ex, responseBody: null, response: null)
{
}

public GraphQLHttpException(GraphQLRequest request, HttpResponse httpResponse, Exception ex, string responseBody, GraphQLResponse response)
: base(request, httpResponse, BuildMessage(request, responseBody, response), ex)
{
ResponseBody = responseBody;
Response = response;
}

private static string BuildMessage(GraphQLRequest request, string responseBody, GraphQLResponse response)
{
var message = $"""
GraphQL request failed with an HTTP error:
Request: {request}
""", ex)
{
""";

if (response?.errors?.Count > 0)
return $"{message}\nErrors: {string.Join("\n", response.errors.Select(e => e.ToString()))}";

if (!string.IsNullOrEmpty(responseBody))
{
var body = responseBody.Length <= MaxResponseBodyLengthInMessage
? responseBody
: $"{responseBody[..MaxResponseBodyLengthInMessage]}... (truncated)";
return $"{message}\nResponse body: {body}";
}

return message;
}
}
}
43 changes: 42 additions & 1 deletion GraphQLSharp/Client/GraphQLClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ private async Task<GraphQLResponse<T>> ExecuteCoreAsync<T>(TRequest request, Can
}
catch (Exception httpEx)
{
throw new GraphQLHttpException(request, httpResponse, httpEx);
var (responseBody, errorResponse) = await ReadErrorResponseAsync(request, httpResponse, httpResponseMsg.Content, cancellationToken);
throw new GraphQLHttpException(request, httpResponse, httpEx, responseBody, errorResponse);
}

GraphQLResponse<T> res;
Expand Down Expand Up @@ -131,6 +132,46 @@ private async Task<GraphQLResponse<T>> ExecuteCoreAsync<T>(TRequest request, Can
}
}

// Best effort: the body of a non-success response is only used to enrich GraphQLHttpException.
// Any failure here (I/O, cancellation, invalid JSON...) must not replace the HTTP error, which drives retries.
private async Task<(string responseBody, GraphQLResponse response)> ReadErrorResponseAsync(TRequest request, HttpResponse httpResponse, HttpContent content, CancellationToken cancellationToken)
{
string responseBody;
try
{
responseBody = await content.ReadAsStringAsync(cancellationToken);
}
catch
{
return (null, null);
}

if (string.IsNullOrWhiteSpace(responseBody))
return (null, null);

// Per the GraphQL over HTTP spec, a non-2xx body may come from an intermediary (proxy, gateway...)
// so it is only parsed when it is JSON, and only kept when it actually looks like a GraphQL response.
var mediaType = content.Headers.ContentType?.MediaType;
if (!string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(mediaType, "application/graphql-response+json", StringComparison.OrdinalIgnoreCase))
return (responseBody, null);

try
{
var response = JsonSerializer.Deserialize<GraphQLResponse>(responseBody, _options.JsonSerializerOptions ?? Serializer.GetOptions());
if (response?.errors == null && response?.data == null)
return (responseBody, null);

response.Request = request;
response.HttpResponse = httpResponse;
return (responseBody, response);
}
catch
{
return (responseBody, null);
}
}

private HttpRequestMessage CreateHttpRequest(TRequest request, HttpClient httpClient)
{
_ = request.query ?? throw new ArgumentNullException(nameof(request.query));
Expand Down
Loading