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.
This commit is contained in:
Luis Pater
2026-06-21 10:12:10 +08:00
parent 31549af180
commit 5bc0c68284
5 changed files with 124 additions and 4 deletions
+3
View File
@@ -191,6 +191,9 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
C.cliproxy_free_plugin_buffer(c.api.free_buffer, response.ptr, response.len)
}
if rc != 0 {
if isPluginErrorEnvelope(out) {
return out, nil
}
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, int(rc), string(out))
}
return out, nil
+3
View File
@@ -128,6 +128,9 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
_, _, _ = syscall.SyscallN(c.api.freeBuffer, response.ptr, response.len)
}
if rc != 0 {
if isPluginErrorEnvelope(out) {
return out, nil
}
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, rc, string(out))
}
return out, nil
+32 -1
View File
@@ -35,6 +35,19 @@ type rpcThinkingApplier struct {
*rpcPluginAdapter
}
type rpcPluginError struct {
message string
statusCode int
}
func (e rpcPluginError) Error() string {
return e.message
}
func (e rpcPluginError) StatusCode() int {
return e.statusCode
}
type rpcResponseNormalizer struct {
*rpcPluginAdapter
method string
@@ -140,6 +153,9 @@ func callPlugin[T any](ctx context.Context, client pluginClient, method string,
}
out, errDecode := decodeEnvelopeResult[T](envelope)
if errDecode != nil {
if !envelope.OK {
return zero, errDecode
}
return zero, fmt.Errorf("decode plugin result %s: %w", method, errDecode)
}
return out, nil
@@ -260,11 +276,26 @@ func decodeRPCEnvelope[T any](raw []byte) (T, error) {
return decodeEnvelopeResult[T](envelope)
}
func isPluginErrorEnvelope(raw []byte) bool {
var envelope pluginabi.Envelope
if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
return false
}
return !envelope.OK && envelope.Error != nil
}
func decodeEnvelopeResult[T any](envelope pluginabi.Envelope) (T, error) {
var zero T
if !envelope.OK {
if envelope.Error != nil {
return zero, fmt.Errorf("%s", envelope.Error.Message)
message := strings.TrimSpace(envelope.Error.Message)
if message == "" {
message = "plugin call failed"
}
if envelope.Error.HTTPStatus > 0 {
return zero, rpcPluginError{message: message, statusCode: envelope.Error.HTTPStatus}
}
return zero, fmt.Errorf("%s", message)
}
return zero, fmt.Errorf("plugin call failed")
}
@@ -0,0 +1,82 @@
package pluginhost
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
)
type staticEnvelopePluginClient struct {
raw []byte
}
func (c staticEnvelopePluginClient) Call(context.Context, string, []byte) ([]byte, error) {
return c.raw, nil
}
func (c staticEnvelopePluginClient) Shutdown() {}
func TestDecodeEnvelopeResultPreservesPluginHTTPStatus(t *testing.T) {
_, errDecode := decodeEnvelopeResult[rpcEmptyResponse](pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: "plugin_error",
Message: "license required",
HTTPStatus: http.StatusForbidden,
},
})
if errDecode == nil {
t.Fatal("decodeEnvelopeResult returned nil error")
}
if got := errDecode.Error(); got != "license required" {
t.Fatalf("error = %q, want license required", got)
}
statusProvider, ok := errDecode.(interface{ StatusCode() int })
if !ok {
t.Fatalf("error %T does not expose StatusCode", errDecode)
}
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
}
}
func TestCallPluginReturnsPluginErrorWithoutMethodWrapper(t *testing.T) {
raw, errMarshal := json.Marshal(pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: "plugin_error",
Message: "license required",
HTTPStatus: http.StatusForbidden,
},
})
if errMarshal != nil {
t.Fatalf("marshal envelope: %v", errMarshal)
}
_, errCall := callPlugin[rpcEmptyResponse](context.Background(), staticEnvelopePluginClient{raw: raw}, pluginabi.MethodExecutorExecuteStream, rpcEmptyResponse{})
if errCall == nil {
t.Fatal("callPlugin returned nil error")
}
if got := errCall.Error(); got != "license required" {
t.Fatalf("error = %q, want license required", got)
}
statusProvider, ok := errCall.(interface{ StatusCode() int })
if !ok {
t.Fatalf("error %T does not expose StatusCode", errCall)
}
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
}
}
func TestIsPluginErrorEnvelopeAcceptsNonzeroReturnEnvelope(t *testing.T) {
raw := marshalRPCError("plugin_error", "upstream failed")
if !isPluginErrorEnvelope(raw) {
t.Fatalf("isPluginErrorEnvelope(%s) = false, want true", raw)
}
if isPluginErrorEnvelope([]byte(`not json`)) {
t.Fatal("isPluginErrorEnvelope accepted invalid JSON")
}
}
+4 -3
View File
@@ -86,7 +86,8 @@ type Envelope struct {
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
}