Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions examples/grpc_error_details.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import grpc from 'k6/net/grpc';
import { check } from 'k6';

const client = new grpc.Client();
client.load(['definitions'], 'hello.proto');

export default () => {
client.connect('localhost:10000', {
plaintext: true
});

const data = { greeting: 'Bert' };
const response = client.invoke('hello.HelloService/SayHello', data);

check(response, {
'status is OK': (r) => r && r.status === grpc.StatusOK,
});

// If there's an error with details, log them
if (response.error && response.error.details) {
console.log('Error details:', JSON.stringify(response.error.details, null, 2));

response.error.details.forEach((detail, index) => {
console.log(`Detail ${index} type:`, detail['@type']);

// Example: Check for ErrorInfo
if (detail['@type'] && detail['@type'].includes('ErrorInfo')) {
console.log(' Reason:', detail.reason);
console.log(' Domain:', detail.domain);
console.log(' Metadata:', JSON.stringify(detail.metadata));
}

// Example: Check for RetryInfo
if (detail['@type'] && detail['@type'].includes('RetryInfo')) {
console.log(' Retry delay:', detail.retryDelay);
}

// Example: Check for BadRequest
if (detail['@type'] && detail['@type'].includes('BadRequest')) {
console.log(' Field violations:');
detail.fieldViolations.forEach((violation) => {
console.log(` Field: ${violation.field}, Description: ${violation.description}`);
});
}
});
}

client.close();
};
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)

Expand Down
73 changes: 73 additions & 0 deletions internal/js/modules/k6/grpc/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"go.k6.io/k6/lib/fsext"
"go.k6.io/k6/metrics"

