diff --git a/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs new file mode 100644 index 0000000..e9b5780 --- /dev/null +++ b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs @@ -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(() => 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(() => 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(() => client.ExecuteAsync(Query)); + + Assert.IsNull(ex.Response); + Assert.AreEqual(body, ex.ResponseBody); + } + + [TestMethod] + public async Task NonJsonBodyIsNotParsedAndTruncatedInMessage() + { + var body = $"{new string('x', 5000)}"; + var client = CreateClient(HttpStatusCode.BadGateway, body, "text/html"); + + var ex = await Assert.ThrowsExceptionAsync(() => 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(() => 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(), + 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(() => new GraphQLCLient(options).ExecuteAsync(Query)); + + Assert.AreEqual(RetryInterceptor.MAX_TRIES, tries); + Assert.AreEqual("Service unavailable", ex.Response.errors[0].message); + } +} diff --git a/GraphQLSharp/Client/Exceptions/GraphQLHttpException.cs b/GraphQLSharp/Client/Exceptions/GraphQLHttpException.cs index c88e49f..62952b3 100644 --- a/GraphQLSharp/Client/Exceptions/GraphQLHttpException.cs +++ b/GraphQLSharp/Client/Exceptions/GraphQLHttpException.cs @@ -2,11 +2,50 @@ namespace GraphQLSharp; public class GraphQLHttpException : GraphQLException { + private const int MaxResponseBodyLengthInMessage = 1000; + + /// + /// Raw body of the non-success response, or null when it was empty or could not be read. + /// + public readonly string ResponseBody; + + /// + /// GraphQL response parsed from the body when the server returned a JSON GraphQL response + /// (e.g. a 400 with {"errors":[{"message":...}]} 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 . + /// + 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; } -} \ No newline at end of file +} diff --git a/GraphQLSharp/Client/GraphQLClient.cs b/GraphQLSharp/Client/GraphQLClient.cs index e986605..310c194 100644 --- a/GraphQLSharp/Client/GraphQLClient.cs +++ b/GraphQLSharp/Client/GraphQLClient.cs @@ -102,7 +102,8 @@ private async Task> ExecuteCoreAsync(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 res; @@ -131,6 +132,46 @@ private async Task> ExecuteCoreAsync(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(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));