Repository navigation
fix(graphql): log JSON encode failure in respondWithErrors (#4256) - #4268
aryanmehrotra merged 4 commits into
Conversation
…4256) respondWithErrors discarded the error returned by json.Encoder.Encode, so a failure to write the GraphQL error body (e.g. client disconnected) left no trace. Log it via the container logger, matching the existing success-path log in the same file. Also add the missing blank line before GetHandler. Tests cover the three real error responses (500/415/400), the write-failure log, and the previously untested 415 and 400 paths through Handle.
aryanmehrotra
left a comment
There was a problem hiding this comment.
The change itself is right — Errorf not Error, matching the pattern 50 lines above it, and it takes respondWithErrors from 0% to 100% coverage with handleGraphQLRequest going 79.3% → 96.6%. The mutation test is clean: reverting to _ = json.NewEncoder(w).Encode(...) fails the new test.
Worth calling out that you used Errorf without being prompted. I captured both against the production JSON logger:
Errorf → {"level":"ERROR","message":"error encoding GraphQL error response: write failed"}
Error → {"level":"ERROR","message":{}}
The second is the trap — the logger JSON-marshals a bare error value and errors.errorString has no exported fields, so the text is lost. You're on the right side of it.
Requesting changes for the merge collision, and one correction to the description.
1. This will not compile once #4167 lands
#4167 (perf/optional-datasources-graphql) makes GraphQL optional behind gofr_nographql and changes App.graphqlManager from *graphQLManager to a graphQLRunner interface whose methods are RegisterQuery, RegisterMutation, buildSchema, GetHandler — Handle is not on it. That's why it rewrites the eight existing app.graphqlManager.Handle(...) call sites to gqlManager(t, app).Handle(...).
This PR's new TestGraphQL_RequestErrors adds a ninth at graphql_test.go:535. I counted: nine call sites here, eight converted there.
There's also a trivial text conflict at the top of graphql_test.go (that PR's gqlManager helper vs this PR's errWriter) — resolution is "keep both". graphql.go auto-merges cleanly.
Whichever lands second needs the one-line change; flagging so it isn't a surprise. Nothing to do now beyond being aware.
2. The description claims a failure mode this can't catch
The body says the encode can fail "for example, the client disconnected". I don't think that case is reachable here. json.Encoder.Encode marshals fully into its own buffer and issues a single Write (encoding/json/stream.go:225-233), and net/http hands the handler a 2048-byte bufio.Writer (net/http/server.go:341, allocated :1071) that isn't flushed until finishRequest (:1671). The largest of the three bodies here is 65 bytes, so the write never reaches the socket inside the handler.
I tested it with a client that RSTs the connection before the handler writes:
65-byte body Encode() err = <nil>
>2048-byte body Encode() err = write tcp ...: write: broken pipe
The marshal half is closed too — the value is a literal map[string]any holding one of three compile-time strings, so there's no channel, func, cycle or float that could fail.
I'm happy with the change as hygiene and for consistency with line 464. Could you just reword the description so it doesn't claim the disconnect case? And a comment on errWriter saying it's a synthetic failure rather than a reproduction of a real disconnect would stop the next reader concluding the production path is covered.
3. Please drop the AI-attribution footer
The PR body carries a 🤖 Generated with [Claude Code] line. This repo's convention is that merged history attributes only the human author.
Two things for context, not for this PR
- GoFr's house pattern is the other way round.
pkg/gofr/http/responder.go:134-146encodes into a buffer first and only commits the status once the encode succeeded, so an encode error produces a clean 500 instead of a truncated 200. Here that costs nothing (marshal can't fail on a literal), so I wouldn't change it. But if you do the follow-up you offered formiddleware/logger.go:345andai/mcp/jsonrpc.go:71,responder.gois the shape worth copying rather than this one. - The panic path already sends a broken response, and this PR doesn't change that.
respondWithErrorsis also reached from the recover atgraphql.go:401-406, by which pointhandleGraphQLRequestmay have sent a 200 (:462) and a full body (:464), with its deferred metrics/span block (:434-445) running after. TheWriteHeader(500)is then dropped — silently, becauseStatusResponseWriter.WriteHeadershort-circuits atmiddleware/logger.go:41-43and never reaches net/http's "superfluous WriteHeader" warning — and the client gets 200 with two concatenated JSON documents. Pre-existing and out of scope, but the description's "status and headers are already committed, so logging is the only meaningful action" reads as if that case were fine. Worth a separate issue?
No leaks — the three messages are compile-time constants, and the only variable content is the transport error text, which is a client IP:port the access log already records.
Verified: gofmt clean, go vet clean, all 16 CI checks green, golangci-lint identical count and breakdown to origin/development (0 introduced). The two local failures under -race and -count=5 (websocket.go:47 race, port-2121 probe) reproduce on development.
- TestGraphQL_RequestErrors now calls graphqlManager.GetHandler().ServeHTTP instead of Handle, so it keeps compiling once App.graphqlManager becomes the graphQLRunner interface (gofr-dev#4167), which has GetHandler but not Handle. - Reword the errWriter comment: it is a synthetic write failure used to exercise the logging path, not a reproduction of a client disconnect, since net/http buffers these small bodies.
|
Thanks for the thorough review, especially the bufio analysis. I hadn't traced the write that far. Pushed 60988e1:
Noted on |
aryanmehrotra
left a comment
There was a problem hiding this comment.
All three addressed, and I verified each by execution rather than by reading.
The #4167 collision is resolved and the merged tree is green. I test-merged this into perf/optional-datasources-graphql (2139269c3) and ran it both ways:
| check | result |
|---|---|
go build ./... merged |
PASS |
go build -tags gofr_nographql ./... |
PASS |
go test ./pkg/gofr/ -count=1 merged |
ok 7.069s |
go test -tags gofr_nographql ./pkg/gofr/ -count=1 |
ok 8.498s |
| graphql tests on the merged tree | 14 tests / 20 subtests, all pass |
Zero app.graphqlManager.Handle( remain; routing through GetHandler().ServeHTTP puts it on the graphQLRunner interface, which is exactly right. GetHandler() is http.HandlerFunc(m.Handle) (graphql.go:527-529), so the executed path is identical — and GetHandler now reaches 100% itself. Coverage held: respondWithErrors 0% → 100%, handleGraphQLRequest 79.3% → 96.6%, package 88.7% → 89.1%.
One trivial hunk still conflicts — graphql_test.go:24-55, where both sides insert a top-level block right after the imports (errWriteFailed/errWriter here, gqlManager() there). Resolution is "keep both", in either order; that's what produced the green results above. Whichever of the two lands second owns it, and I'll flag it on #4167 too so neither of you is surprised.
The fix is pinned. Reverting respondWithErrors to _ = json.NewEncoder(w).Encode(...) with the tests untouched:
--- FAIL: Test_respondWithErrors_WriteFailureIsLogged
graphql_test.go:502: "" does not contain "error encoding GraphQL error response"
graphql_test.go:503: "" does not contain "write failed"
Errorf confirmed against the production logger, not the mock:
Errorf → {"level":"ERROR","message":"error encoding GraphQL error response: write failed","gofrVersion":"dev"}
Error → {"level":"ERROR","message":{},"gofrVersion":"dev"}The body is accurate now. You retracted the disconnect claim rather than softening it, which is what I was hoping for — and the "≤ 65 bytes" figure checks out exactly: {"errors":[{"message":"Content-Type must be application/json"}]}\n is 65 bytes, the largest of the three. The errWriter comment says plainly that it's synthetic and not a disconnect reproduction, with the reason. Attribution footer gone from the body and from both commit messages.
Two notes, neither blocking:
- Your first commit message (
8b9122d32) still carries the "e.g. client disconnected" wording that the body now retracts. This repo squash-merges — I checked the last twelve merges intodevelopmentand every one is a single-parent commit titled with the PR title — so the PR body is what lands in history and the stale message never reaches it. Flagging only so you know it isn't being overlooked. - Please do open the follow-up you offered.
pkg/gofr/http/middleware/logger.go:345andpkg/gofr/ai/mcp/jsonrpc.go:71both still carry the discarded-encode pattern. If you take it up,pkg/gofr/http/responder.go:134-146is the shape worth copying — buffer first, commit the status only once the encode succeeded — rather than this one, which is right here only because the value is a literal that can't fail to marshal.
Gates: gofmt clean, go vet clean, golangci-lint identical to development (0 introduced — the only delta is the pre-existing swagger.go:33 goconst counter going 14 → 15). The -race and -count=5 failures reproduce byte-identically on development (the websocket.go:47 ↔ handler.go:190 race and the port-2121 isPortAvailable probe). The new tests are race-clean in isolation.
Description:
Fixes #4256.
graphQLManager.respondWithErrorsdiscarded the error fromjson.NewEncoder(w).Encode(...). Logging it makes the error path consistent with the success path in the same file, which already logs its encode error.This is defensive rather than a fix for an observed failure. The body is a literal
map[string]anyholding one of three constant strings, so marshalling can't fail, and net/http buffers a body this small (≤ 65 bytes), so a client disconnect doesn't surface as a write error inside the handler either. The log only fires if theResponseWriteritself returns an error fromWrite.Changes:
respondWithErrorsnow checks the encode error and logs it via the container logger:error encoding GraphQL error response: <err>. This mirrors the existing success-path log in the same file (error encoding GraphQL response: %v, Error level).GetHandler.Tests (
pkg/gofr/graphql_test.go):Test_respondWithErrors: the three real error responses (500 / 415 / 400) produce the right status,Content-Type, and{"errors":[{"message": ...}]}body, and nothing is logged.Test_respondWithErrors_WriteFailureIsLogged: with a syntheticResponseWriterwhoseWritefails, the error is logged (this test fails on the old code). It exercises the logging path; it isn't a reproduction of a real disconnect.TestGraphQL_RequestErrors: covers the previously untested 415 (wrong Content-Type) and 400 (invalid JSON body) paths end-to-end throughGetHandler(), so it stays compatible with perf(deps): let a build omit the datasource drivers and GraphQL engine it does not use #4167'sgraphQLRunnerinterface.Breaking Changes (if applicable):
None.
Additional Information:
No new dependencies. Out of scope, but the same
_ = json.NewEncoder(w).Encode(...)pattern exists inpkg/gofr/http/middleware/logger.go(panic recovery) andpkg/gofr/ai/mcp/jsonrpc.go(writeJSON); happy to follow up if desired.Checklist:
goimportandgolangci-lint.