This is an automated email from the ASF dual-hosted git repository.

Alanxtl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git


The following commit(s) were added to refs/heads/develop by this push:
     new 9fcee8d69 feat(triple): expose unary response metadata via call 
options (#3443)
9fcee8d69 is described below

commit 9fcee8d6907548cdec9614972d91b0f4bbf3c0bd
Author: ヴァニラシ <[email protected]>
AuthorDate: Fri Jun 19 16:03:18 2026 +0800

    feat(triple): expose unary response metadata via call options (#3443)
    
    * feat(triple): expose unary response metadata via call options
    
    * fix(triple): capture unary response metadata on error
    
    * docs(client): clarify response metadata capture on errors
    
    * fix(triple): validate response metadata capture attributes
    
    Signed-off-by: Vanillaxi <[email protected]>
    
    * style(triple): format client test imports
    
    ---------
    
    Signed-off-by: Vanillaxi <[email protected]>
---
 client/client.go                          |  6 +++
 client/client_test.go                     | 23 +++++++-
 client/options.go                         | 25 ++++++++-
 common/constant/key.go                    |  2 +
 protocol/triple/client.go                 | 12 +++--
 protocol/triple/client_test.go            | 89 +++++++++++++++++++++++++++++++
 protocol/triple/triple_invoker.go         | 28 +++++++++-
 protocol/triple/triple_protocol/triple.go |  6 ++-
 8 files changed, 180 insertions(+), 11 deletions(-)

diff --git a/client/client.go b/client/client.go
index 794da976d..c15514830 100644
--- a/client/client.go
+++ b/client/client.go
@@ -277,6 +277,12 @@ func generateInvocation(ctx context.Context, methodName 
string, reqs []any, resp
                invocation.WithAttachments(attachments),
        )
        inv.SetAttribute(constant.CallTypeKey, callType)
+       if opts.ResponseHeader != nil {
+               inv.SetAttribute(constant.ResponseHeaderKey, 
opts.ResponseHeader)
+       }
+       if opts.ResponseTrailer != nil {
+               inv.SetAttribute(constant.ResponseTrailerKey, 
opts.ResponseTrailer)
+       }
 
        return inv, nil
 }
diff --git a/client/client_test.go b/client/client_test.go
index 95d2e5ea1..837925a67 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -20,6 +20,7 @@ package client
 import (
        "context"
        "errors"
+       "net/http"
        "testing"
        "time"
 )
@@ -146,7 +147,19 @@ func TestConnectionCallPassesOptions(t *testing.T) {
        conn := &Connection{refOpts: &ReferenceOptions{invoker: invoker}}
 
        var resp string
-       res, err := conn.call(context.Background(), []any{"req"}, &resp, 
"Ping", constant.CallUnary, WithCallRequestTimeout(1500*time.Millisecond), 
WithCallRetries(3))
+       var responseHeader http.Header
+       var responseTrailer http.Header
+       res, err := conn.call(
+               context.Background(),
+               []any{"req"},
+               &resp,
+               "Ping",
+               constant.CallUnary,
+               WithCallRequestTimeout(1500*time.Millisecond),
+               WithCallRetries(3),
+               WithResponseHeader(&responseHeader),
+               WithResponseTrailer(&responseTrailer),
+       )
        require.NoError(t, err)
        require.Equal(t, invRes, res)
 
@@ -158,6 +171,14 @@ func TestConnectionCallPassesOptions(t *testing.T) {
 
        requireCallType(t, inv, constant.CallUnary)
        require.Equal(t, []any{"req", &resp}, inv.ParameterRawValues())
+
+       headerTarget, ok := inv.GetAttribute(constant.ResponseHeaderKey)
+       require.True(t, ok)
+       require.Same(t, &responseHeader, headerTarget)
+
+       trailerTarget, ok := inv.GetAttribute(constant.ResponseTrailerKey)
+       require.True(t, ok)
+       require.Same(t, &responseTrailer, trailerTarget)
 }
 
 func TestCallUnary(t *testing.T) {
diff --git a/client/options.go b/client/options.go
index 2be018441..cae30d2e1 100644
--- a/client/options.go
+++ b/client/options.go
@@ -18,6 +18,7 @@
 package client
 
 import (
+       "net/http"
        "strconv"
        "time"
 )
@@ -999,8 +1000,10 @@ func SetClientRouters(routers []*global.RouterConfig) 
ClientOption {
 
 // todo: need to be consistent with MethodConfig
 type CallOptions struct {
-       RequestTimeout string
-       Retries        string
+       RequestTimeout  string
+       Retries         string
+       ResponseHeader  *http.Header
+       ResponseTrailer *http.Header
 }
 
 type CallOption func(*CallOptions)
@@ -1022,3 +1025,21 @@ func WithCallRetries(retries int) CallOption {
                opts.Retries = strconv.Itoa(retries)
        }
 }
+
+// WithResponseHeader configures a target to receive response headers.
+// Currently, only Triple unary calls populate this option (including error
+// responses when metadata is available).
+func WithResponseHeader(header *http.Header) CallOption {
+       return func(opts *CallOptions) {
+               opts.ResponseHeader = header
+       }
+}
+
+// WithResponseTrailer configures a target to receive response trailers.
+// Currently, only Triple unary calls populate this option (including error
+// responses when metadata is available).
+func WithResponseTrailer(trailer *http.Header) CallOption {
+       return func(opts *CallOptions) {
+               opts.ResponseTrailer = trailer
+       }
+}
diff --git a/common/constant/key.go b/common/constant/key.go
index bab746aaf..f1a1e9be0 100644
--- a/common/constant/key.go
+++ b/common/constant/key.go
@@ -157,6 +157,8 @@ const (
        CycleReportKey                     = "cycle.report"
        DefaultBlackListRecoverBlock       = 16
        CallTypeKey                        = "call-type"
+       ResponseHeaderKey                  = "response-header"
+       ResponseTrailerKey                 = "response-trailer"
        CallUnary                          = "unary"
        CallClientStream                   = "client-stream"
        CallServerStream                   = "server-stream"
diff --git a/protocol/triple/client.go b/protocol/triple/client.go
index 1e548d2e1..6360972dd 100644
--- a/protocol/triple/client.go
+++ b/protocol/triple/client.go
@@ -66,13 +66,17 @@ type clientManager struct {
 // TODO: code a triple client between clientManager and triple_protocol client
 // TODO: write a NewClient for triple client
 
-func (cm *clientManager) callUnary(ctx context.Context, method string, req, 
resp any) error {
+func (cm *clientManager) callUnary(ctx context.Context, method string, req, 
resp any, responseHeader, responseTrailer *http.Header) error {
        triReq := tri.NewRequest(req)
        triResp := tri.NewResponse(resp)
-       if err := cm.triClient.CallUnary(ctx, triReq, method, triResp); err != 
nil {
-               return err
+       err := cm.triClient.CallUnary(ctx, triReq, method, triResp)
+       if responseHeader != nil {
+               *responseHeader = triResp.Header().Clone()
        }
-       return nil
+       if responseTrailer != nil {
+               *responseTrailer = triResp.Trailer().Clone()
+       }
+       return err
 }
 
 func (cm *clientManager) callClientStream(ctx context.Context, method string) 
(any, error) {
diff --git a/protocol/triple/client_test.go b/protocol/triple/client_test.go
index 522958b71..277180bc5 100644
--- a/protocol/triple/client_test.go
+++ b/protocol/triple/client_test.go
@@ -20,6 +20,7 @@ package triple
 import (
        "context"
        "net/http"
+       "net/http/httptest"
        "testing"
        "time"
 )
@@ -27,6 +28,8 @@ import (
 import (
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
+
+       "google.golang.org/protobuf/types/known/emptypb"
 )
 
 import (
@@ -103,6 +106,92 @@ func TestClientManager_Close(t *testing.T) {
        require.NoError(t, err)
 }
 
+func TestClientManagerCallUnaryCopiesResponseMetadata(t *testing.T) {
+       const (
+               responseHeaderKey  = "x-test-response-header"
+               responseHeaderVal  = "response-header-value"
+               responseTrailerKey = "x-test-response-trailer"
+               responseTrailerVal = "response-trailer-value"
+       )
+
+       handler := tri.NewUnaryHandler(
+               "/test.Service/Check",
+               func() any {
+                       return new(emptypb.Empty)
+               },
+               func(ctx context.Context, req *tri.Request) (*tri.Response, 
error) {
+                       resp := tri.NewResponse(new(emptypb.Empty))
+                       resp.Header().Set(responseHeaderKey, responseHeaderVal)
+                       resp.Trailer().Set(responseTrailerKey, 
responseTrailerVal)
+                       return resp, nil
+               },
+       )
+       server := httptest.NewServer(handler)
+       t.Cleanup(server.Close)
+
+       cm := &clientManager{
+               isIDL:     true,
+               triClient: tri.NewClient(server.Client(), 
server.URL+"/test.Service", tri.WithTriple()),
+       }
+
+       var resp emptypb.Empty
+       var responseHeader http.Header
+       var responseTrailer http.Header
+       err := cm.callUnary(
+               context.Background(),
+               "Check",
+               &emptypb.Empty{},
+               &resp,
+               &responseHeader,
+               &responseTrailer,
+       )
+       require.NoError(t, err)
+       require.Equal(t, responseHeaderVal, 
responseHeader.Get(responseHeaderKey))
+       require.Equal(t, responseTrailerVal, 
responseTrailer.Get(responseTrailerKey))
+}
+
+func TestClientManagerCallUnaryCopiesErrorResponseMetadata(t *testing.T) {
+       const (
+               responseHeaderKey  = "x-test-error-header"
+               responseHeaderVal  = "error-header-value"
+               responseTrailerKey = "x-test-error-trailer"
+               responseTrailerVal = "error-trailer-value"
+       )
+
+       handler := http.HandlerFunc(func(w http.ResponseWriter, r 
*http.Request) {
+               w.Header().Set("Content-Type", "application/grpc")
+               w.Header().Set(responseHeaderKey, responseHeaderVal)
+               w.Header().Set("Trailer", "Grpc-Status, Grpc-Message, 
"+responseTrailerKey)
+               w.WriteHeader(http.StatusOK)
+               w.Header().Set("Grpc-Status", "3")
+               w.Header().Set("Grpc-Message", "invalid%20test%20request")
+               w.Header().Set(responseTrailerKey, responseTrailerVal)
+       })
+       server := httptest.NewServer(handler)
+       t.Cleanup(server.Close)
+
+       cm := &clientManager{
+               isIDL:     true,
+               triClient: tri.NewClient(server.Client(), 
server.URL+"/test.Service"),
+       }
+
+       var resp emptypb.Empty
+       var responseHeader http.Header
+       var responseTrailer http.Header
+       err := cm.callUnary(
+               context.Background(),
+               "Check",
+               &emptypb.Empty{},
+               &resp,
+               &responseHeader,
+               &responseTrailer,
+       )
+       require.Error(t, err)
+       require.Equal(t, tri.CodeInvalidArgument, tri.CodeOf(err))
+       require.Equal(t, responseHeaderVal, 
responseHeader.Get(responseHeaderKey))
+       require.Equal(t, responseTrailerVal, 
responseTrailer.Get(responseTrailerKey))
+}
+
 // TestClientManager_CallMethods_MissingClient removed - no longer applicable
 // in the service-level client architecture where all methods share a single 
triClient.
 
diff --git a/protocol/triple/triple_invoker.go 
b/protocol/triple/triple_invoker.go
index 4eb7a4ff1..d41a4fd45 100644
--- a/protocol/triple/triple_invoker.go
+++ b/protocol/triple/triple_invoker.go
@@ -101,11 +101,13 @@ func (ti *TripleInvoker) Invoke(ctx context.Context, 
invocation base.Invocation)
 
        inRawLen := len(inRaw)
 
+       responseHeader, responseTrailer := responseMetadataTargets(invocation)
+
        if !ti.clientManager.isIDL {
                switch callType {
                case constant.CallUnary:
                        // todo(DMwangnima): consider inRawLen == 0
-                       if err := ti.clientManager.callUnary(ctx, method, 
inRaw[0:inRawLen-1], inRaw[inRawLen-1]); err != nil {
+                       if err := ti.clientManager.callUnary(ctx, method, 
inRaw[0:inRawLen-1], inRaw[inRawLen-1], responseHeader, responseTrailer); err 
!= nil {
                                result.SetError(err)
                        }
                default:
@@ -118,7 +120,7 @@ func (ti *TripleInvoker) Invoke(ctx context.Context, 
invocation base.Invocation)
                if len(inRaw) != 2 {
                        panic(fmt.Sprintf("Wrong parameter Values number for 
CallUnary, want 2, but got %d", inRawLen))
                }
-               if err := ti.clientManager.callUnary(ctx, method, inRaw[0], 
inRaw[1]); err != nil {
+               if err := ti.clientManager.callUnary(ctx, method, inRaw[0], 
inRaw[1], responseHeader, responseTrailer); err != nil {
                        result.SetError(err)
                }
        case constant.CallClientStream:
@@ -158,6 +160,28 @@ func (ti *TripleInvoker) Invoke(ctx context.Context, 
invocation base.Invocation)
        return &result
 }
 
+func responseMetadataTargets(invocation base.Invocation) (*http.Header, 
*http.Header) {
+       var responseHeader *http.Header
+       if headerRaw, ok := 
invocation.GetAttribute(constant.ResponseHeaderKey); ok {
+               if header, ok := headerRaw.(*http.Header); ok {
+                       responseHeader = header
+               } else if headerRaw != nil {
+                       logger.Warnf("[Triple][Invoker] invocation attribute %s 
should be *http.Header, got %T", constant.ResponseHeaderKey, headerRaw)
+               }
+       }
+
+       var responseTrailer *http.Header
+       if trailerRaw, ok := 
invocation.GetAttribute(constant.ResponseTrailerKey); ok {
+               if trailer, ok := trailerRaw.(*http.Header); ok {
+                       responseTrailer = trailer
+               } else if trailerRaw != nil {
+                       logger.Warnf("[Triple][Invoker] invocation attribute %s 
should be *http.Header, got %T", constant.ResponseTrailerKey, trailerRaw)
+               }
+       }
+
+       return responseHeader, responseTrailer
+}
+
 func mergeAttachmentToOutgoing(ctx context.Context, inv base.Invocation) 
(context.Context, error) {
        // Todo(finalt) Temporarily solve the problem that the timeout time is 
not valid
        if timeout, ok := inv.GetAttachment(constant.TimeoutKey); ok {
diff --git a/protocol/triple/triple_protocol/triple.go 
b/protocol/triple/triple_protocol/triple.go
index 99b5bf642..976437bc0 100644
--- a/protocol/triple/triple_protocol/triple.go
+++ b/protocol/triple/triple_protocol/triple.go
@@ -346,6 +346,10 @@ func receiveUnaryResponse(conn StreamingClientConn, 
response AnyResponse) error
        if !ok {
                panic(fmt.Sprintf("response %T is not of Response type", 
response))
        }
+       defer func() {
+               resp.header = conn.ResponseHeader()
+               resp.trailer = conn.ResponseTrailer()
+       }()
        if err := conn.Receive(resp.Msg); err != nil {
                return err
        }
@@ -357,8 +361,6 @@ func receiveUnaryResponse(conn StreamingClientConn, 
response AnyResponse) error
        } else if !errors.Is(err, io.EOF) {
                return NewError(CodeUnknown, err)
        }
-       resp.header = conn.ResponseHeader()
-       resp.trailer = conn.ResponseTrailer()
        return nil
 }
 

Reply via email to