Skip to content

Commit 04898b7

Browse files
authored
Merge pull request #20 from redpanda-data/jb/errorhandling
generator: include errordetails in error
2 parents 10b1375 + f44ac18 commit 04898b7

File tree

11 files changed

+412
-76
lines changed

11 files changed

+412
-76
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ require (
1414
)
1515

1616
require (
17+
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 // indirect
1718
github.com/google/go-cmp v0.7.0 // indirect
1819
github.com/google/uuid v1.6.0 // indirect
20+
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 // indirect
1921
github.com/tidwall/gjson v1.14.4 // indirect
2022
github.com/tidwall/match v1.1.1 // indirect
2123
github.com/tidwall/pretty v1.2.1 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 h1:JyGBchZNUPlQ7/qjieeKq/Cy+/i1vc0H+cIniGZNSFg=
2+
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2/go.mod h1:wThyg02xJx4K/DA5fg0QlKts8XVPyTT86JC8hPfEzno=
13
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
24
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
35
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -22,6 +24,8 @@ github.com/openai/openai-go v1.5.0 h1:EcSBUYTiA4xbsO0VTX3i2WCPwKLMniwlVpiW/dCoXr
2224
github.com/openai/openai-go v1.5.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
2325
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2426
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
27+
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 h1:9Ecc+Cg1EyqSTIQ6wQKoKk8BqDlBQmR74bJui4qIqsM=
28+
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06/go.mod h1:klAmWfc8Q3hEZk8geFTMu6f2sk3VUKRS7cv/LvB05ig=
2529
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
2630
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
2731
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

pkg/generator/generator.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func Register{{$key}}Handler(s *mcpserver.MCPServer, srv {{$key}}Server, opts ..
124124
125125
resp, err := srv.{{$tool_name}}(ctx, &req)
126126
if err != nil {
127-
return nil, err
127+
return runtime.HandleError(err)
128128
}
129129
130130
marshaled, err = (protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}).Marshal(resp)
@@ -176,7 +176,7 @@ func Register{{$key}}HandlerOpenAI(s *mcpserver.MCPServer, srv {{$key}}Server, o
176176
177177
resp, err := srv.{{$tool_name}}(ctx, &req)
178178
if err != nil {
179-
return nil, err
179+
return runtime.HandleError(err)
180180
}
181181
182182
marshaled, err = (protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}).Marshal(resp)
@@ -259,7 +259,7 @@ func ForwardToConnect{{$key}}Client(s *mcpserver.MCPServer, client Connect{{$key
259259
260260
resp, err := client.{{$tool_name}}(ctx, connect.NewRequest(&req))
261261
if err != nil {
262-
return nil, err
262+
return runtime.HandleError(err)
263263
}
264264
265265
marshaled, err = (protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}).Marshal(resp.Msg)
@@ -310,7 +310,7 @@ func ForwardTo{{$key}}Client(s *mcpserver.MCPServer, client {{$key}}Client, opts
310310
311311
resp, err := client.{{$tool_name}}(ctx, &req)
312312
if err != nil {
313-
return nil, err
313+
return runtime.HandleError(err)
314314
}
315315
316316
marshaled, err = (protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}).Marshal(resp)

pkg/runtime/error.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright 2025 Redpanda Data, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package runtime
16+
17+
import (
18+
"errors"
19+
20+
"connectrpc.com/connect"
21+
"github.com/mark3labs/mcp-go/mcp"
22+
apierrors "github.com/redpanda-data/common-go/api/errors"
23+
spb "google.golang.org/genproto/googleapis/rpc/status"
24+
"google.golang.org/grpc/codes"
25+
"google.golang.org/grpc/status"
26+
"google.golang.org/protobuf/encoding/protojson"
27+
)
28+
29+
// HandleError converts a gRPC/Connect error into a structured MCP tool result
30+
// It extracts error codes, messages, and detailed error information using common-go utilities
31+
func HandleError(err error) (*mcp.CallToolResult, error) {
32+
if err == nil {
33+
return nil, nil
34+
}
35+
36+
// Convert to google.rpc.Status regardless of source
37+
var statusProto *spb.Status
38+
39+
// Try to extract gRPC status first
40+
if st, ok := status.FromError(err); ok {
41+
statusProto = st.Proto()
42+
} else if connectErr := new(connect.Error); errors.As(err, &connectErr) {
43+
// Handle Connect RPC errors using ConnectErrorToGoogleStatus
44+
statusProto = apierrors.ConnectErrorToGoogleStatus(connectErr)
45+
} else {
46+
// Create a basic status for generic errors
47+
statusProto = &spb.Status{
48+
Code: int32(codes.Unknown),
49+
Message: err.Error(),
50+
Details: nil,
51+
}
52+
}
53+
54+
// Use StatusToNice to convert to the common-go ErrorStatus format
55+
niceStatus := apierrors.StatusToNice(statusProto)
56+
57+
// Marshal the niceStatus directly to JSON using protojson
58+
finalJSON, marshalErr := protojson.Marshal(niceStatus)
59+
if marshalErr != nil {
60+
// Fallback to simple error message if JSON marshaling fails
61+
return mcp.NewToolResultError("Error: " + err.Error()), nil
62+
}
63+
64+
return mcp.NewToolResultError(string(finalJSON)), nil
65+
}

pkg/runtime/error_test.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package runtime
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"testing"
7+
8+
"connectrpc.com/connect"
9+
"github.com/mark3labs/mcp-go/mcp"
10+
"google.golang.org/genproto/googleapis/rpc/errdetails"
11+
"google.golang.org/grpc/codes"
12+
"google.golang.org/grpc/status"
13+
"google.golang.org/protobuf/types/known/wrapperspb"
14+
)
15+
16+
func TestHandleError_SimpleError(t *testing.T) {
17+
err := errors.New("simple error")
18+
result, handleErr := HandleError(err)
19+
20+
if handleErr != nil {
21+
t.Fatalf("HandleError should not return an error, got: %v", handleErr)
22+
}
23+
24+
if result == nil {
25+
t.Fatal("HandleError should return a result")
26+
}
27+
28+
// Parse the JSON error response
29+
textContent := result.Content[0].(mcp.TextContent)
30+
var errorResp map[string]interface{}
31+
if jsonErr := json.Unmarshal([]byte(textContent.Text), &errorResp); jsonErr != nil {
32+
t.Fatalf("Failed to parse error JSON: %v", jsonErr)
33+
}
34+
35+
if errorResp["code"] != "UNKNOWN" {
36+
t.Errorf("Expected code 'UNKNOWN', got: %s", errorResp["code"])
37+
}
38+
39+
if errorResp["message"] != "simple error" {
40+
t.Errorf("Expected message 'simple error', got: %s", errorResp["message"])
41+
}
42+
}
43+
44+
func TestHandleError_GRPCStatus(t *testing.T) {
45+
// Create a gRPC status error with details
46+
st := status.New(codes.InvalidArgument, "invalid request")
47+
48+
// Add a detail message
49+
stringValue := wrapperspb.String("additional info")
50+
51+
st, err := st.WithDetails(stringValue)
52+
if err != nil {
53+
t.Fatalf("Failed to add details: %v", err)
54+
}
55+
56+
result, handleErr := HandleError(st.Err())
57+
58+
if handleErr != nil {
59+
t.Fatalf("HandleError should not return an error, got: %v", handleErr)
60+
}
61+
62+
if result == nil {
63+
t.Fatal("HandleError should return a result")
64+
}
65+
66+
// Parse the JSON error response
67+
textContent := result.Content[0].(mcp.TextContent)
68+
var errorResp map[string]interface{}
69+
if jsonErr := json.Unmarshal([]byte(textContent.Text), &errorResp); jsonErr != nil {
70+
t.Fatalf("Failed to parse error JSON: %v", jsonErr)
71+
}
72+
73+
if errorResp["code"] != "INVALID_ARGUMENT" {
74+
t.Errorf("Expected code 'INVALID_ARGUMENT', got: %s", errorResp["code"])
75+
}
76+
77+
if errorResp["message"] != "invalid request" {
78+
t.Errorf("Expected message 'invalid request', got: %s", errorResp["message"])
79+
}
80+
81+
details, hasDetails := errorResp["details"].([]interface{})
82+
if !hasDetails || len(details) == 0 {
83+
t.Error("Expected error details, got none")
84+
}
85+
86+
// Verify the actual content of error details
87+
if len(details) > 0 {
88+
detail := details[0].(map[string]interface{})
89+
90+
// Check @type field
91+
if typeField, ok := detail["@type"]; ok {
92+
if typeStr, ok := typeField.(string); ok {
93+
if typeStr != "type.googleapis.com/google.protobuf.StringValue" {
94+
t.Errorf("Expected @type 'type.googleapis.com/google.protobuf.StringValue', got: %s", typeStr)
95+
}
96+
}
97+
} else {
98+
t.Error("Expected @type field in error details")
99+
}
100+
101+
// Check value field
102+
if valueField, ok := detail["value"]; ok {
103+
if valueStr, ok := valueField.(string); ok {
104+
if valueStr != "additional info" {
105+
t.Errorf("Expected value 'additional info', got: %s", valueStr)
106+
}
107+
}
108+
} else {
109+
t.Error("Expected value field in error details")
110+
}
111+
}
112+
}
113+
114+
func TestHandleError_ConnectError(t *testing.T) {
115+
// Create a Connect error
116+
connectErr := connect.NewError(connect.CodeInvalidArgument, errors.New("connect error"))
117+
118+
result, handleErr := HandleError(connectErr)
119+
120+
if handleErr != nil {
121+
t.Fatalf("HandleError should not return an error, got: %v", handleErr)
122+
}
123+
124+
if result == nil {
125+
t.Fatal("HandleError should return a result")
126+
}
127+
128+
// Parse the JSON error response
129+
textContent := result.Content[0].(mcp.TextContent)
130+
var errorResp map[string]interface{}
131+
if jsonErr := json.Unmarshal([]byte(textContent.Text), &errorResp); jsonErr != nil {
132+
t.Fatalf("Failed to parse error JSON: %v", jsonErr)
133+
}
134+
135+
if errorResp["code"] != "INVALID_ARGUMENT" {
136+
t.Errorf("Expected code 'INVALID_ARGUMENT', got: %s", errorResp["code"])
137+
}
138+
139+
if errorResp["message"] != "connect error" {
140+
t.Errorf("Expected message 'connect error', got: %s", errorResp["message"])
141+
}
142+
}
143+
144+
func TestHandleError_GRPCStatusWithBadRequest(t *testing.T) {
145+
// Create a gRPC status error with BadRequest details
146+
st := status.New(codes.InvalidArgument, "validation failed")
147+
148+
// Add BadRequest details with field violations
149+
badRequest := &errdetails.BadRequest{
150+
FieldViolations: []*errdetails.BadRequest_FieldViolation{
151+
{
152+
Field: "email",
153+
Description: "email is required",
154+
},
155+
{
156+
Field: "password",
157+
Description: "password must be at least 8 characters",
158+
},
159+
},
160+
}
161+
162+
st, err := st.WithDetails(badRequest)
163+
if err != nil {
164+
t.Fatalf("Failed to add details: %v", err)
165+
}
166+
167+
result, handleErr := HandleError(st.Err())
168+
169+
if handleErr != nil {
170+
t.Fatalf("HandleError should not return an error, got: %v", handleErr)
171+
}
172+
173+
if result == nil {
174+
t.Fatal("HandleError should return a result")
175+
}
176+
177+
// Parse the JSON error response
178+
textContent := result.Content[0].(mcp.TextContent)
179+
var errorResp map[string]interface{}
180+
if jsonErr := json.Unmarshal([]byte(textContent.Text), &errorResp); jsonErr != nil {
181+
t.Fatalf("Failed to parse error JSON: %v", jsonErr)
182+
}
183+
184+
if errorResp["code"] != "INVALID_ARGUMENT" {
185+
t.Errorf("Expected code 'INVALID_ARGUMENT', got: %s", errorResp["code"])
186+
}
187+
188+
if errorResp["message"] != "validation failed" {
189+
t.Errorf("Expected message 'validation failed', got: %s", errorResp["message"])
190+
}
191+
192+
details, hasDetails := errorResp["details"].([]interface{})
193+
if !hasDetails || len(details) == 0 {
194+
t.Error("Expected error details, got none")
195+
}
196+
197+
// Verify the BadRequest error details content
198+
if len(details) > 0 {
199+
detail := details[0].(map[string]interface{})
200+
201+
// Check @type field
202+
if typeField, ok := detail["@type"]; ok {
203+
if typeStr, ok := typeField.(string); ok {
204+
if typeStr != "type.googleapis.com/google.rpc.BadRequest" {
205+
t.Errorf("Expected @type 'type.googleapis.com/google.rpc.BadRequest', got: %s", typeStr)
206+
}
207+
}
208+
} else {
209+
t.Error("Expected @type field in error details")
210+
}
211+
212+
// Check fieldViolations field (protojson uses camelCase by default)
213+
if fieldViolationsField, ok := detail["fieldViolations"]; ok {
214+
if violations, ok := fieldViolationsField.([]interface{}); ok {
215+
if len(violations) != 2 {
216+
t.Errorf("Expected 2 field violations, got: %d", len(violations))
217+
}
218+
219+
// Check first violation
220+
if len(violations) > 0 {
221+
violation := violations[0].(map[string]interface{})
222+
if field, ok := violation["field"].(string); ok {
223+
if field != "email" {
224+
t.Errorf("Expected field 'email', got: %s", field)
225+
}
226+
}
227+
if desc, ok := violation["description"].(string); ok {
228+
if desc != "email is required" {
229+
t.Errorf("Expected description 'email is required', got: %s", desc)
230+
}
231+
}
232+
}
233+
234+
// Check second violation
235+
if len(violations) > 1 {
236+
violation := violations[1].(map[string]interface{})
237+
if field, ok := violation["field"].(string); ok {
238+
if field != "password" {
239+
t.Errorf("Expected field 'password', got: %s", field)
240+
}
241+
}
242+
if desc, ok := violation["description"].(string); ok {
243+
if desc != "password must be at least 8 characters" {
244+
t.Errorf("Expected description 'password must be at least 8 characters', got: %s", desc)
245+
}
246+
}
247+
}
248+
}
249+
} else {
250+
t.Error("Expected fieldViolations field in error details")
251+
}
252+
}
253+
}
254+
255+
func TestHandleError_NilError(t *testing.T) {
256+
result, handleErr := HandleError(nil)
257+
258+
if handleErr != nil {
259+
t.Fatalf("HandleError should not return an error for nil input, got: %v", handleErr)
260+
}
261+
262+
if result != nil {
263+
t.Fatal("HandleError should return nil result for nil error")
264+
}
265+
}

0 commit comments

Comments
 (0)