"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/health"
Expand Down Expand Up @@ -669,6 +670,78 @@ func TestClient(t *testing.T) {
},
},
},
{
name: "ResponseErrorWithDetails",
initString: codeBlock{
code: `
var client = new grpc.Client();
client.load([], "../../../../lib/testutils/httpmultibin/grpc_testing/test.proto");`,
},
setup: func(tb *httpmultibin.HTTPMultiBin) {
tb.GRPCStub.EmptyCallFunc = func(_ context.Context, _ *grpc_testing.Empty) (*grpc_testing.Empty, error) {
// Create a status with error details
st := status.New(codes.InvalidArgument, "invalid request")

// Add structured error details using google.rpc.ErrorInfo
errInfo := &errdetails.ErrorInfo{
Reason: "FIELD_VALIDATION_ERROR",
Domain: "k6.io",
Metadata: map[string]string{
"field": "username",
"issue": "too_short",
},
}
st, err := st.WithDetails(errInfo)
if err != nil {
return nil, status.Error(codes.Internal, "failed to add error details")
}

return nil, st.Err()
}
},
vuString: codeBlock{
code: `
client.connect("GRPCBIN_ADDR");
var resp = client.invoke("grpc.testing.TestService/EmptyCall", {})

if (resp.status !== grpc.StatusInvalidArgument) {
throw new Error("unexpected error status: " + resp.status)
}

if (!resp.error || resp.error.message !== "invalid request" || resp.error.code !== 3) {
throw new Error("unexpected error object: " + JSON.stringify(resp.error))
}

// Check that error details are present
if (!resp.error.details || resp.error.details.length === 0) {
throw new Error("expected error details but got: " + JSON.stringify(resp.error))
}

// Verify the error detail structure
const detail = resp.error.details[0]
if (!detail["@type"] || !detail["@type"].includes("ErrorInfo")) {
throw new Error("expected ErrorInfo detail but got: " + JSON.stringify(detail))
}

// Check ErrorInfo fields
if (detail.reason !== "FIELD_VALIDATION_ERROR") {
throw new Error("unexpected reason: " + detail.reason)
}

if (detail.domain !== "k6.io") {
throw new Error("unexpected domain: " + detail.domain)
}

if (!detail.metadata || detail.metadata.field !== "username" || detail.metadata.issue !== "too_short") {
throw new Error("unexpected metadata: " + JSON.stringify(detail.metadata))
}
`,
asserts: func(t *testing.T, rb *httpmultibin.HTTPMultiBin, samples chan metrics.SampleContainer, _ error) {
samplesBuf := metrics.GetBufferedSamples(samples)
assertMetricEmitted(t, metrics.GRPCReqDurationName, samplesBuf, rb.Replacer.Replace("GRPCBIN_ADDR/grpc.testing.TestService/EmptyCall"))
},
},
},
{
name: "ResponseHeaders",
initString: codeBlock{
Expand Down
58 changes: 45 additions & 13 deletions internal/lib/netext/grpcext/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"go.k6.io/k6/metrics"

protov1 "github.com/golang/protobuf/proto" //nolint:staticcheck,nolintlint // this is the old v1 version
spb "google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
Expand Down Expand Up @@ -178,19 +179,7 @@ func (c *Conn) Invoke(
marshaler := protojson.MarshalOptions{EmitUnpopulated: true, Resolver: c.types}

if err != nil {
sterr := status.Convert(err)
response.Status = sterr.Code()

// (rogchap) when you access a JSON property in Sobek, you are actually accessing the underling
// Go type (struct, map, slice etc); because these are dynamic messages the Unmarshaled JSON does
// not map back to a "real" field or value (as a normal Go type would). If we don't marshal and then
// unmarshal back to a map, you will get "undefined" when accessing JSON properties, even when
// JSON.Stringify() shows the object to be correctly present.

raw, _ := marshaler.Marshal(sterr.Proto())
errMsg := make(map[string]any)
_ = json.Unmarshal(raw, &errMsg)
response.Error = errMsg
response.Status, response.Error = convertInvokeError(err, trailer)
}

if resp != nil && !req.DiscardResponseMessage {
Expand All @@ -204,6 +193,49 @@ func (c *Conn) Invoke(
return &response, nil
}

// convertInvokeError converts a gRPC invocation error into a status code and
// a JSON-serializable error map. It reads the grpc-status-details-bin trailer
// to populate rich error details when present and the status code matches.
func convertInvokeError(err error, trailer metadata.MD) (codes.Code, map[string]any) {
sterr := status.Convert(err)

// grpc-status-details-bin carries a google.rpc.Status protobuf with rich error details.
// Only use it when the status code matches to avoid inconsistent state.
if detailsBin := trailer.Get("grpc-status-details-bin"); len(detailsBin) > 0 {
statusProto := &spb.Status{}
if unmarshalErr := proto.Unmarshal([]byte(detailsBin[0]), statusProto); unmarshalErr == nil {
if statusProto.Code == int32(sterr.Code()) { //nolint:gosec // gRPC codes are 0-16, always within int32 range
sterr = status.FromProto(statusProto)
}
}
}

// (rogchap) when you access a JSON property in Sobek, you are actually accessing the underling
// Go type (struct, map, slice etc); because these are dynamic messages the Unmarshaled JSON does
// not map back to a "real" field or value (as a normal Go type would). If we don't marshal and then
// unmarshal back to a map, you will get "undefined" when accessing JSON properties, even when
// JSON.Stringify() shows the object to be correctly present.

// c.types only contains user-defined protobuf types, so we use nil here to fall back to the
// global registry for standard Google types (e.g. google.protobuf.Any, google.rpc.ErrorInfo).
errorMarshaler := protojson.MarshalOptions{EmitUnpopulated: true, Resolver: nil}
raw, err := errorMarshaler.Marshal(sterr.Proto())
if err != nil {
return sterr.Code(), map[string]any{
"code": int32(sterr.Code()), //nolint:gosec // gRPC codes are 0-16, always within int32 range
"message": sterr.Message(),
}
}
errMsg := make(map[string]any)
if err = json.Unmarshal(raw, &errMsg); err != nil {
return sterr.Code(), map[string]any{
"code": int32(sterr.Code()), //nolint:gosec // gRPC codes are 0-16, always within int32 range
"message": sterr.Message(),
}
}
return sterr.Code(), errMsg
}

// NewStream creates a new gRPC stream.
func (c *Conn) NewStream(
ctx context.Context,
Expand Down
Loading