Repository: bazelbuild/bazelisk
Pull request #526 (2024-01-04 14:51:58)

Allow specifying where stdout should get written
This can be useful when invoking bazelisk programatically.


Patch:
diff --git a/.bazelversion b/.bazelversion
index 09b254e..21c8c7b 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1,1 +1,1 @@
-6.0.0
+7.1.1
diff --git a/.gitignore b/.gitignore
index db4213b..78189b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@
 
 # Binary created by 'go build'
 /bazelisk
-/bazelisk.exe
\ No newline at end of file
+/bazelisk.exe
+MODULE.bazel.lock
diff --git a/BUILD b/BUILD
index 3b3abec..93a9ac9 100644
--- a/BUILD
+++ b/BUILD
@@ -1,6 +1,6 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
 load("@bazel_gazelle//:def.bzl", "gazelle")
-load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm")
+load("@aspect_rules_js//npm:defs.bzl", "npm_package", "stamped_package_json")
 
 # gazelle:prefix github.com/bazelbuild/bazelisk
 gazelle(name = "gazelle")
@@ -152,22 +152,25 @@ go_binary(
     visibility = ["//visibility:public"],
 )
 
-pkg_npm(
+stamped_package_json(
+    name = "package",
+    # This key is defined by /stamp.sh
+    stamp_var = "BUILD_SCM_VERSION",
+)
+
+npm_package(
     name = "npm_package",
-    package_name = "@bazel/bazelisk",
     srcs = [
         "LICENSE",
         "README.md",
         "bazelisk.d.ts",
         "bazelisk.js",
-        "package.json",
-    ],
-    substitutions = {"0.0.0-PLACEHOLDER": "{BUILD_SCM_VERSION}"},
-    deps = [
         ":bazelisk-darwin-amd64",
         ":bazelisk-darwin-arm64",
         ":bazelisk-linux-amd64",
         ":bazelisk-linux-arm64",
         ":bazelisk-windows-amd64",
+        ":package",
     ],
+    package = "@bazel/bazelisk",
 )
diff --git a/MODULE.bazel b/MODULE.bazel
new file mode 100644
index 0000000..f22d5f1
--- /dev/null
+++ b/MODULE.bazel
@@ -0,0 +1,20 @@
+module(name = "bazelisk", version="")
+
+bazel_dep(name = "gazelle", version = "0.32.0", repo_name = "bazel_gazelle")
+bazel_dep(name = "platforms", version = "0.0.7")
+bazel_dep(name = "rules_go", version = "0.41.0", repo_name = "io_bazel_rules_go")
+bazel_dep(name = "aspect_rules_js", version = "1.39.1")
+# -- bazel_dep definitions -- #
+
+go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
+go_sdk.download(version = "1.20.5")
+
+go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
+go_deps.from_file(go_mod = "//:go.mod")
+use_repo(
+    go_deps,
+    "com_github_hashicorp_go_version",
+    "com_github_mitchellh_go_homedir",
+    "com_github_bgentry_go_netrc",
+    "org_golang_x_term",
+)
diff --git a/WORKSPACE b/WORKSPACE
deleted file mode 100644
index bdcca7f..0000000
--- a/WORKSPACE
+++ /dev/null
@@ -1,94 +0,0 @@
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
-
-http_archive(
-    name = "io_bazel_rules_go",
-    sha256 = "278b7ff5a826f3dc10f04feaf0b70d48b68748ccd512d7f98bf442077f043fe3",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.41.0/rules_go-v0.41.0.zip",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.41.0/rules_go-v0.41.0.zip",
-    ],
-)
-
-http_archive(
-    name = "bazel_gazelle",
-    sha256 = "448e37e0dbf61d6fa8f00aaa12d191745e14f07c31cabfa731f0c8e8a4f41b97",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.28.0/bazel-gazelle-v0.28.0.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.28.0/bazel-gazelle-v0.28.0.tar.gz",
-    ],
-)
-
-load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
-load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
-
-go_repository(
-    name = "com_github_bazelbuild_rules_go",
-    importpath = "github.com/bazelbuild/rules_go",
-    sum = "h1:vbnESGv/t2WgGEbXatwbXAS95dTx93Lv6Uh5QkVF13s=",
-    version = "v0.42.0",
-)
-
-go_repository(
-    name = "com_github_hashicorp_go_version",
-    importpath = "github.com/hashicorp/go-version",
-    sum = "h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=",
-    version = "v1.6.0",
-)
-
-go_repository(
-    name = "com_github_mitchellh_go_homedir",
-    importpath = "github.com/mitchellh/go-homedir",
-    sum = "h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=",
-    version = "v1.1.0",
-)
-
-go_repository(
-    name = "com_github_bgentry_go_netrc",
-    importpath = "github.com/bgentry/go-netrc",
-    sum = "h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=",
-    version = "v0.0.0-20140422174119-9fd32a8b3d3d",
-)
-
-go_repository(
-    name = "org_golang_x_term",
-    importpath = "golang.org/x/term",
-    sum = "h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=",
-    version = "v0.13.0",
-)
-
-go_repository(
-    name = "org_golang_x_sys",
-    importpath = "golang.org/x/sys",
-    sum = "h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=",
-    version = "v0.13.0",
-)
-
-go_rules_dependencies()
-
-go_register_toolchains(version = "1.20.5")
-
-gazelle_dependencies()
-
-# We don't use any nodejs but this includes a rule for publishing releases to npm
-http_archive(
-    name = "build_bazel_rules_nodejs",
-    sha256 = "f10a3a12894fc3c9bf578ee5a5691769f6805c4be84359681a785a0c12e8d2b6",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.3/rules_nodejs-5.5.3.tar.gz"],
-)
-
-load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
-
-build_bazel_rules_nodejs_dependencies()
-
-load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")
-
-node_repositories()
-
-http_archive(
-    name = "platforms",
-    sha256 = "079945598e4b6cc075846f7fd6a9d0857c33a7afc0de868c2ccb96405225135d",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.4/platforms-0.0.4.tar.gz",
-        "https://github.com/bazelbuild/platforms/releases/download/0.0.4/platforms-0.0.4.tar.gz",
-    ],
-)
diff --git a/core/core.go b/core/core.go
index f6ed200..429ae32 100644
--- a/core/core.go
+++ b/core/core.go
@@ -89,6 +89,12 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 // RunBazeliskWithArgsFuncAndConfig runs the main Bazelisk logic for the given ArgsFunc and Bazel
 // repositories and config.
 func RunBazeliskWithArgsFuncAndConfig(argsFunc ArgsFunc, repos *Repositories, config config.Config) (int, error) {
+	return RunBazeliskWithArgsFuncAndConfigAndOut(argsFunc, repos, config, nil)
+}
+
+// RunBazeliskWithArgsFuncAndConfigAndOut runs the main Bazelisk logic for the given ArgsFunc and Bazel
+// repositories and config, writing its stdout to the passed writer.
+func RunBazeliskWithArgsFuncAndConfigAndOut(argsFunc ArgsFunc, repos *Repositories, config config.Config, out io.Writer) (int, error) {
 	httputil.UserAgent = getUserAgent(config)
 
 	bazeliskHome := config.Get("BAZELISK_HOME")
@@ -198,7 +204,7 @@ func RunBazeliskWithArgsFuncAndConfig(argsFunc ArgsFunc, repos *Repositories, co
 		}
 	}
 
