From 59773c19b005ca50f3d6d2c1b70ffcc921cbe815 Mon Sep 17 00:00:00 2001 From: Anton Nekipelov <226657+anton-107@users.noreply.github.com> Date: Thu, 7 May 2026 11:53:48 +0200 Subject: [PATCH 1/3] ssh: actionable error when binary upload is reset by network proxy When `databricks ssh connect` uploads the CLI binary to the workspace, a network intermediary (corporate egress proxy, VPN, firewall/WAF) may close the HTTP/2 stream mid-upload, surfacing as a cryptic `stream error: stream ID N; NO_ERROR; received from peer`. Detect this transport-level reset and wrap the error with a hint that the connection was closed by an intermediary and the user should try from a network without such restrictions. Co-authored-by: Isaac --- experimental/ssh/internal/client/releases.go | 21 ++++++++ .../ssh/internal/client/releases_test.go | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 experimental/ssh/internal/client/releases_test.go diff --git a/experimental/ssh/internal/client/releases.go b/experimental/ssh/internal/client/releases.go index 6c6ad800aa2..6f433524369 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,18 @@ 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). +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..14f51419c76 --- /dev/null +++ b/experimental/ssh/internal/client/releases_test.go @@ -0,0 +1,50 @@ +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: "http2 stream error type", + err: http2.StreamError{StreamID: 15, Code: http2.ErrCodeNo}, + want: true, + }, + { + name: "wrapped http2 stream error type", + err: fmt.Errorf("post failed: %w", http2.StreamError{StreamID: 15, Code: http2.ErrCodeNo}), + want: true, + }, + { + name: "string match from peer reset (Go HTTP/2 client format)", + err: errors.New(`Post "https://example/api/2.0/workspace-files/import-file/...": 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)) + }) + } +} From f4da3c31876e1f524eea79e64feb5387b73b300c Mon Sep 17 00:00:00 2001 From: Anton Nekipelov <226657+anton-107@users.noreply.github.com> Date: Thu, 7 May 2026 11:59:07 +0200 Subject: [PATCH 2/3] fixup: drop http2 import to keep go.mod unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed http2.StreamError check promoted golang.org/x/net to a direct dependency, failing the lint check that go.mod is unchanged. Rely on string match alone — http2.StreamError.Error() formats as "stream error: stream ID N; ..." which the existing string match catches. Co-authored-by: Isaac --- experimental/ssh/internal/client/releases.go | 8 ++------ experimental/ssh/internal/client/releases_test.go | 14 ++++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/experimental/ssh/internal/client/releases.go b/experimental/ssh/internal/client/releases.go index 6f433524369..28ccd8f2e4e 100644 --- a/experimental/ssh/internal/client/releases.go +++ b/experimental/ssh/internal/client/releases.go @@ -15,7 +15,6 @@ 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) @@ -84,12 +83,9 @@ func uploadReleases(ctx context.Context, workspaceFiler filer.Filer, getRelease // 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). +// endpoint rejected the request body (e.g. body-size limit). Matches both the +// raw http2.StreamError.Error() format and wrapped variants. 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") } diff --git a/experimental/ssh/internal/client/releases_test.go b/experimental/ssh/internal/client/releases_test.go index 14f51419c76..ca60e2e21fe 100644 --- a/experimental/ssh/internal/client/releases_test.go +++ b/experimental/ssh/internal/client/releases_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "golang.org/x/net/http2" ) func TestIsStreamResetError(t *testing.T) { @@ -16,18 +15,13 @@ func TestIsStreamResetError(t *testing.T) { want bool }{ { - name: "http2 stream error type", - err: http2.StreamError{StreamID: 15, Code: http2.ErrCodeNo}, + name: "raw http2 stream error string", + err: errors.New("stream error: stream ID 15; NO_ERROR"), want: true, }, { - name: "wrapped http2 stream error type", - err: fmt.Errorf("post failed: %w", http2.StreamError{StreamID: 15, Code: http2.ErrCodeNo}), - want: true, - }, - { - name: "string match from peer reset (Go HTTP/2 client format)", - err: errors.New(`Post "https://example/api/2.0/workspace-files/import-file/...": stream error: stream ID 15; NO_ERROR; received from peer`), + name: "wrapped peer-reset error (Go HTTP/2 client format)", + err: fmt.Errorf(`Post "https://example/api/2.0/workspace-files/import-file/...": %w`, errors.New("stream error: stream ID 15; NO_ERROR; received from peer")), want: true, }, { From dd5ccfbaf263e3c577ff55d2ae498fbd8c9d3a93 Mon Sep 17 00:00:00 2001 From: Anton Nekipelov <226657+anton-107@users.noreply.github.com> Date: Mon, 11 May 2026 16:29:09 +0200 Subject: [PATCH 3/3] Restore typed http2.StreamError check; add x/net license metadata The previous fixup dropped the typed errors.As(err, &http2.StreamError{}) check because importing golang.org/x/net/http2 promoted golang.org/x/net from indirect to direct in go.mod and tripped the SPDX license test. Per CLAUDE.md, we should not branch on err.Error() content when a typed sentinel is available, so restore the typed check and pay the license metadata tax instead: - Add // BSD-3-Clause suffix on the direct require in go.mod. - Add a matching entry to NOTICE under the BSD (3-clause) section. Update the test that previously claimed to cover the wrapped/typed form (but actually used errors.New) to wrap a real http2.StreamError. Co-authored-by: Isaac --- NOTICE | 4 ++++ experimental/ssh/internal/client/releases.go | 10 ++++++++-- experimental/ssh/internal/client/releases_test.go | 9 +++++---- go.mod | 2 +- 4 files changed, 18 insertions(+), 7 deletions(-) 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 28ccd8f2e4e..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) @@ -83,9 +84,14 @@ func uploadReleases(ctx context.Context, workspaceFiler filer.Filer, getRelease // 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). Matches both the -// raw http2.StreamError.Error() format and wrapped variants. +// 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") } diff --git a/experimental/ssh/internal/client/releases_test.go b/experimental/ssh/internal/client/releases_test.go index ca60e2e21fe..6444a779c60 100644 --- a/experimental/ssh/internal/client/releases_test.go +++ b/experimental/ssh/internal/client/releases_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "golang.org/x/net/http2" ) func TestIsStreamResetError(t *testing.T) { @@ -15,13 +16,13 @@ func TestIsStreamResetError(t *testing.T) { want bool }{ { - name: "raw http2 stream error string", - err: errors.New("stream error: stream ID 15; NO_ERROR"), + 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: "wrapped peer-reset error (Go HTTP/2 client format)", - err: fmt.Errorf(`Post "https://example/api/2.0/workspace-files/import-file/...": %w`, errors.New("stream error: stream ID 15; NO_ERROR; received from peer")), + name: "stringified stream error", + err: errors.New("stream error: stream ID 15; NO_ERROR; received from peer"), want: true, }, { 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