diff --git a/go/porcelain/deploy.go b/go/porcelain/deploy.go index 7fb675dc..35f6097a 100644 --- a/go/porcelain/deploy.go +++ b/go/porcelain/deploy.go @@ -82,7 +82,8 @@ type DeployOptions struct { BuildDir string LargeMediaEnabled bool - IsDraft bool + IsDraft bool + SkipRetry bool Title string Branch string @@ -348,12 +349,14 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy * return deploy, nil } - if err := n.uploadFiles(ctx, deploy, options.files, options.Observer, fileUpload, options.UploadTimeout); err != nil { + skipRetry := options.SkipRetry + + if err := n.uploadFiles(ctx, deploy, options.files, options.Observer, fileUpload, options.UploadTimeout, skipRetry); err != nil { return nil, err } if options.functions != nil { - if err := n.uploadFiles(ctx, deploy, options.functions, options.Observer, functionUpload, options.UploadTimeout); err != nil { + if err := n.uploadFiles(ctx, deploy, options.functions, options.Observer, functionUpload, options.UploadTimeout, skipRetry); err != nil { return nil, err } } @@ -405,7 +408,7 @@ func (n *Netlify) WaitUntilDeployLive(ctx context.Context, d *models.Deploy) (*m return n.waitForState(ctx, d, "ready") } -func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *deployFiles, observer DeployObserver, t uploadType, timeout time.Duration) error { +func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *deployFiles, observer DeployObserver, t uploadType, timeout time.Duration, skipRetry bool) error { sharedErr := &uploadError{err: nil, mutex: &sync.Mutex{}} sem := make(chan int, n.uploadLimit) wg := &sync.WaitGroup{} @@ -435,7 +438,7 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl select { case sem <- 1: wg.Add(1) - go n.uploadFile(ctx, d, file, observer, t, timeout, wg, sem, sharedErr) + go n.uploadFile(ctx, d, file, observer, t, timeout, wg, sem, sharedErr, skipRetry) case <-ctx.Done(): log.Info("Context terminated, aborting file upload") return errors.Wrap(ctx.Err(), "aborted file upload early") @@ -455,7 +458,7 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl return sharedErr.err } -func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundle, c DeployObserver, t uploadType, timeout time.Duration, wg *sync.WaitGroup, sem chan int, sharedErr *uploadError) { +func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundle, c DeployObserver, t uploadType, timeout time.Duration, wg *sync.WaitGroup, sem chan int, sharedErr *uploadError, skipRetry bool) { defer func() { wg.Done() <-sem @@ -543,10 +546,16 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl context.GetLogger(ctx).WithError(operationError).Errorf("Failed to upload file %v", f.Name) apiErr, ok := operationError.(deployApiError) - if ok && apiErr.Code() == 401 { - sharedErr.mutex.Lock() - sharedErr.err = operationError - sharedErr.mutex.Unlock() + if ok { + if apiErr.Code() == 401 { + sharedErr.mutex.Lock() + sharedErr.err = operationError + sharedErr.mutex.Unlock() + } + + if skipRetry && (apiErr.Code() == 400 || apiErr.Code() == 422) { + operationError = backoff.Permanent(operationError) + } } } diff --git a/go/porcelain/deploy_test.go b/go/porcelain/deploy_test.go index a5de80d2..3da66d4e 100644 --- a/go/porcelain/deploy_test.go +++ b/go/porcelain/deploy_test.go @@ -287,7 +287,7 @@ func TestUploadFiles_Cancelation(t *testing.T) { for _, bundle := range files.Files { d.Required = append(d.Required, bundle.Sum) } - err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute) + err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute, false) require.ErrorIs(t, err, gocontext.Canceled) } @@ -317,10 +317,131 @@ func TestUploadFiles_Errors(t *testing.T) { for _, bundle := range files.Files { d.Required = append(d.Required, bundle.Sum) } - err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute) + err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute, false) require.Equal(t, err.Error(), "[PUT /deploys/{deploy_id}/files/{path}][500] uploadDeployFile default &{Code:0 Message:}") } +func TestUploadFiles422Error_SkipsRetry(t *testing.T) { + attempts := 0 + ctx := gocontext.Background() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + defer func() { + attempts++ + }() + + rw.Header().Set("Content-Type", "application/json; charset=utf-8") + rw.WriteHeader(http.StatusUnprocessableEntity) + rw.Write([]byte(`{"message": "Unprocessable Entity", "code": 422 }`)) + })) + defer server.Close() + + // File upload: + hu, _ := url.Parse(server.URL) + tr := apiClient.NewWithClient(hu.Host, "/api/v1", []string{"http"}, http.DefaultClient) + client := NewRetryable(tr, strfmt.Default, 1) + client.uploadLimit = 1 + ctx = context.WithAuthInfo(ctx, apiClient.BearerToken("token")) + + // Create some files to deploy + dir, err := ioutil.TempDir("", "deploy") + require.NoError(t, err) + defer os.RemoveAll(dir) + require.NoError(t, ioutil.WriteFile(filepath.Join(dir, "foo.html"), []byte("Hello"), 0644)) + + files, err := walk(dir, nil, false, false) + require.NoError(t, err) + d := &models.Deploy{} + for _, bundle := range files.Files { + d.Required = append(d.Required, bundle.Sum) + } + // Set SkipRetry to true + err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute, true) + require.ErrorContains(t, err, "Code:422 Message:Unprocessable Entity") + require.Equal(t, attempts, 1) +} + +func TestUploadFunctions422Error_SkipsRetry(t *testing.T) { + attempts := 0 + ctx := gocontext.Background() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + defer func() { + attempts++ + }() + + rw.Header().Set("Content-Type", "application/json; charset=utf-8") + rw.WriteHeader(http.StatusUnprocessableEntity) + rw.Write([]byte(`{"message": "Unprocessable Entity", "code": 422 }`)) + })) + defer server.Close() + + // Function upload: + hu, _ := url.Parse(server.URL) + tr := apiClient.NewWithClient(hu.Host, "/api/v1", []string{"http"}, http.DefaultClient) + client := NewRetryable(tr, strfmt.Default, 1) + client.uploadLimit = 1 + apiCtx := context.WithAuthInfo(ctx, apiClient.BearerToken("token")) + + dir, err := ioutil.TempDir("", "deploy") + functionsPath := filepath.Join(dir, ".netlify", "functions") + os.MkdirAll(functionsPath, os.ModePerm) + require.NoError(t, err) + defer os.RemoveAll(dir) + require.NoError(t, ioutil.WriteFile(filepath.Join(functionsPath, "foo.js"), []byte("module.exports = () => {}"), 0644)) + + files, _, _, err := bundle(ctx, functionsPath, mockObserver{}) + require.NoError(t, err) + d := &models.Deploy{} + for _, bundle := range files.Files { + d.RequiredFunctions = append(d.RequiredFunctions, bundle.Sum) + } + // Set SkipRetry to true + err = client.uploadFiles(apiCtx, d, files, nil, functionUpload, time.Minute, true) + require.ErrorContains(t, err, "Code:422 Message:Unprocessable Entity") + require.Equal(t, attempts, 1) +} + +func TestUploadFiles400Error_NoSkipRetry(t *testing.T) { + attempts := 0 + ctx := gocontext.Background() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + defer func() { + attempts++ + }() + + rw.Header().Set("Content-Type", "application/json; charset=utf-8") + rw.WriteHeader(http.StatusBadRequest) + rw.Write([]byte(`{"message": "Bad Request", "code": 400 }`)) + return + })) + defer server.Close() + + hu, _ := url.Parse(server.URL) + tr := apiClient.NewWithClient(hu.Host, "/api/v1", []string{"http"}, http.DefaultClient) + client := NewRetryable(tr, strfmt.Default, 1) + client.uploadLimit = 1 + ctx = context.WithAuthInfo(ctx, apiClient.BearerToken("token")) + + // Create some files to deploy + dir, err := ioutil.TempDir("", "deploy") + require.NoError(t, err) + defer os.RemoveAll(dir) + require.NoError(t, ioutil.WriteFile(filepath.Join(dir, "foo.html"), []byte("Hello"), 0644)) + + files, err := walk(dir, nil, false, false) + require.NoError(t, err) + d := &models.Deploy{} + for _, bundle := range files.Files { + d.Required = append(d.Required, bundle.Sum) + } + // Set SkipRetry to false + err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute, false) + require.ErrorContains(t, err, "Code:400 Message:Bad Request") + require.Greater(t, attempts, 1) +} + func TestUploadFiles_SkipEqualFiles(t *testing.T) { ctx := gocontext.Background() @@ -377,11 +498,11 @@ func TestUploadFiles_SkipEqualFiles(t *testing.T) { d.Required = []string{files.Sums["a.html"]} d.RequiredFunctions = []string{functions.Sums["a"]} - err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute) + err = client.uploadFiles(ctx, d, files, nil, fileUpload, time.Minute, false) require.NoError(t, err) assert.Equal(t, 1, serverRequests) - err = client.uploadFiles(ctx, d, functions, nil, functionUpload, time.Minute) + err = client.uploadFiles(ctx, d, functions, nil, functionUpload, time.Minute, false) require.NoError(t, err) assert.Equal(t, 2, serverRequests) } @@ -437,7 +558,7 @@ func TestUploadFunctions_RetryCountHeader(t *testing.T) { d.RequiredFunctions = append(d.RequiredFunctions, bundle.Sum) } - require.NoError(t, client.uploadFiles(apiCtx, d, files, nil, functionUpload, time.Minute)) + require.NoError(t, client.uploadFiles(apiCtx, d, files, nil, functionUpload, time.Minute, false)) } func TestBundle(t *testing.T) {