From 34c69e0c9c16b344d1ffcf7953979654791077b4 Mon Sep 17 00:00:00 2001 From: dorothyyzh <133956597+dorothyyzh@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:49:44 +0800 Subject: [PATCH] feat(statusx): expose error metadata via x-status-metadata response header vproto.ValidationError has no metadata field, so metadata set via statusx.WithMetadata would be lost on the vproto HTTP error path (unlike the connect path, which carries it natively in error details). Expose it out-of-band on the x-status-metadata response header: a JSON-encoded, URL-escaped map[string]string, set only when the error carries metadata. Add var ExposedHeaders (symmetric to AllowHeaders) so downstreams using the vproto path can wire the header into their CORS ExposedHeaders. The header ownership stays in statusx; httpx is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- statusx/vproto.go | 17 +++++++++++++++ statusx/vproto_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/statusx/vproto.go b/statusx/vproto.go index 5b8a7ddf..63fcf45f 100644 --- a/statusx/vproto.go +++ b/statusx/vproto.go @@ -5,6 +5,7 @@ import ( "context" "log/slog" "net/http" + "net/url" "strings" "connectrpc.com/connect" @@ -26,6 +27,16 @@ var AllowHeaders = []string{ http.CanonicalHeaderKey(HeaderEnsureConnectError), } +// HeaderStatusMetadata carries the error's metadata (from statusx.WithMetadata) +// on the HTTP response. Since vproto.ValidationError has no metadata field, the +// metadata is exposed out-of-band: a JSON-encoded map[string]string, URL-escaped, +// set on this header. It is only present when the error actually carries metadata. +const HeaderStatusMetadata = "x-status-metadata" + +var ExposedHeaders = []string{ + http.CanonicalHeaderKey(HeaderStatusMetadata), +} + func EnsureConnectError(ctx context.Context) bool { return metadata.ExtractIncoming(ctx).Get(HeaderEnsureConnectError) == "true" } @@ -117,6 +128,12 @@ func WriteVProtoHTTPError(err error, w http.ResponseWriter, r *http.Request) (xe } } + // vproto.ValidationError has no metadata field, so expose the error's + // metadata (from statusx.WithMetadata) out-of-band on a response header. + if md := errorInfo.GetMetadata(); len(md) > 0 { + w.Header().Set(HeaderStatusMetadata, url.QueryEscape(jsonx.MustMarshalX[string](md))) + } + isJSON := isMimeTypeJSON(w.Header().Get(httpx.HeaderContentType)) if w.Header().Get(httpx.HeaderContentType) == "" { isJSON = shouldReturnJSON(r) diff --git a/statusx/vproto_test.go b/statusx/vproto_test.go index 42f4a714..6871144b 100644 --- a/statusx/vproto_test.go +++ b/statusx/vproto_test.go @@ -3,9 +3,11 @@ package statusx import ( "net/http" "net/http/httptest" + "net/url" "strings" "testing" + "github.com/qor5/x/v3/jsonx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -25,6 +27,53 @@ func TestWriteVProtoHTTPError_JSON(t *testing.T) { assert.Contains(t, w.Body.String(), "INVALID_ARGUMENT") } +func TestWriteVProtoHTTPError_MetadataHeader(t *testing.T) { + md := map[string]string{"order_id": "123", "retry_after": "30s"} + tests := []struct { + name string + err error + wantMetadata map[string]string // nil means no metadata header expected + }{ + { + name: "with metadata sets header", + err: New(codes.InvalidArgument, "INVALID_ARGUMENT", "invalid").WithMetadata(md).Err(), + wantMetadata: md, + }, + { + name: "without metadata omits header", + err: BadRequest(NewFieldViolation("email", "REQUIRED", "Email is required")).Err(), + wantMetadata: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", strings.NewReader("req")) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + + werr := WriteVProtoHTTPError(tt.err, w, req) + require.NoError(t, werr) + + encoded := w.Header().Get(HeaderStatusMetadata) + if tt.wantMetadata == nil { + assert.Empty(t, encoded) + return + } + + require.NotEmpty(t, encoded) + + // Reverse the encoding done by WriteVProtoHTTPError: URL-unescape + // then JSON-unmarshal the header value back into a metadata map. + data, derr := url.QueryUnescape(encoded) + require.NoError(t, derr) + var decoded map[string]string + require.NoError(t, jsonx.Unmarshal([]byte(data), &decoded)) + assert.Equal(t, tt.wantMetadata, decoded) + }) + } +} + func TestWriteVProtoHTTPError_MarshalFailureFallbackJSON(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", strings.NewReader("req")) w := httptest.NewRecorder()