-	exitCode, err := runBazel(bazelPath, args, nil, config)
+	exitCode, err := runBazel(bazelPath, args, out, config)
 	if err != nil {
 		return -1, fmt.Errorf("could not run Bazel: %v", err)
 	}
@@ -346,7 +352,7 @@ func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Reposi
 //	downloads/metadata/[fork-or-url]/bazel-[version-os-etc] is a text file containing a hex sha256 of the contents of the downloaded bazel file.
 //	downloads/sha256/[sha256]/bin/bazel[extension] contains the bazel with a particular sha256.
 func downloadBazelIfNecessary(version string, bazeliskHome string, bazelForkOrURLDirName string, repos *Repositories, config config.Config, downloader DownloadFunc) (string, error) {
-	pathSegment, err := platforms.DetermineBazelFilename(version, false)
+	pathSegment, err := platforms.DetermineBazelFilename(version, false, config)
 	if err != nil {
 		return "", fmt.Errorf("could not determine path segment to use for Bazel binary: %v", err)
 	}
diff --git a/core/repositories.go b/core/repositories.go
index b84a1f4..f8710d9 100644
--- a/core/repositories.go
+++ b/core/repositories.go
@@ -227,7 +227,7 @@ func (r *Repositories) DownloadFromBaseURL(baseURL, version, destDir, destFile s
 		return "", fmt.Errorf("%s is not set", BaseURLEnv)
 	}
 
-	srcFile, err := platforms.DetermineBazelFilename(version, true)
+	srcFile, err := platforms.DetermineBazelFilename(version, true, config)
 	if err != nil {
 		return "", err
 	}
diff --git a/go.mod b/go.mod
index 67e4f47..98c8840 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d
 	github.com/hashicorp/go-version v1.6.0
 	github.com/mitchellh/go-homedir v1.1.0
-        golang.org/x/term v0.13.0
-	golang.org/x/sys v0.13.0
+	golang.org/x/term v0.13.0
 )
