Skip to content

transport: surface subsequent data when receiving non-gRPC header#8929

Open
chengxilo wants to merge 26 commits into
grpc:masterfrom
chengxilo:surface-response-body
Open

transport: surface subsequent data when receiving non-gRPC header#8929
chengxilo wants to merge 26 commits into
grpc:masterfrom
chengxilo:surface-response-body

Conversation

@chengxilo
Copy link
Copy Markdown
Contributor

@chengxilo chengxilo commented Feb 22, 2026

Fixes #7406

If content-type is not grpc, we read the next data frames till 1kb or endStream, and append the subsequent data to error message.

Example:

When receiving

[header]
":status", "200",
"content-type", "text/html",

[payload]
<html><body>Hello World</body></html>

Return

rpc error: code = Unknown desc = unexpected HTTP status code received from server: 200 (OK); transport: received unexpected content-type "text/html"
data: "<html><body>Hello World</body></html>"`,

RELEASE NOTES:

  • Surface data when receiving non-gRPC header, append it to the error message with this formet [error]\n data: "[data]"

@chengxilo chengxilo force-pushed the surface-response-body branch from d42d1a5 to 3dc8573 Compare February 22, 2026 21:53
@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 22, 2026

Codecov Report

❌ Patch coverage is 81.57895% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.14%. Comparing base (99312fe) to head (df306a0).
⚠️ Report is 40 commits behind head on master.

Files with missing lines Patch % Lines
internal/transport/http2_client.go 76.19% 4 Missing and 1 partial ⚠️
internal/transport/client_stream.go 88.23% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8929      +/-   ##
==========================================
+ Coverage   83.11%   83.14%   +0.02%     
==========================================
  Files         413      413              
  Lines       33135    33524     +389     
==========================================
+ Hits        27540    27872     +332     
- Misses       4190     4233      +43     
- Partials     1405     1419      +14     
Files with missing lines Coverage Δ
internal/transport/client_stream.go 96.77% <88.23%> (-3.23%) ⬇️
internal/transport/http2_client.go 92.34% <76.19%> (-0.52%) ⬇️

... and 62 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chengxilo chengxilo changed the title feat: surface response data when receiving an unexpected status code … feat: surface subsequent data when receiving non-gRPC header Feb 22, 2026
@chengxilo chengxilo changed the title feat: surface subsequent data when receiving non-gRPC header transport: surface subsequent data when receiving non-gRPC header Feb 23, 2026
@easwars easwars added Type: Behavior Change Behavior changes not categorized as bugs Area: Transport Includes HTTP/2 client/server and HTTP server handler transports and advanced transport features. labels Feb 24, 2026
Comment thread internal/transport/client_stream.go Outdated
Comment thread internal/transport/client_stream.go Outdated
@chengxilo chengxilo requested a review from easwars February 26, 2026 07:43
@chengxilo chengxilo marked this pull request as draft February 26, 2026 08:26
@chengxilo chengxilo marked this pull request as ready for review February 26, 2026 08:46
@chengxilo
Copy link
Copy Markdown
Contributor Author

chengxilo commented Feb 26, 2026

Sorry for request a review while a data race detected 🥲. It's solved now.

To clarify my previous commit:

…oseStream

Replace the `collecting` bool and `finalizeNonGRPCDataCollectionLocked`
method with a `nonGRPCStatus` field on ClientStream. The non-gRPC
status is now stored separately from the stream's `status` field and
finalized inline in closeStream, which eliminates the race on `status`
between Header() readers and closeStream writers.
…esponses

Do not close headerChan in operateHeaders when a non-gRPC response is
received. Instead, let closeStream close it when the stream finishes.
This ensures Header() only returns after the status is finalized,
eliminating the need for collectionMu in Header().
…er cases

The original TestHeaderChanClosedAfterReceivingAnInvalidHeader was
actually testing the non-gRPC response path (!isGRPC), not the
malformed header path (headerError). Split into two tests:
- TestHeaderChanClosedAfterReceivingNonGRPCResponse: verifies
  headerChan closes when the stream closes (via context timeout)
- TestHeaderChanClosedAfterReceivingAnInvalidHeader: verifies
  headerChan closes immediately via closeStream on the headerError path
@chengxilo chengxilo force-pushed the surface-response-body branch from f3fa621 to df12e44 Compare April 25, 2026 02:22
@chengxilo
Copy link
Copy Markdown
Contributor Author

chengxilo commented Apr 25, 2026

  • Add a new field in ClientStream, let's maybe call it nonGPRCStatus and protect it with collectingMu (√)
  • In startNonGRPCDataCollection, set the above newly added field (√)
  • Do not close the headerChan here (√)

I followed most of the suggestion but diverged on one point: instead of adding a closeStreamWithNonGRPCStatus method on ClientStream that finalizes the status and then calls t.closeStream, I inlined the finalization logic directly in closeStream. I added some comment to clarify it so I think it would be ok.... perhaps.

  • In handleData, when we have read enough of the non-grpc data and want to close the stream, instead of calling the closeStreamWithNonGRPCStatus on the transport:

    • call a new method on the ClientStream, maybe call it closeStreamWithNonGRPCStatus or something else

    • this new method will

      • grab collectionMu
      • it will update nonGPRCStatus with non-grpc data read from the stream
      • it will then call t.closeStream and pass it the nonGPRCStatus
      • this will result in the stream closing cleanly, while respecting the existing synchronization guarantees about access to the status field
      • this will also mean that we will no longer need finalizeNonGRPCDataCollectionLocked method

The reason is that closeStream can be called from multiple paths, not just from handleData when non-gRPC data collection completes, but also from context cancellation, transport errors, etc. When closeStream is called for these reasons, it will still finalize the collected non-gRPC data into the status and return to the user.

The race on the status field between Header() readers and closeStream writers is addressed by not closing headerChan in operateHeaders because of non-GRPC header anymore. Header() now only returns after closeStream has finished writing s.status, so no lock is needed in Header().

Also I removed collecting, as whether nonGRPCStatus is nil or not can tell us whether we are collecting the non-gRPC data or not.

@chengxilo chengxilo requested a review from easwars April 25, 2026 02:38
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 2, 2026

This PR is labeled as requiring an update from the reporter, and no update has been received after 6 days. If no update is provided in the next 7 days, this issue will be automatically closed.

Comment thread test/end2end_test.go Outdated
Comment on lines +6871 to +6872
wantCode: codes.Unavailable,
wantErr: `rpc error: code = Unavailable desc = unexpected HTTP status code received from server: 502 (Bad Gateway); malformed header: missing HTTP content-type
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Since we are also checking the returned status with wantCode, we can skip the rpc error: code = Unavailable desc part in wantErr, and instead of performing a strict equality check err.Error() != test.wantErr, you could do a strings.Contains()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I skip the rpc error: code = Unavailable desc part in wantErr now.

However I did't use strings.Contains() because it's too loose for this test case:

	{
			name: "non-gRPC content-type with bytes payload length more than nonGRPCDataMaxLen",
			responses: []httpServerResponse{
				{
					headers: [][]string{
						{
							":status", "200",
							"content-type", "text/html",
						},
					},
					payload: bytes.Repeat([]byte("a"), nonGRPCDataMaxLen+1),
				},
			},
			wantCode: codes.Unknown,
			wantErr: `rpc error: code = Unknown desc = unexpected HTTP status code received from server: 200 (OK); transport: received unexpected content-type "text/html"
data: ` + strconv.Quote(strings.Repeat("a", nonGRPCDataMaxLen)),
		},

When the payload exceeds nonGRPCDataMaxLen, we expect the data to be truncated to exactly 1024 bytes. But strings.Contains() would pass even if truncation were broken, since a string of 1024 "a"s is always a substring of 1025 "a"s, the test would silently pass with untruncated data.

Comment thread test/end2end_test.go Outdated
@easwars easwars removed their assignment May 4, 2026
@easwars
Copy link
Copy Markdown
Contributor

easwars commented May 4, 2026

@arjan-bal : Over to you for second review.

headerValid bool
headerValid bool

collectionMu sync.Mutex // protects nonGRPCStatus and nonGRPCDataBuf during the non-gRPC data collection lifecycle.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may not need the mutex here. The three places where the nonGRPC fields are accessed are:

  1. operateHeaders
  2. handleData
  3. closeStream

Methods 1 and 2 are called serially by the goroutine running the controlbuf loop.

Method 3 reads the fields to overrides the arguments passed to it. This seems undesirable because it ignores the status passed by the caller.

The way to gracefully close streams in HTTP/2 is by using the "end of stream" (EOS) flag in the frame header. Since this flag is only present in HEADERS and DATA frames, we should have operateHeaders and handleData check the nonGRPCStatus, call closeStream with the resolved status, and remove the extra logic from closeStream. This approach should also eliminate the need for the mutex.

@arjan-bal arjan-bal assigned chengxilo and unassigned arjan-bal May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: Transport Includes HTTP/2 client/server and HTTP server handler transports and advanced transport features. Type: Behavior Change Behavior changes not categorized as bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface response body when receiving an unexpected status code and content-type

5 participants