diff --git a/internal/pluginhost/loader_unix.go b/internal/pluginhost/loader_unix.go --- a/internal/pluginhost/loader_unix.go +++ b/internal/pluginhost/loader_unix.go @@ -191,6 +191,9 @@ 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 diff --git a/internal/pluginhost/loader_windows.go b/internal/pluginhost/loader_windows.go --- a/internal/pluginhost/loader_windows.go +++ b/internal/pluginhost/loader_windows.go @@ -128,6 +128,9 @@ _, _, _ = 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 diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go --- a/internal/pluginhost/rpc_client.go +++ b/internal/pluginhost/rpc_client.go @@ -35,6 +35,19 @@ *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 @@ } 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 @@ 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") } diff --git a/internal/pluginhost/rpc_client_error_test.go b/internal/pluginhost/rpc_client_error_test.go new file mode 100644 --- /dev/null +++ b/internal/pluginhost/rpc_client_error_test.go @@ -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") + } +} diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go --- a/sdk/pluginabi/types.go +++ b/sdk/pluginabi/types.go @@ -86,7 +86,8 @@ } 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"` }