From e7fb5caa0c17b2fce87f2fb3b4e67d18469494c3 Mon Sep 17 00:00:00 2001 From: jarradg Date: Wed, 21 Oct 2026 11:05:56 -0400 Subject: [PATCH 1/2] feat(cli): preserve structured Brev HTTP error codes --- pkg/store/http.go | 25 +++++++++++++++++++++++++ pkg/store/http_response_error_test.go | 23 +++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pkg/store/http.go b/pkg/store/http.go index 5c452865..504564c8 100644 --- a/pkg/store/http.go +++ b/pkg/store/http.go @@ -2,6 +2,7 @@ package store import ( "encoding/json" + stderrors "errors" "fmt" "log" "net/http" @@ -311,9 +312,33 @@ type BrevDeployErrorList struct { type BrevDeployError struct { Kind string `json:"type"` + Code string `json:"code"` Message string `json:"message"` } +// IsHTTPErrorCode matches a Brev error envelope through ordinary error wrapping. +// Callers supply the exact status and Brev-owned code they handle; malformed, +// unstructured, and unrelated errors keep their normal failure path. +func IsHTTPErrorCode(err error, status int, code string) bool { + if code == "" { + return false + } + var responseErr *HTTPResponseError + if !stderrors.As(err, &responseErr) || responseErr.Response == nil || responseErr.Response.StatusCode() != status { + return false + } + var envelope BrevDeployErrorList + if json.Unmarshal(responseErr.Response.Body(), &envelope) != nil { + return false + } + for _, item := range envelope.Errors { + if item.Code == code { + return true + } + } + return false +} + type HTTPResponseError struct { Response *resty.Response } diff --git a/pkg/store/http_response_error_test.go b/pkg/store/http_response_error_test.go index 489a237f..aea6c6fa 100644 --- a/pkg/store/http_response_error_test.go +++ b/pkg/store/http_response_error_test.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -11,6 +12,28 @@ import ( "github.com/stretchr/testify/require" ) +func TestIsHTTPErrorCode(t *testing.T) { + cases := []struct { + name string + status int + body string + code string + want bool + }{ + {"matching Brev code", http.StatusBadRequest, `{"errors":[{"type":"BadRequestError","code":"WORKSPACE_ALREADY_EXISTS","message":"Request could not be completed"}]}`, "WORKSPACE_ALREADY_EXISTS", true}, + {"other code", http.StatusBadRequest, `{"errors":[{"type":"BadRequestError","code":"OTHER","message":"Request could not be completed"}]}`, "WORKSPACE_ALREADY_EXISTS", false}, + {"wrong status", http.StatusInternalServerError, `{"errors":[{"type":"InternalServerError","code":"WORKSPACE_ALREADY_EXISTS","message":"Request could not be completed"}]}`, "WORKSPACE_ALREADY_EXISTS", false}, + {"unstructured body", http.StatusBadRequest, `{"message":"WORKSPACE_ALREADY_EXISTS"}`, "WORKSPACE_ALREADY_EXISTS", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := fmt.Errorf("create workspace: %w", doRequestWithToken(t, tc.status, tc.body, "")) + assert.Equal(t, tc.want, IsHTTPErrorCode(err, http.StatusBadRequest, tc.code)) + }) + } + assert.False(t, IsHTTPErrorCode(fmt.Errorf("WORKSPACE_ALREADY_EXISTS"), http.StatusBadRequest, "WORKSPACE_ALREADY_EXISTS")) +} + const unauthorizedBody = `{"errors":[{"type":"UnauthorizedError"}]}` func doRequestWithToken(t *testing.T, status int, body, token string) *HTTPResponseError { From 91a3d181f47e98c0fef6b6ac2ca97eb6f7f930dc Mon Sep 17 00:00:00 2001 From: jarrad Date: Wed, 21 Oct 2026 16:52:44 -0400 Subject: [PATCH 2/2] fix(start): guide coded duplicate workspace failures (#517) --- pkg/cmd/start/start.go | 4 +- pkg/cmd/start/start_test.go | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index c81a1b9a..3636cbb3 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -3,6 +3,7 @@ package start import ( "fmt" + "net/http" "net/url" "os" "path/filepath" @@ -331,6 +332,7 @@ func createEmptyWorkspace(user *entity.User, apiKeyAuth bool, t *terminal.Termin s := t.NewSpinner() s.Suffix = " Creating your instance. Hang tight 🤙" s.Start() + defer s.Stop() w, err := startStore.CreateWorkspace(orgID, cwOptions) if err != nil { return breverrors.WrapAndTrace(err) @@ -678,7 +680,7 @@ func runSingleStart(t *terminal.Terminal, names []string, name, setupScript, set InstanceType: gpu, }, startStore) if err != nil { - if strings.Contains(err.Error(), "duplicate instance with name") { + if store.IsHTTPErrorCode(err, http.StatusBadRequest, "WORKSPACE_ALREADY_EXISTS") { t.Vprint(t.Yellow("try running:")) t.Vprint(t.Yellow("\tbrev start --name [different name] [repo] # or")) t.Vprint(t.Yellow("\tbrev delete [name]")) diff --git a/pkg/cmd/start/start_test.go b/pkg/cmd/start/start_test.go index 3b8d606d..965b9281 100644 --- a/pkg/cmd/start/start_test.go +++ b/pkg/cmd/start/start_test.go @@ -1,14 +1,92 @@ package start import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" + resty "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +type failingCreateStartStore struct { + StartStore + createErr error +} + +func (s failingCreateStartStore) GetAuthTokens() (*entity.AuthTokens, error) { + return nil, nil +} + +func (s failingCreateStartStore) GetCurrentUser() (*entity.User, error) { + return &entity.User{ID: "user-1"}, nil +} + +func (s failingCreateStartStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) { + return &entity.Organization{ID: "org-1"}, nil +} + +func (s failingCreateStartStore) CreateWorkspace(_ string, _ *store.CreateWorkspacesOptions) (*entity.Workspace, error) { + return nil, s.createErr +} + +func startHTTPError(t *testing.T, status int, body string) error { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + response, err := resty.New().R().Post(server.URL) + require.NoError(t, err) + return fmt.Errorf("create failed: %w", store.NewHTTPResponseError(response)) +} + +func TestRunSingleStartDuplicateGuidanceUsesWorkspaceCode(t *testing.T) { + cases := []struct { + name string + status int + body string + wantGuidance bool + }{ + {"coded duplicate", http.StatusBadRequest, `{"errors":[{"type":"BadRequestError","code":"WORKSPACE_ALREADY_EXISTS","message":"Bad Request"}]}`, true}, + {"generic bad request", http.StatusBadRequest, `{"errors":[{"type":"BadRequestError","message":"Bad Request"}]}`, false}, + {"unrelated error with duplicate words", http.StatusBadRequest, `{"errors":[{"type":"BadRequestError","code":"OTHER","message":"duplicate instance with name"}]}`, false}, + {"wrong status", http.StatusInternalServerError, `{"errors":[{"type":"InternalServerError","code":"WORKSPACE_ALREADY_EXISTS","message":"Internal Server Error"}]}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + createErr := startHTTPError(t, tc.status, tc.body) + read, write, err := os.Pipe() + require.NoError(t, err) + original := os.Stdout + os.Stdout = write + t.Cleanup(func() { + os.Stdout = original + _ = read.Close() + _ = write.Close() + }) + term := terminal.New() + runErr := runSingleStart(term, nil, "existing-name", "", "", "", "", "", true, false, failingCreateStartStore{createErr: createErr}) + require.Error(t, runErr) + require.ErrorContains(t, runErr, "create failed") + require.NoError(t, write.Close()) + var output bytes.Buffer + _, err = output.ReadFrom(read) + require.NoError(t, err) + assert.Equal(t, tc.wantGuidance, strings.Contains(output.String(), "brev delete [name]"), output.String()) + }) + } +} + func TestMakeNewWorkspaceFromURL(t *testing.T) { gitTruth := "github.com:brevdev/brev-cli.git" nameTruth := "brev-cli"