+
+require golang.org/x/sys v0.13.0 // indirect
diff --git a/httputil/fake.go b/httputil/fake.go
index 4cc316e..2081add 100644
--- a/httputil/fake.go
+++ b/httputil/fake.go
@@ -18,38 +18,48 @@ func NewFakeTransport() *FakeTransport {
 	}
 }
 
-// AddResponse stores a fake HTTP response for the given URL.
-func (ft *FakeTransport) AddResponse(url string, status int, body string, headers map[string]string) {
+func (ft *FakeTransport) responseCollection(url string) *responseCollection {
 	if _, ok := ft.responses[url]; !ok {
 		ft.responses[url] = &responseCollection{}
 	}
+	return ft.responses[url]
+}
+
+// AddResponse stores a fake HTTP response for the given URL.
+func (ft *FakeTransport) AddResponse(url string, status int, body string, headers map[string]string) {
+	ft.responseCollection(url).Add(createResponse(status, body, headers), nil)
+}
+
+// AddResponse stores a error for the given URL.
+func (ft *FakeTransport) AddError(url string, err error) {
+	ft.responseCollection(url).Add(nil, err)
 
-	ft.responses[url].Add(createResponse(status, body, headers))
 }
 
 // RoundTrip returns a prerecorded response to the given request, if one exists. Otherwise its response indicates 404 - not found.
 func (ft *FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 	if responses, ok := ft.responses[req.URL.String()]; ok {
-		return responses.Next(), nil
+		return responses.Next()
 	}
 	return notFound(), nil
 }
 
 type responseCollection struct {
-	all  []*http.Response
+	all  []responseError
 	next int
 }
 
-func (rc *responseCollection) Add(resp *http.Response) {
-	rc.all = append(rc.all, resp)
+func (rc *responseCollection) Add(resp *http.Response, err error) {
+	rc.all = append(rc.all, responseError{resp: resp, err: err})
 }
 
-func (rc *responseCollection) Next() *http.Response {
+func (rc *responseCollection) Next() (*http.Response, error) {
 	if rc.next >= len(rc.all) {
-		return notFound()
+		return notFound(), nil
 	}
 	rc.next++
-	return rc.all[rc.next-1]
+	next := rc.all[rc.next-1]
+	return next.resp, next.err
 }
 
 func createResponse(status int, body string, headers map[string]string) *http.Response {
@@ -71,3 +81,8 @@ func transformHeaders(original map[string]string) http.Header {
 func notFound() *http.Response {
 	return createResponse(http.StatusNotFound, "", nil)
 }
+
+type responseError struct {
+	resp *http.Response
+	err  error
+}
diff --git a/httputil/httputil.go b/httputil/httputil.go
index 07eb8c5..804b8e5 100644
--- a/httputil/httputil.go
+++ b/httputil/httputil.go
@@ -89,16 +89,19 @@ func get(url, auth string) (*http.Response, error) {
 	}
 	client := &http.Client{Transport: DefaultTransport}
 	deadline := RetryClock.Now().Add(MaxRequestDuration)
-	lastStatus := 0
+	var lastFailure string
 	for attempt := 0; attempt <= MaxRetries; attempt++ {
 		res, err := client.Do(req)
-		// Do not retry on success and permanent/fatal errors
-		if err != nil || !shouldRetry(res) {
+		if !shouldRetry(res, err) {
 			return res, err
 		}
 
-		lastStatus = res.StatusCode
-		waitFor, err := getWaitPeriod(res, attempt)
+		if err == nil {
+			lastFailure = fmt.Sprintf("HTTP %d", res.StatusCode)
+		} else {
+			lastFailure = err.Error()
+		}
+		waitFor, err := getWaitPeriod(res, err, attempt)
 		if err != nil {
 			return nil, err
 		}
@@ -111,18 +114,25 @@ func get(url, auth string) (*http.Response, error) {
 			RetryClock.Sleep(waitFor)
 		}
 	}
-	return nil, fmt.Errorf("unable to complete request to %s after %d retries. Most recent status: %d", url, MaxRetries, lastStatus)
+	return nil, fmt.Errorf("unable to complete request to %s after %d retries. Most recent failure: %s", url, MaxRetries, lastFailure)
 }
 
-func shouldRetry(res *http.Response) bool {
+func shouldRetry(res *http.Response, err error) bool {
+	// Retry if the client failed to speak HTTP.
+	if err != nil {
+		return true
+	}
+	// For HTTP: only retry on permanent/fatal errors.
 	return res.StatusCode == 429 || (500 <= res.StatusCode && res.StatusCode <= 504)
 }
 
-func getWaitPeriod(res *http.Response, attempt int) (time.Duration, error) {
-	// Check if the server told us when to retry
-	for _, header := range retryHeaders {
-		if value := res.Header[header]; len(value) > 0 {
-			return parseRetryHeader(value[0])
+func getWaitPeriod(res *http.Response, err error, attempt int) (time.Duration, error) {
+	if err == nil {
+		// If HTTP works, check if the server told us when to retry
+		for _, header := range retryHeaders {
+			if value := res.Header[header]; len(value) > 0 {
+				return parseRetryHeader(value[0])
+			}
 		}
 	}
 	// Let's just use exponential backoff: 1s + d1, 2s + d2, 4s + d3, 8s + d4 with dx being a random value in [0ms, 500ms]
diff --git a/httputil/progress/progress.go b/httputil/progress/progress.go
index 2df6612..ece3d93 100644
--- a/httputil/progress/progress.go
+++ b/httputil/progress/progress.go
@@ -85,11 +85,19 @@ func (p *progress) Write(buf []byte) (int, error) {
 
 // Writes the current download progress to stdout.
 func (p *progress) ShowProgress() {
-	message := fmt.Sprintf("%s: %s out of %s (%s)",
-		p.header,
-		formatMb(p.current),
-		formatMb(p.total),
-		formatPercentage(p.current, p.total))
+	var message string
+
+	if p.total > 0 {
+		message = fmt.Sprintf("%s: %s out of %s (%s)",
+			p.header,
+			formatMb(p.current),
+			formatMb(p.total),
+			formatPercentage(p.current, p.total))
+	} else {
+		message = fmt.Sprintf("%s: %s",
+			p.header,
+			formatMb(p.current))
+	}
 
 	if message == p.lastMessage {
 		return
diff --git a/package.json b/package.json
index a8b56b0..edf944b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,6 @@
 {
     "name": "@bazel/bazelisk",
     "description": "A user-friendly launcher for Bazel",
-    "version": "0.0.0-PLACEHOLDER",
     "license": "Apache-2.0",
     "bin": {
         "bazelisk": "bazelisk.js",
diff --git a/platforms/BUILD b/platforms/BUILD
index cb6950d..9136652 100644
--- a/platforms/BUILD
+++ b/platforms/BUILD
@@ -6,6 +6,7 @@ go_library(
     importpath = "github.com/bazelbuild/bazelisk/platforms",
     visibility = ["//visibility:public"],
     deps = [
+        "//config:go_default_library",
         "//versions:go_default_library",
         "@com_github_hashicorp_go_version//:go_default_library",
     ],
diff --git a/platforms/platforms.go b/platforms/platforms.go
index 2e918dc..3055cc1 100644
--- a/platforms/platforms.go
+++ b/platforms/platforms.go
@@ -6,6 +6,7 @@ import (
 	"log"
 	"runtime"
 
+	"github.com/bazelbuild/bazelisk/config"
 	"github.com/bazelbuild/bazelisk/versions"
 	semver "github.com/hashicorp/go-version"
 )
@@ -82,7 +83,15 @@ func DetermineOperatingSystem() (string, error) {
 }
 
 // DetermineBazelFilename returns the correct file name of a local Bazel binary.
-func DetermineBazelFilename(version string, includeSuffix bool) (string, error) {
+func DetermineBazelFilename(version string, includeSuffix bool, config config.Config) (string, error) {
+	flavor := "bazel"
+
+	bazeliskNojdk := config.Get("BAZELISK_NOJDK")
+	
+	if len(bazeliskNojdk) != 0 && bazeliskNojdk != "0" {
+		flavor = "bazel_nojdk"
+	}
+
 	osName, err := DetermineOperatingSystem()
 	if err != nil {
 		return "", err
@@ -98,7 +107,7 @@ func DetermineBazelFilename(version string, includeSuffix bool) (string, error)
 		filenameSuffix = DetermineExecutableFilenameSuffix()
 	}
 
-	return fmt.Sprintf("bazel-%s-%s-%s%s", version, osName, machineName, filenameSuffix), nil
+	return fmt.Sprintf("%s-%s-%s-%s%s", flavor, version, osName, machineName, filenameSuffix), nil
 }
 
 // DarwinFallback Darwin arm64 was supported since 4.1.0, before 4.1.0, fall back to x86_64
diff --git a/repositories/gcs.go b/repositories/gcs.go
index d5a107d..e53133f 100644
--- a/repositories/gcs.go
+++ b/repositories/gcs.go
@@ -139,7 +139,7 @@ type GcsListResponse struct {
 
 // DownloadRelease downloads the given Bazel release into the specified location and returns the absolute path.
 func (gcs *GCSRepo) DownloadRelease(version, destDir, destFile string, config config.Config) (string, error) {
-	srcFile, err := platforms.DetermineBazelFilename(version, true)
+	srcFile, err := platforms.DetermineBazelFilename(version, true, config)
 	if err != nil {
 		return "", err
 	}
@@ -235,7 +235,7 @@ func (gcs *GCSRepo) DownloadCandidate(version, destDir, destFile string, config
 		return "", fmt.Errorf("'%s' does not refer to a release candidate", version)
 	}
 
-	srcFile, err := platforms.DetermineBazelFilename(version, true)
+	srcFile, err := platforms.DetermineBazelFilename(version, true, config)
 	if err != nil {
 		return "", err
 	}
@@ -299,7 +299,7 @@ func (gcs *GCSRepo) GetRollingVersions(bazeliskHome string) ([]string, error) {
 
 // DownloadRolling downloads the given Bazel version into the specified location and returns the absolute path.
 func (gcs *GCSRepo) DownloadRolling(version, destDir, destFile string, config config.Config) (string, error) {
-	srcFile, err := platforms.DetermineBazelFilename(version, true)
+	srcFile, err := platforms.DetermineBazelFilename(version, true, config)
 	if err != nil {
 		return "", err
 	}
diff --git a/repositories/github.go b/repositories/github.go
index 0dddd3c..c5c3f4b 100644
--- a/repositories/github.go
+++ b/repositories/github.go
@@ -86,7 +86,7 @@ type gitHubRelease struct {
 
 // DownloadVersion downloads a Bazel binary for the given version and fork to the specified location and returns the absolute path.
 func (gh *GitHubRepo) DownloadVersion(fork, version, destDir, destFile string, config config.Config) (string, error) {
-	filename, err := platforms.DetermineBazelFilename(version, true)
+	filename, err := platforms.DetermineBazelFilename(version, true, config)
 	if err != nil {
 		return "", err
 	}


Test patch:
diff --git a/bazelisk_test.sh b/bazelisk_test.sh
index fded3a7..dfd19db 100755
--- a/bazelisk_test.sh
+++ b/bazelisk_test.sh
@@ -267,6 +267,32 @@ function test_bazel_last_downstream_green() {
       (echo "FAIL: 'bazelisk version' of an unreleased binary must not print a build label."; exit 1)
 }
 
+function test_BAZELISK_NOJDK() {
+  setup
+
+  # Running the nojdk Bazel without a valid JAVA is expected to fail
+  set +e
+  BAZELISK_HOME="$BAZELISK_HOME" \
+      USE_BAZEL_VERSION="7.0.0" \
+      BAZELISK_NOJDK="1" \
+      bazelisk --noautodetect_server_javabase version 2>&1 | tee log
+  set -e
+
+  grep "FATAL: Could not find embedded or explicit server javabase, and --noautodetect_server_javabase is set." log || \
+      (echo "FAIL: nojdk Bazel should fail when no JDK is supplied."; exit 1)
+
+  # Theoretically there could be a cache collision in the Bazelisk cache between nojdk and regular Bazel.
+  #
+  # Ensure that isn't happening by running the regular Bazel right after nojdk Bazel, just in case. If there is a collision, it will fail.
+  BAZELISK_HOME="$BAZELISK_HOME" \
+      USE_BAZEL_VERSION="7.0.0" \
+      JAVA_HOME="does/not/exist" \
+      bazelisk --noautodetect_server_javabase version 2>&1 | tee log
+
+  grep "Build label: 7.0.0" log || \
+      (echo "FAIL: Expected to find 'Build label: 7.0.0' in the output of 'bazelisk version'"; exit 1)
+}
+
 function test_bazel_last_rc() {
   setup
 
@@ -491,6 +517,10 @@ echo "# test_bazel_last_downstream_green"
 test_bazel_last_downstream_green
 echo
 
+echo "# test_BAZELISK_NOJDK"
+test_BAZELISK_NOJDK
+echo
+
 if [[ $BAZELISK_VERSION == "GO" ]]; then
   echo "# test_bazel_version_go"
   test_bazel_version_go
diff --git a/httputil/httputil_test.go b/httputil/httputil_test.go
index d3a83a2..9ceb9fb 100644
--- a/httputil/httputil_test.go
+++ b/httputil/httputil_test.go
@@ -1,6 +1,7 @@
 package httputil
 
 import (
+	"errors"
 	"net/http"
 	"strconv"
 	"testing"
@@ -93,6 +94,29 @@ func TestSuccessOnRetry(t *testing.T) {
 	}
 }
 
+func TestSuccessOnRetryNonHTTPError(t *testing.T) {
+	transport, clock := setUp()
+
+	url := "http://foo"
+	want := "the_body"
+	transport.AddError(url, errors.New("boom!"))
+	transport.AddResponse(url, 200, want, nil)
+	body, _, err := ReadRemoteFile(url, "")
+
+	if err != nil {
+		t.Fatalf("Unexpected error %v", err)
+	}
+
+	got := string(body)
+	if got != want {
+		t.Fatalf("Expected body %q, but got %q", want, got)
+	}
+
+	if clock.TimesSlept() != 1 {
+		t.Fatalf("Expected a single retry, not %d", clock.TimesSlept())
+	}
+}
+
 func TestAllTriesFail(t *testing.T) {
 	MaxRequestDuration = 100 * time.Second
 
@@ -106,7 +130,7 @@ func TestAllTriesFail(t *testing.T) {
 	}
 
 	reason := err.Error()
-	expected := "could not fetch http://bar: unable to complete request to http://bar after 5 retries. Most recent status: 502"
+	expected := "could not fetch http://bar: unable to complete request to http://bar after 5 retries. Most recent failure: HTTP 502"
 	if reason != expected {
 		t.Fatalf("Expected request to fail with %q, but got %q", expected, reason)
 	}
@@ -176,7 +200,8 @@ func TestShouldUseExponentialBackoffIfNoRetryHeader(t *testing.T) {
 	}
 
 	delta := time.Millisecond * 100
-	lower := (1 << uint(retries)) * time.Second
+	// exponential backoff: 1s + d1, 2s + d2, 4s + d3, 8s + d4 with dx being a random value in [0ms, 500ms]
+	lower := (1<<uint(retries))*time.Second - time.Second
 	upper := lower + time.Duration(retries*500)*time.Millisecond
 	if total < lower-delta || upper+delta < total {
 		t.Fatalf("Expected a total sleep time between %s and %s (%d retries, exponential backoff with fuzzing), but waited %s instead", lower, upper, retries, total)