Skip to content

Fix CallInfo header/trailer propagation on error responses#892

Merged
emcfarlane merged 10 commits intomainfrom
ed/propagateErrMeta
Oct 7, 2025
Merged

Fix CallInfo header/trailer propagation on error responses#892
emcfarlane merged 10 commits intomainfrom
ed/propagateErrMeta

Conversation

@emcfarlane
Copy link
Contributor

@emcfarlane emcfarlane commented Sep 30, 2025

This fixes CallInfo to properly propagate headers and trailers when handlers return errors, and makes error metadata accessible through CallInfo on the client side.

Headers/trailers set via CallInfo.ResponseHeader() and CallInfo.ResponseTrailer() in handlers are now merged into the connection before error responses are sent.

Fixes #885

This fixes CallInfo to propagate headers and trailers on an error
response and to receive error metadata as trailers.

Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Copy link
Member

@jhump jhump left a comment

Choose a reason for hiding this comment

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

I don't think you need to do anything special with err.Meta(). Did the tests fail until you added that? If so, I think that's worth more exploration.

context.go Outdated
Comment on lines +263 to +274
combined := make(http.Header)
if w.base != nil {
for k, v := range w.base.ResponseTrailer() {
combined[k] = v
}
}
if w.err != nil {
for k, v := range w.err.Meta() {
combined[k] = v
}
}
return combined
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't seem right. On the client side, we already get the response headers and trailers from the underlying streaming conn and then use those to populate err.Meta(). Won't this duplicate metadata? Also, err.Meta() is not able to distinguish between headers and trailers, while the underlying connection is, so if this does not duplicate metadata, I think we have a problem that headers sent by the server will get mashed together in the error metadata and then incorrectly reported by the client as trailers.

Copy link
Contributor Author

@emcfarlane emcfarlane Oct 1, 2025

Choose a reason for hiding this comment

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

I've reworked this. Error metadata is not re-added to the trailers. This was duplicated for the grpc/grpcWeb case, but connect always returned the metadata as headers in the unary case. I've updated the docs for Error.Meta as this stated:

Metadata attached to errors returned by unary handlers is always sent as HTTP headers, regardless of the protocol.

Which disagrees with the implementation comments in protocol_grpc.go. This is maybe odd? But I wasn't sure if changing this behaviour would be a non-breaking change.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this is a bit muddled. I think we ended up here because the gRPC spec didn't (and still doesn't) make it clear when trailers-only responses are used. So the worry is that implementations could arbitrarily choose to use them and mash headers and trailers together in the process.

Importantly, there are no remarks in the spec about what servers should do and, in particular, no remarks in the spec to the effect of "if handler needs to be able to classify response metadata as headers or trailers, then trailers-only cannot be used if there is header metadata in the response, all metadata in a trailers-only response is interpreted as trailers". However, that latter statement is actually what is implemented in all gRPC implementations I know of... except connect, where the handler is (or at least was, before ContextInfo) unable to classify response metadata as headers or trailers for errors to unary RPCs.

I'm the one that changed the behavior of gRPC-Web to sometimes send them as trailers instead of headers, in #677.

And the gRPC protocol has always sent them as trailers, not as headers, out of concern for how the response would be encoded into HTTP/2 frames and whether it would actually conform to the spec. Though I am now certain it could send them as headers via a "proper" trailers-only response, because I was able to get the Conformance reference server to work that way (connectrpc/conformance#904).

So we definitely need to revise that comment. I don't even think it's valuable to describe what happens on the wire: instead it should just state that clients using CallInfo should always see the error metadata interpreted as trailers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The error metadata might not always be interpreted as trailers (in the CallInfo.ResponseTrailer field). So maybe the comment should instead be to check the connect Error.Metadata which include both the headers and trailers?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, Error.Metadata is the sane way to handle this so you don't have to worry about what protocol was used.

}
if request.GetNumber() < 0 {
callInfo.ResponseHeader().Set("x-custom-key", "ping-error")
callInfo.ResponseHeader().Set("x-callinfo-only", "from-callinfo")
Copy link
Member

Choose a reason for hiding this comment

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

Seems like we might also want a x-callinfo-and-error key that is set from both and expected to be merged.

We should also be explicitly setting callInfo.ResponseTrailer() metadata (for both success and error cases).

Comment on lines +3104 to +3110
headerValue := callInfo.ResponseHeader().Get("x-custom-key")
assert.Equal(t, headerValue, "ping-error")
callinfoOnlyValue := callInfo.ResponseHeader().Get("x-callinfo-only")
assert.Equal(t, callinfoOnlyValue, "from-callinfo")
// Error.Meta() should be in ResponseTrailer.
errorMetaValue := callInfo.ResponseTrailer().Get("x-error-meta")
assert.Equal(t, errorMetaValue, "from-error-meta")
Copy link
Member

Choose a reason for hiding this comment

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

I think we could use more assertions, like explicitly verifying that keys that should only be in trailers are not present in headers and vice versa.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added asserts for empty returns in the non-expected cases.

context.go Outdated
Comment on lines +270 to +272
for k, v := range w.err.Meta() {
combined[k] = v
}
Copy link
Member

Choose a reason for hiding this comment

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

I think the tests are passing because:

  1. You are not asserting that items that should be headers are not present in trailers. (err.Meta() will contain both as it provides no way to distinguish the two cases).
  2. You are straight-overwriting here instead of merging (i.e. instead of combined[k] = append(combined[k], v...). So if the values are duplicated between conn trailers and err.Meta(), this is basically a no-op and doesn't reveal an issue in the tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed

Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
@emcfarlane emcfarlane force-pushed the ed/propagateErrMeta branch from e2a60cf to a6c7c7b Compare October 1, 2025 17:37
@emcfarlane emcfarlane requested a review from jhump October 1, 2025 18:26
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Comment on lines +3132 to +3136
headerValue := callInfo.ResponseHeader().Get("x-custom-key")
assert.Equal(t, headerValue, "ping-error")
assert.Equal(t, callInfo.ResponseHeader().Get("x-header-only"), "should-not-be-in-trailers")
assert.Equal(t, callInfo.ResponseTrailer().Get("x-trailer-only"), "should-not-be-in-headers")
assert.Equal(t, callInfo.ResponseHeader().Get("x-trailer-only"), "")
Copy link
Member

Choose a reason for hiding this comment

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

using http.Header.Get() seems risky because it will succeed, even if the headers actually include more than one value. I think you need to use Values() and then update expectation to be a single-element slice literal.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Assert the values now.

Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
Signed-off-by: Edward McFarlane <emcfarlane@buf.build>
@emcfarlane emcfarlane enabled auto-merge (squash) October 7, 2025 19:31
@emcfarlane emcfarlane merged commit a19db8a into main Oct 7, 2025
9 checks passed
@emcfarlane emcfarlane deleted the ed/propagateErrMeta branch October 7, 2025 19:32
@emcfarlane emcfarlane mentioned this pull request Oct 7, 2025
project-mirrors-bot-tu bot pushed a commit to project-mirrors/forgejo-runner that referenced this pull request Oct 11, 2025
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [connectrpc.com/connect](https://github.com/connectrpc/connect-go) | `v1.18.1` -> `v1.19.1` | [![age](https://developer.mend.io/api/mc/badges/age/go/connectrpc.com%2fconnect/v1.19.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/go/connectrpc.com%2fconnect/v1.18.1/v1.19.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>connectrpc/connect-go (connectrpc.com/connect)</summary>

### [`v1.19.1`](https://github.com/connectrpc/connect-go/releases/tag/v1.19.1)

[Compare Source](connectrpc/connect-go@v1.19.0...v1.19.1)

<!-- Release notes generated using configuration in .github/release.yml at main -->

#### What's Changed

##### Bugfixes

- Fix bounds check on envelope for 32-bit archs by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;887](connectrpc/connect-go#887)
- Fix CallInfo header/trailer propagation on error responses by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;892](connectrpc/connect-go#892)

**Full Changelog**: <connectrpc/connect-go@v1.19.0...v1.19.1>

### [`v1.19.0`](https://github.com/connectrpc/connect-go/releases/tag/v1.19.0)

[Compare Source](connectrpc/connect-go@v1.18.1...v1.19.0)

This release introduces the highly requested "simple" flag for code generation, making Connect significantly more ergonomic for everyday RPC development.

The new simple flag in protoc-gen-connect-go generates cleaner, more intuitive client and handler interfaces that eliminate request/response wrappers for most use cases. This addresses community feedback about verbosity and provides a more straightforward API. When enabled, metadata (headers/trailers) can be passed through context instead of explicit wrapper objects, optimizing for the common case where developers don't need direct access to HTTP headers.

#### What's Changed

##### Enhancements

- Add simple flag for more ergonomic generated code by [@&#8203;bufdev](https://github.com/bufdev) and [@&#8203;smaye81](https://github.com/smaye81) in [#&#8203;851](connectrpc/connect-go#851)
- Update Go to v1.24 and document http.Protocol use removing the dependency on `golang.org/x/net/http2` by [@&#8203;maxbrunet](https://github.com/maxbrunet) in [#&#8203;873](connectrpc/connect-go#873), [#&#8203;877](connectrpc/connect-go#877)
- Add support for Edition 2024 by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;878](connectrpc/connect-go#878)

##### Bugfixes

- Include valid spec and headers when calling recover handler for streaming RPCs by [@&#8203;jhump](https://github.com/jhump) in [#&#8203;817](connectrpc/connect-go#817)

##### Other changes

- Go version support updated to latest two instead of three by [@&#8203;jhump](https://github.com/jhump) in [#&#8203;837](connectrpc/connect-go#837)
- CI testing improvements by [@&#8203;pkwarren](https://github.com/pkwarren) and [@&#8203;jhump](https://github.com/jhump) in [#&#8203;838](connectrpc/connect-go#838), [#&#8203;839](connectrpc/connect-go#839)
- Code quality improvements by [@&#8203;mattrobenolt](https://github.com/mattrobenolt) and [@&#8203;bufdev](https://github.com/bufdev) in [#&#8203;841](connectrpc/connect-go#841), [#&#8203;867](connectrpc/connect-go#867)
- Documentation improvements by [@&#8203;adlion](https://github.com/adlion) and [@&#8203;stefanvanburen](https://github.com/stefanvanburen) in [#&#8203;821](connectrpc/connect-go#821),
  [#&#8203;880](connectrpc/connect-go#880)

#### New Contributors

- [@&#8203;adlion](https://github.com/adlion) made their first contribution in [#&#8203;821](connectrpc/connect-go#821)
- [@&#8203;maxbrunet](https://github.com/maxbrunet) made their first contribution in [#&#8203;873](connectrpc/connect-go#873)
- [@&#8203;stefanvanburen](https://github.com/stefanvanburen) made their first contribution in [#&#8203;880](connectrpc/connect-go#880)

**Full Changelog**: <connectrpc/connect-go@v1.18.1...v1.19.0>

</details>

---

### Configuration

📅 **Schedule**: Branch creation - Between 12:00 AM and 03:59 AM ( * 0-3 * * * ) (UTC), Automerge - Between 12:00 AM and 03:59 AM ( * 0-3 * * * ) (UTC).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xMzUuNSIsInVwZGF0ZWRJblZlciI6IjQxLjEzNS41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJLaW5kL0RlcGVuZGVuY3lVcGRhdGUiLCJydW4tZW5kLXRvLWVuZC10ZXN0cyJdfQ==-->

Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1078
Reviewed-by: earl-warren <earl-warren@noreply.code.forgejo.org>
Co-authored-by: Renovate Bot <bot@kriese.eu>
Co-committed-by: Renovate Bot <bot@kriese.eu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Response headers set in handler are lost when handler returns an error

3 participants