From 962e97e87211b98e46be4457e155f6ff9d5ff7ed Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Wed, 29 Jul 2026 16:21:10 -0400 Subject: [PATCH] Preserve raw API error codes in JSON agent output. Keep schema_mutation_blocked (and other API codes) on planetscale.Error and surface them in GlobalJSONError instead of collapsing to COMMAND_FAILED. Co-authored-by: Cursor --- internal/cmdutil/json_error.go | 15 +++++++++++++++ internal/cmdutil/json_error_test.go | 28 ++++++++++++++++++++++++++++ internal/planetscale/client.go | 9 +++++++-- internal/planetscale/client_test.go | 18 ++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index 5873a2c0..8bddb6f0 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -7,6 +7,8 @@ import ( "io" "regexp" "strings" + + "github.com/planetscale/cli/internal/planetscale" ) // JSONErrorIssue mirrors the issue shape used by auth check, sql, and import @@ -49,6 +51,7 @@ func GlobalJSONError(err error) JSONErrorResponse { msg := err.Error() status := "error" code := "COMMAND_FAILED" + apiCode := "" if cmdErr, ok := errors.AsType[*Error](err); ok { if cmdErr.Msg != "" { @@ -58,6 +61,9 @@ func GlobalJSONError(err error) JSONErrorResponse { status = "action_required" } } + if perr, ok := err.(*planetscale.Error); ok { + apiCode = perr.APICode + } msg = ansiEscape.ReplaceAllString(msg, "") @@ -169,6 +175,15 @@ func GlobalJSONError(err error) JSONErrorResponse { } } + if apiCode != "" { + code = apiCode + if apiCode == "schema_mutation_blocked" { + nextSteps = []string{ + "Wait for the active vtctld mutation or deploy to finish, then retry", + } + } + } + return JSONErrorResponse{ Status: status, Error: msg, diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index 5e84d91d..f5f17c8a 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "testing" + + "github.com/planetscale/cli/internal/planetscale" ) func TestGlobalJSONErrorAuthRequired(t *testing.T) { @@ -140,6 +142,32 @@ func TestGlobalJSONErrorGenericFallback(t *testing.T) { } } +func TestGlobalJSONErrorSchemaMutationBlocked(t *testing.T) { + resp := GlobalJSONError(&planetscale.Error{ + APICode: "schema_mutation_blocked", + }) + if resp.Code() != "schema_mutation_blocked" { + t.Fatalf("code = %q", resp.Code()) + } + if len(resp.NextSteps) != 1 || resp.NextSteps[0] != "Wait for the active vtctld mutation or deploy to finish, then retry" { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } + for _, step := range resp.NextSteps { + if step == AgentAuthCheckCmd() || step == AgentAuthLoginCmd() { + t.Fatalf("schema_mutation_blocked should not suggest auth, got %#v", resp.NextSteps) + } + } +} + +func TestGlobalJSONErrorPreservesOtherAPICodes(t *testing.T) { + resp := GlobalJSONError(&planetscale.Error{ + APICode: "unprocessable", + }) + if resp.Code() != "unprocessable" { + t.Fatalf("code = %q", resp.Code()) + } +} + func TestGlobalJSONErrorIssuesMirrorError(t *testing.T) { resp := GlobalJSONError(errors.New("something went wrong")) if len(resp.Issues) != 1 { diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index ae2f4303..ffd8ae00 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -462,8 +462,9 @@ func (c *Client) handleResponse(ctx context.Context, res *http.Response, v inter } return &Error{ - msg: errorRes.Message, - Code: errCode, + msg: errorRes.Message, + Code: errCode, + APICode: errorRes.Code, } } @@ -661,6 +662,10 @@ type Error struct { // Code specifies the error code. i.e; NotFound, RateLimited, etc... Code ErrorCode + // APICode is the raw code from the API error JSON body (e.g. + // "schema_mutation_blocked"). Empty when the response had no code field. + APICode string + // Meta contains additional information depending on the error code. As an // example, if the Code is "ErrResponseMalformed", the map will be: ["body"] // = "body of the response" diff --git a/internal/planetscale/client_test.go b/internal/planetscale/client_test.go index 5826b3a0..c966774c 100644 --- a/internal/planetscale/client_test.go +++ b/internal/planetscale/client_test.go @@ -70,6 +70,19 @@ func TestDo(t *testing.T) { Code: ErrInvalid, }, }, + { + desc: "preserves raw API code for schema_mutation_blocked", + statusCode: http.StatusUnprocessableEntity, + method: http.MethodPost, + response: `{ + "code": "schema_mutation_blocked", + "message": "MoveTables create in progress" + }`, + expectedError: &Error{ + msg: "MoveTables create in progress", + APICode: "schema_mutation_blocked", + }, + }, { desc: "returns ErrorResponse for 5xx errors", statusCode: http.StatusInternalServerError, @@ -165,6 +178,11 @@ func TestDo(t *testing.T) { if err != nil { if tt.expectedError != nil { c.Assert(tt.expectedError.Error(), qt.Equals, err.Error()) + if want, ok := tt.expectedError.(*Error); ok && want.APICode != "" { + got, ok := err.(*Error) + c.Assert(ok, qt.IsTrue) + c.Assert(got.APICode, qt.Equals, want.APICode) + } } }