diff --git a/internal/cmd/upload/upload.go b/internal/cmd/upload/upload.go index 4614ebe..6ed02c6 100644 --- a/internal/cmd/upload/upload.go +++ b/internal/cmd/upload/upload.go @@ -17,6 +17,7 @@ import ( "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/internal/util" "github.com/zeabur/cli/pkg/constant" + pkgutil "github.com/zeabur/cli/pkg/util" ) type Options struct{} @@ -100,7 +101,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("failed to create upload session: status code %d", resp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to create upload session", resp) } var uploadSession struct { @@ -132,7 +133,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer uploadResp.Body.Close() if uploadResp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to upload to S3: status code %d", uploadResp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to upload to S3", uploadResp) } // Step 4: Prepare upload for deployment @@ -164,7 +165,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to prepare upload: status code %d", resp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to prepare upload", resp) } return uploadSession.UploadID, nil diff --git a/pkg/api/service.go b/pkg/api/service.go index e126f60..06bf0ae 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/viper" "github.com/zeabur/cli/pkg/constant" "github.com/zeabur/cli/pkg/model" + "github.com/zeabur/cli/pkg/util" ) func (c *client) ListServices(ctx context.Context, projectID string, skip, limit int) (*model.Connection[model.Service], error) { @@ -392,7 +393,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("failed to create upload session: status code %d", resp.StatusCode) + return nil, util.FormatHTTPError("failed to create upload session", resp) } var uploadSession struct { @@ -424,7 +425,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer uploadResp.Body.Close() if uploadResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to upload to S3: status code %d", uploadResp.StatusCode) + return nil, util.FormatHTTPError("failed to upload to S3", uploadResp) } // Step 4: Prepare upload for deployment @@ -460,7 +461,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to prepare upload: status code %d", resp.StatusCode) + return nil, util.FormatHTTPError("failed to prepare upload", resp) } return nil, nil diff --git a/pkg/util/http.go b/pkg/util/http.go new file mode 100644 index 0000000..520a4b5 --- /dev/null +++ b/pkg/util/http.go @@ -0,0 +1,40 @@ +package util + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// FormatHTTPError reads a non-2xx HTTP response and returns an error that +// preserves whatever the server actually said. +// +// The Zeabur API returns JSON bodies of the form +// +// {"code": "REQUIRE_PAID_PLAN", "error": "Upload size ... exceeds ..."} +// +// for client-visible failures. When that shape is present, the message and +// (if available) code are surfaced. Otherwise the raw body is included so +// the user still has something to act on instead of a bare status code. +// +// action is a short verb phrase describing what failed, e.g. "create upload session". +func FormatHTTPError(action string, resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + + var errResp struct { + Code string `json:"code"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { + if errResp.Code != "" { + return fmt.Errorf("%s: %s (code: %s, status: %d)", action, errResp.Error, errResp.Code, resp.StatusCode) + } + return fmt.Errorf("%s: %s (status: %d)", action, errResp.Error, resp.StatusCode) + } + + if len(body) > 0 { + return fmt.Errorf("%s: status code %d, body: %s", action, resp.StatusCode, string(body)) + } + return fmt.Errorf("%s: status code %d", action, resp.StatusCode) +} diff --git a/pkg/util/http_test.go b/pkg/util/http_test.go new file mode 100644 index 0000000..e9ae823 --- /dev/null +++ b/pkg/util/http_test.go @@ -0,0 +1,76 @@ +package util_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zeabur/cli/pkg/util" +) + +func TestFormatHTTPError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + wantSubstr []string + }{ + { + name: "structured error with code (REQUIRE_PAID_PLAN)", + statusCode: 400, + body: `{"code":"REQUIRE_PAID_PLAN","error":"Upload size 53048320 bytes exceeds your plan's limit of 52428800 bytes.","limit":52428800,"content_length":53048320}`, + wantSubstr: []string{ + "do thing", + "Upload size 53048320 bytes exceeds", + "code: REQUIRE_PAID_PLAN", + "status: 400", + }, + }, + { + name: "structured error without code", + statusCode: 403, + body: `{"error":"forbidden"}`, + wantSubstr: []string{"do thing", "forbidden", "status: 403"}, + }, + { + name: "non-JSON body falls back to raw body", + statusCode: 502, + body: "Bad Gateway", + wantSubstr: []string{"do thing", "status code 502", "Bad Gateway"}, + }, + { + name: "empty body falls back to status code only", + statusCode: 504, + body: "", + wantSubstr: []string{"do thing", "status code 504"}, + }, + { + name: "JSON without error field falls back to raw body", + statusCode: 400, + body: `{"foo":"bar"}`, + wantSubstr: []string{"status code 400", `{"foo":"bar"}`}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{ + StatusCode: tc.statusCode, + Body: io.NopCloser(strings.NewReader(tc.body)), + } + + err := util.FormatHTTPError("do thing", resp) + assert.Error(t, err) + for _, s := range tc.wantSubstr { + assert.Contains(t, err.Error(), s) + } + }) + } +}