Skip to content

Commit 5bc0c68

Browse files
committed
feat(pluginhost): improve error handling with HTTP status codes for plugin calls
- Added `rpcPluginError` to encapsulate plugin errors with HTTP status codes. - Enhanced `decodeEnvelopeResult` to preserve and return detailed plugin errors with status codes. - Introduced `isPluginErrorEnvelope` to identify plugin error envelopes. - Updated plugin call logic in Unix and Windows loaders to differentiate plugin errors from system errors. - Added unit tests to verify error handling and status code preservation.
1 parent 31549af commit 5bc0c68

5 files changed

Lines changed: 124 additions & 4 deletions

File tree

internal/pluginhost/loader_unix.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
191191
C.cliproxy_free_plugin_buffer(c.api.free_buffer, response.ptr, response.len)
192192
}
193193
if rc != 0 {
194+
if isPluginErrorEnvelope(out) {
195+
return out, nil
196+
}
194197
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, int(rc), string(out))
195198
}
196199
return out, nil

internal/pluginhost/loader_windows.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
128128
_, _, _ = syscall.SyscallN(c.api.freeBuffer, response.ptr, response.len)
129129
}
130130
if rc != 0 {
131+
if isPluginErrorEnvelope(out) {
132+
return out, nil
133+
}
131134
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, rc, string(out))
132135
}
133136
return out, nil

internal/pluginhost/rpc_client.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ type rpcThinkingApplier struct {
3535
*rpcPluginAdapter
3636
}
3737

38+
type rpcPluginError struct {
39+
message string
40+
statusCode int
41+
}
42+
43+
func (e rpcPluginError) Error() string {
44+
return e.message
45+
}
46+
47+
func (e rpcPluginError) StatusCode() int {
48+
return e.statusCode
49+
}
50+
3851
type rpcResponseNormalizer struct {
3952
*rpcPluginAdapter
4053
method string
@@ -140,6 +153,9 @@ func callPlugin[T any](ctx context.Context, client pluginClient, method string,
140153
}
141154
out, errDecode := decodeEnvelopeResult[T](envelope)
142155
if errDecode != nil {
156+
if !envelope.OK {
157+
return zero, errDecode
158+
}
143159
return zero, fmt.Errorf("decode plugin result %s: %w", method, errDecode)
144160
}
145161
return out, nil
@@ -260,11 +276,26 @@ func decodeRPCEnvelope[T any](raw []byte) (T, error) {
260276
return decodeEnvelopeResult[T](envelope)
261277
}
262278

279+
func isPluginErrorEnvelope(raw []byte) bool {
280+
var envelope pluginabi.Envelope
281+
if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
282+
return false
283+
}
284+
return !envelope.OK && envelope.Error != nil
285+
}
286+
263287
func decodeEnvelopeResult[T any](envelope pluginabi.Envelope) (T, error) {
264288
var zero T
265289
if !envelope.OK {
266290
if envelope.Error != nil {
267-
return zero, fmt.Errorf("%s", envelope.Error.Message)
291+
message := strings.TrimSpace(envelope.Error.Message)
292+
if message == "" {
293+
message = "plugin call failed"
294+
}
295+
if envelope.Error.HTTPStatus > 0 {
296+
return zero, rpcPluginError{message: message, statusCode: envelope.Error.HTTPStatus}
297+
}
298+
return zero, fmt.Errorf("%s", message)
268299
}
269300
return zero, fmt.Errorf("plugin call failed")
270301
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package pluginhost
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
10+
)
11+
12+
type staticEnvelopePluginClient struct {
13+
raw []byte
14+
}
15+
16+
func (c staticEnvelopePluginClient) Call(context.Context, string, []byte) ([]byte, error) {
17+
return c.raw, nil
18+
}
19+
20+
func (c staticEnvelopePluginClient) Shutdown() {}
21+
22+
func TestDecodeEnvelopeResultPreservesPluginHTTPStatus(t *testing.T) {
23+
_, errDecode := decodeEnvelopeResult[rpcEmptyResponse](pluginabi.Envelope{
24+
OK: false,
25+
Error: &pluginabi.Error{
26+
Code: "plugin_error",
27+
Message: "license required",
28+
HTTPStatus: http.StatusForbidden,
29+
},
30+
})
31+
if errDecode == nil {
32+
t.Fatal("decodeEnvelopeResult returned nil error")
33+
}
34+
if got := errDecode.Error(); got != "license required" {
35+
t.Fatalf("error = %q, want license required", got)
36+
}
37+
statusProvider, ok := errDecode.(interface{ StatusCode() int })
38+
if !ok {
39+
t.Fatalf("error %T does not expose StatusCode", errDecode)
40+
}
41+
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
42+
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
43+
}
44+
}
45+
46+
func TestCallPluginReturnsPluginErrorWithoutMethodWrapper(t *testing.T) {
47+
raw, errMarshal := json.Marshal(pluginabi.Envelope{
48+
OK: false,
49+
Error: &pluginabi.Error{
50+
Code: "plugin_error",
51+
Message: "license required",
52+
HTTPStatus: http.StatusForbidden,
53+
},
54+
})
55+
if errMarshal != nil {
56+
t.Fatalf("marshal envelope: %v", errMarshal)
57+
}
58+
_, errCall := callPlugin[rpcEmptyResponse](context.Background(), staticEnvelopePluginClient{raw: raw}, pluginabi.MethodExecutorExecuteStream, rpcEmptyResponse{})
59+
if errCall == nil {
60+
t.Fatal("callPlugin returned nil error")
61+
}
62+
if got := errCall.Error(); got != "license required" {
63+
t.Fatalf("error = %q, want license required", got)
64+
}
65+
statusProvider, ok := errCall.(interface{ StatusCode() int })
66+
if !ok {
67+
t.Fatalf("error %T does not expose StatusCode", errCall)
68+
}
69+
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
70+
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
71+
}
72+
}
73+
74+
func TestIsPluginErrorEnvelopeAcceptsNonzeroReturnEnvelope(t *testing.T) {
75+
raw := marshalRPCError("plugin_error", "upstream failed")
76+
if !isPluginErrorEnvelope(raw) {
77+
t.Fatalf("isPluginErrorEnvelope(%s) = false, want true", raw)
78+
}
79+
if isPluginErrorEnvelope([]byte(`not json`)) {
80+
t.Fatal("isPluginErrorEnvelope accepted invalid JSON")
81+
}
82+
}

sdk/pluginabi/types.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ type Envelope struct {
8686
}
8787

8888
type Error struct {
89-
Code string `json:"code"`
90-
Message string `json:"message"`
91-
Retryable bool `json:"retryable,omitempty"`
89+
Code string `json:"code"`
90+
Message string `json:"message"`
91+
Retryable bool `json:"retryable,omitempty"`
92+
HTTPStatus int `json:"http_status,omitempty"`
9293
}

0 commit comments

Comments
 (0)