From 04e9238229807b7e9fb7fe8af5e8dd4ab54ffc9d Mon Sep 17 00:00:00 2001 From: Clement Gutel Date: Fri, 6 Nov 2026 09:10:35 +1100 Subject: [PATCH 1/2] Expose response body and GraphQL errors on GraphQLHttpException On a non-success HTTP status, read the response body (best effort) and expose it on GraphQLHttpException: - ResponseBody: the raw body, always available when non-empty - Response: a GraphQLResponse parsed from the body when it is JSON and looks like a GraphQL response (GraphQL over HTTP spec), reusing the existing GraphQLError model (message, locations, path, extensions) Failures while reading or parsing the body never replace the HTTP error, so retries and exception types are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../GraphQLHttpExceptionTests.cs | 115 ++++++++++++++++++ .../Client/Exceptions/GraphQLHttpException.cs | 47 ++++++- GraphQLSharp/Client/GraphQLClient.cs | 43 ++++++- 3 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs diff --git a/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs new file mode 100644 index 0000000..7525928 --- /dev/null +++ b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs @@ -0,0 +1,115 @@ +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 NonGraphQLJsonErrorKeepsRawBody() + { + //e.g. Shopify returns a plain string in "errors" for authentication failures + var body = """{"errors":"[API] Invalid API key or access token (unrecognized login or wrong password)"}"""; + 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); + StringAssert.Contains(ex.Message, "Invalid API key or access token"); + } + + [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)); From f0286ad745aa4f12a5fde0f36fdb09a25e6adbe0 Mon Sep 17 00:00:00 2001 From: Clement Gutel Date: Fri, 6 Nov 2026 09:38:17 +1100 Subject: [PATCH 2/2] Frame non-array errors as non-spec in tests Co-Authored-By: Claude Opus 5.5 --- GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs index 7525928..e9b5780 100644 --- a/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs +++ b/GraphQLSharp.Tests/GraphQLHttpExceptionTests.cs @@ -40,17 +40,16 @@ public async Task GraphQLErrorResponseIsParsed() } [TestMethod] - public async Task NonGraphQLJsonErrorKeepsRawBody() + public async Task NonArrayErrorsIsNotParsed() { - //e.g. Shopify returns a plain string in "errors" for authentication failures - var body = """{"errors":"[API] Invalid API key or access token (unrecognized login or wrong password)"}"""; + //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); - StringAssert.Contains(ex.Message, "Invalid API key or access token"); } [TestMethod]