diff --git a/NOTICE b/NOTICE index 883c24ab787..197d8a4c37e 100644 --- a/NOTICE +++ b/NOTICE @@ -103,6 +103,10 @@ golang.org/x/mod - https://github.com/golang/mod Copyright 2009 The Go Authors. License - https://github.com/golang/mod/blob/master/LICENSE +golang.org/x/net - https://github.com/golang/net +Copyright 2009 The Go Authors. +License - https://github.com/golang/net/blob/master/LICENSE + golang.org/x/oauth2 - https://github.com/golang/oauth2 Copyright 2009 The Go Authors. License - https://github.com/golang/oauth2/blob/master/LICENSE diff --git a/experimental/ssh/internal/client/releases.go b/experimental/ssh/internal/client/releases.go index 6c6ad800aa2..0e24f8ac586 100644 --- a/experimental/ssh/internal/client/releases.go +++ b/experimental/ssh/internal/client/releases.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/libs/filer" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" + "golang.org/x/net/http2" ) type releaseProvider func(ctx context.Context, architecture, version, releasesDir string) (io.ReadCloser, error) @@ -65,6 +66,14 @@ func uploadReleases(ctx context.Context, workspaceFiler filer.Filer, getRelease // producing the filerRoot/remoteSubFolder/*archive-contents* structure, with 'databricks' binary inside. err = workspaceFiler.Write(ctx, remoteArchivePath, releaseReader, filer.OverwriteIfExists, filer.CreateParentDirectories) if err != nil { + if isStreamResetError(err) { + return fmt.Errorf("failed to upload file %s to workspace: %w\n\n"+ + "The connection was closed before the upload finished. "+ + "This is usually caused by a network intermediary (corporate egress proxy, VPN, or firewall/WAF) "+ + "enforcing a request-body size limit on POSTs to *.cloud.databricks.com. "+ + "Try running this command from a network without such restrictions.", + remoteArchivePath, err) + } return fmt.Errorf("failed to upload file %s to workspace: %w", remoteArchivePath, err) } log.Infof(ctx, "Successfully uploaded %s to workspace", remoteBinaryPath) @@ -73,6 +82,20 @@ func uploadReleases(ctx context.Context, workspaceFiler filer.Filer, getRelease return nil } +// isStreamResetError reports whether err looks like an HTTP/2 stream reset from +// the server, which typically means an edge proxy or the workspace-files import +// endpoint rejected the request body (e.g. body-size limit). The string fallback +// catches cases where a transport layer re-formats the http2 error before it +// reaches us, losing the typed value but preserving the message shape. +func isStreamResetError(err error) bool { + var se http2.StreamError + if errors.As(err, &se) { + return true + } + msg := err.Error() + return strings.Contains(msg, "stream error") && strings.Contains(msg, "stream ID") +} + func getReleaseName(architecture, version string) string { if strings.Contains(version, "dev") { return fmt.Sprintf("databricks_cli_linux_%s.zip", architecture) diff --git a/experimental/ssh/internal/client/releases_test.go b/experimental/ssh/internal/client/releases_test.go new file mode 100644 index 00000000000..6444a779c60 --- /dev/null +++ b/experimental/ssh/internal/client/releases_test.go @@ -0,0 +1,45 @@ +package client + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/net/http2" +) + +func TestIsStreamResetError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "typed http2.StreamError wrapped", + err: fmt.Errorf(`Post "https://example/api/2.0/workspace-files/import-file/...": %w`, http2.StreamError{StreamID: 15, Code: http2.ErrCodeNo}), + want: true, + }, + { + name: "stringified stream error", + err: errors.New("stream error: stream ID 15; NO_ERROR; received from peer"), + want: true, + }, + { + name: "unrelated error", + err: errors.New("connection refused"), + want: false, + }, + { + name: "API error message", + err: errors.New("RESOURCE_DOES_NOT_EXIST: path does not exist"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isStreamResetError(tt.err)) + }) + } +} diff --git a/go.mod b/go.mod index a11ce1a5990..5dcfc6ad64a 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // MIT AND Apache-2.0 golang.org/x/crypto v0.49.0 // BSD-3-Clause golang.org/x/mod v0.34.0 // BSD-3-Clause + golang.org/x/net v0.51.0 // BSD-3-Clause golang.org/x/oauth2 v0.36.0 // BSD-3-Clause golang.org/x/sync v0.20.0 // BSD-3-Clause golang.org/x/sys v0.43.0 // BSD-3-Clause @@ -97,7 +98,6 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/net v0.51.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.265.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect