Repository: Khan/genqlient
Pull request #366 (2024-12-02 18:04:07)

HTTPError wraps full Response for typed output
Follow-up to https://github.com/Khan/genqlient/pull/363. Go one step further lets HTTPError wrap Response to get fully typed error data.



I have:
- [x] Written a clear PR title and description (above)
- [x] Signed the [Khan Academy CLA](https://www.khanacademy.org/r/cla)
- [x] Added tests covering my changes, if applicable
- [x] Included a link to the issue fixed, if applicable
- [x] Included documentation, for new features
- [x] Added an entry to the changelog


HaraldNordgren: Ping @benjaminjkraft 
HaraldNordgren: Ping @benjaminjkraft 

Patch:
diff --git a/graphql/client.go b/graphql/client.go
index 8278759..fb0c759 100644
--- a/graphql/client.go
+++ b/graphql/client.go
@@ -265,9 +265,20 @@ func (c *client) MakeRequest(ctx context.Context, req *Request, resp *Response)
 		if err != nil {
 			respBody = []byte(fmt.Sprintf("<unreadable: %v>", err))
 		}
+
+		var gqlResp Response
+		if err = json.Unmarshal(respBody, &gqlResp); err != nil {
+			return &HTTPError{
+				Response: Response{
+					Errors: gqlerror.List{&gqlerror.Error{Message: string(respBody)}},
+				},
+				StatusCode: httpResp.StatusCode,
+			}
+		}
+
 		return &HTTPError{
+			Response:   gqlResp,
 			StatusCode: httpResp.StatusCode,
-			Body:       string(respBody),
 		}
 	}
 
diff --git a/graphql/errors.go b/graphql/errors.go
index d346cd1..bc91007 100644
--- a/graphql/errors.go
+++ b/graphql/errors.go
@@ -1,14 +1,22 @@
 package graphql
 
-import "fmt"
+import (
+	"encoding/json"
+	"fmt"
+)
 
-// HTTPError represents an HTTP error with status code and response body.
+// HTTPError represents an HTTP error with status coqgqde and response body.
 type HTTPError struct {
-	Body       string
+	Response   Response
 	StatusCode int
 }
 
 // Error implements the error interface for HTTPError.
 func (e *HTTPError) Error() string {
-	return fmt.Sprintf("returned error %v: '%s'", e.StatusCode, e.Body)
+	jsonBody, err := json.Marshal(e.Response)
+	if err != nil {
+		return fmt.Sprintf("returned error %v: '%s'", e.StatusCode, e.Response)
+	}
+
+	return fmt.Sprintf("returned error %v: %s", e.StatusCode, jsonBody)
 }


Test patch:
diff --git a/graphql/client_test.go b/graphql/client_test.go
index 5eef22c..8875ed7 100644
--- a/graphql/client_test.go
+++ b/graphql/client_test.go
@@ -9,36 +9,57 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/vektah/gqlparser/v2/gqlerror"
 )
 
 func TestMakeRequest_HTTPError(t *testing.T) {
 	testCases := []struct {
+		expectedError      *HTTPError
+		serverResponseBody any
 		name               string
-		serverResponseBody string
-		expectedErrorBody  string
 		serverResponseCode int
-		expectedStatusCode int
 	}{
 		{
-			name:               "400 Bad Request",
-			serverResponseBody: "Bad Request",
-			expectedErrorBody:  "Bad Request",
+			name:               "plain_text_error",
 			serverResponseCode: http.StatusBadRequest,
-			expectedStatusCode: http.StatusBadRequest,
+			serverResponseBody: "Bad Request",
+			expectedError: &HTTPError{
+				Response: Response{
+					Errors: gqlerror.List{
+						&gqlerror.Error{
+							Message: "\"Bad Request\"\n",
+						},
+					},
+				},
+				StatusCode: http.StatusBadRequest,
+			},
 		},
 		{
-			name:               "429 Too Many Requests",
-			serverResponseBody: "Rate limit exceeded",
-			expectedErrorBody:  "Rate limit exceeded",
+			name:               "json_error_with_extensions",
 			serverResponseCode: http.StatusTooManyRequests,
-			expectedStatusCode: http.StatusTooManyRequests,
-		},
-		{
-			name:               "500 Internal Server Error",
-			serverResponseBody: "Internal Server Error",
-			expectedErrorBody:  "Internal Server Error",
-			serverResponseCode: http.StatusInternalServerError,
-			expectedStatusCode: http.StatusInternalServerError,
+			serverResponseBody: Response{
+				Errors: gqlerror.List{
+					&gqlerror.Error{
+						Message: "Rate limit exceeded",
+						Extensions: map[string]interface{}{
+							"code": "RATE_LIMIT_EXCEEDED",
+						},
+					},
+				},
+			},
+			expectedError: &HTTPError{
+				Response: Response{
+					Errors: gqlerror.List{
+						&gqlerror.Error{
+							Message: "Rate limit exceeded",
+							Extensions: map[string]interface{}{
+								"code": "RATE_LIMIT_EXCEEDED",
+							},
+						},
+					},
+				},
+				StatusCode: http.StatusTooManyRequests,
+			},
 		},
 	}
 
@@ -46,7 +67,7 @@ func TestMakeRequest_HTTPError(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.WriteHeader(tc.serverResponseCode)
-				_, err := w.Write([]byte(tc.serverResponseBody))
+				err := json.NewEncoder(w).Encode(tc.serverResponseBody)
 				if err != nil {
 					t.Fatalf("Failed to write response: %v", err)
 				}
@@ -64,8 +85,7 @@ func TestMakeRequest_HTTPError(t *testing.T) {
 			assert.Error(t, err)
 			var httpErr *HTTPError
 			assert.True(t, errors.As(err, &httpErr), "Error should be of type *HTTPError")
-			assert.Equal(t, tc.expectedStatusCode, httpErr.StatusCode)
-			assert.Equal(t, tc.expectedErrorBody, httpErr.Body)
+			assert.Equal(t, tc.expectedError, httpErr)
 		})
 	}
 }