Skip to content

Commit 5c430bb

Browse files
authored
fix: improve CLI download error handling [IDE-1690] (#353)
Add HTTP status code validation in FileDownloadResponseHandler (only accept 2xx & 3xx responses, log response body for debugging). Include download URL and status code in error messages. Gracefully handle download failures in SnykStartup, but allow LS startup with existing binary if available. Validate CLI binary exists before starting language server, with helpful error message suggesting to check Error Log when "Manage Binaries Automatically" is enabled.
1 parent b6d78ca commit 5c430bb

File tree

8 files changed

+235
-36
lines changed

8 files changed

+235
-36
lines changed

plugin/src/main/java/io/snyk/eclipse/plugin/SnykStartup.java

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.snyk.eclipse.plugin;
22

3-
import static io.snyk.eclipse.plugin.utils.SnykLogger.logError;
4-
53
import java.io.File;
64
import java.io.IOException;
75
import java.net.URISyntaxException;
@@ -16,6 +14,7 @@
1614
import org.eclipse.core.runtime.Platform;
1715
import org.eclipse.core.runtime.Status;
1816
import org.eclipse.core.runtime.jobs.Job;
17+
import org.eclipse.swt.SWTException;
1918
import org.eclipse.ui.IStartup;
2019
import org.eclipse.ui.IWorkbench;
2120
import org.eclipse.ui.IWorkbenchPage;
@@ -29,6 +28,7 @@
2928
import io.snyk.eclipse.plugin.wizards.SnykWizard;
3029
import io.snyk.languageserver.LsRuntimeEnvironment;
3130
import io.snyk.languageserver.SnykLanguageServer;
31+
import io.snyk.languageserver.download.ChecksumVerificationException;
3232
import io.snyk.languageserver.download.HttpClientFactory;
3333
import io.snyk.languageserver.download.LsBinaries;
3434
import io.snyk.languageserver.download.LsDownloader;
@@ -50,6 +50,8 @@ public void earlyStartup() {
5050
protected IStatus run(IProgressMonitor monitor) {
5151
monitor.beginTask("Initializing...", 100);
5252
downloadIfNeeded(monitor);
53+
54+
// Even if download failed, try to start LS - there might be an existing binary
5355
monitor.subTask("Starting Language Server...");
5456

5557
try {
@@ -62,7 +64,7 @@ protected IStatus run(IProgressMonitor monitor) {
6264
SnykWizard.createAndLaunch();
6365
}
6466
});
65-
} catch (RuntimeException e) {
67+
} catch (RuntimeException e) { // NOPMD - intentional catch-all of runtime exceptions for UI initialization
6668
SnykLogger.logError(e);
6769
}
6870
monitor.done();
@@ -76,12 +78,31 @@ private void downloadIfNeeded(IProgressMonitor monitor) {
7678
monitor.subTask("Downloading CLI");
7779
logger.info("LS: Need to download");
7880
downloading = true;
79-
download(monitor);
81+
IStatus status = download(monitor);
82+
if (!status.isOK()) {
83+
logDownloadFailure(status);
84+
}
8085
}
81-
} catch (Exception exception) {
82-
logError(exception);
86+
} catch (Exception e) { // NOPMD - intentional catch-all for download failures
87+
logDownloadFailure(e.getMessage());
88+
} finally {
89+
downloading = false;
90+
}
91+
}
92+
93+
private void logDownloadFailure(IStatus status) {
94+
String errorMessage = status.getMessage();
95+
if (status.getException() != null) {
96+
errorMessage += ": " + status.getException().getMessage();
8397
}
84-
downloading = false;
98+
logDownloadFailure(errorMessage);
99+
}
100+
101+
private void logDownloadFailure(String errorMessage) {
102+
// Log the error - user may just be offline but have an existing binary. We show a user-facing error later if binary is missing.
103+
String message = "Failed to download Snyk CLI: " + errorMessage
104+
+ ". Will try to start with existing binary if available.";
105+
logger.error(message);
85106
}
86107
};
87108
initJob.setPriority(Job.LONG);
@@ -141,7 +162,7 @@ public static IStatus download(IProgressMonitor monitor) {
141162
lsFile.getParentFile().mkdirs();
142163
lsDownloader.download(monitor);
143164
lsFile.setExecutable(true);
144-
} catch (RuntimeException | URISyntaxException e) {
165+
} catch (RuntimeException | IOException | URISyntaxException | ChecksumVerificationException e) { // NOPMD - intentional catch-all of runtime exceptions for download errors
145166
return Status.error("Download of Snyk Language Server failed", e);
146167
}
147168
return Status.OK_STATUS;
Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,87 @@
11
package io.snyk.eclipse.plugin.utils;
22

3+
import java.io.BufferedReader;
34
import java.io.File;
45
import java.io.IOException;
56
import java.io.InputStream;
7+
import java.io.InputStreamReader;
8+
import java.nio.charset.StandardCharsets;
69
import java.nio.file.Files;
10+
import java.util.stream.Collectors;
711

812
import org.apache.http.HttpResponse;
913
import org.apache.http.client.ResponseHandler;
14+
import org.eclipse.core.runtime.ILog;
1015
import org.eclipse.core.runtime.IProgressMonitor;
16+
import org.eclipse.core.runtime.Platform;
1117
import org.eclipse.core.runtime.SubMonitor;
1218

1319
public class FileDownloadResponseHandler implements ResponseHandler<File> {
1420

1521
private final File destinationFile;
1622
private final IProgressMonitor progressMonitor;
23+
private final String downloadUrl;
24+
private final ILog logger;
1725

18-
public FileDownloadResponseHandler(File file, IProgressMonitor monitor) {
26+
public FileDownloadResponseHandler(File file, IProgressMonitor monitor, String downloadUrl) {
1927
this.destinationFile = file;
2028
this.progressMonitor = monitor;
29+
this.downloadUrl = downloadUrl;
30+
this.logger = Platform.getLog(getClass());
2131
}
2232

2333
@Override
2434
public File handleResponse(HttpResponse httpResponse) throws IOException {
35+
int statusCode = httpResponse.getStatusLine().getStatusCode();
36+
37+
// Accept 2xx (success) and 3xx (redirect) status codes.
38+
// Note: We don't expect to see 3xx here since HttpClient follows redirects automatically.
39+
// Redirect issues (circular redirects, too many redirects) throw RedirectException.
40+
// If we do see 3xx, we'll treat it as okay.
41+
if (statusCode < 200 || statusCode >= 400) {
42+
handleHttpError(httpResponse, statusCode);
43+
}
44+
2545
long contentLengthStr = httpResponse.getEntity().getContentLength();
2646
SubMonitor subMonitor = SubMonitor.convert(progressMonitor, 100);
2747
try (InputStream inputStream = httpResponse.getEntity().getContent();
2848
var outputStream = Files.newOutputStream(destinationFile.toPath())) {
2949
int readCount;
3050
byte[] buffer = new byte[1024];
3151

32-
while ((readCount = inputStream.read(buffer)) != -1) { // NOPMD by bdoetsch on 3/11/25, 2:29PM
52+
while ((readCount = inputStream.read(buffer)) != -1) { // NOPMD by bdoetsch on 3/11/25, 2:29 PM
3353
outputStream.write(buffer, 0, readCount);
3454
subMonitor.split(Math.round((float) readCount / (float) contentLengthStr));
3555
}
3656
}
3757
return this.destinationFile;
3858
}
59+
60+
private void handleHttpError(HttpResponse httpResponse, int statusCode) throws IOException {
61+
String reasonPhrase = httpResponse.getStatusLine().getReasonPhrase();
62+
String responseBody = readResponseBody(httpResponse);
63+
64+
// Log the response body for debugging (may contain useful error details)
65+
logger.warn(String.format("CLI download failed. URL: %s, Status: %d %s, Response body: %s",
66+
downloadUrl, statusCode, reasonPhrase, responseBody));
67+
68+
// Throw exception with URL and status (but not the body - may be large, may be binary or may be HTML we accidentally render)
69+
throw new IOException(String.format(
70+
"Download failed with HTTP %d %s for URL: %s",
71+
statusCode, reasonPhrase, downloadUrl));
72+
}
73+
74+
private String readResponseBody(HttpResponse httpResponse) {
75+
try {
76+
if (httpResponse.getEntity() == null) {
77+
return "(no response body)";
78+
}
79+
try (BufferedReader reader = new BufferedReader(
80+
new InputStreamReader(httpResponse.getEntity().getContent(), StandardCharsets.UTF_8))) {
81+
return reader.lines().collect(Collectors.joining("\n"));
82+
}
83+
} catch (IOException | UnsupportedOperationException e) {
84+
return "(could not read response body: " + e.getMessage() + ")";
85+
}
86+
}
3987
}

plugin/src/main/java/io/snyk/languageserver/SnykLanguageServer.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.snyk.languageserver;
22

3+
import java.io.File;
34
import java.io.IOException;
45
import java.net.URI;
56
import java.util.List;
@@ -30,15 +31,39 @@ public SnykLanguageServer() {
3031
public void start() throws IOException {
3132
var prefs = Preferences.getInstance();
3233
waitForInit();
33-
List<String> commands = Lists.of(prefs.getCliPath(), "language-server", "-l", "info");
34+
35+
String cliPath = getCliPathOrThrow(prefs);
36+
37+
List<String> commands = Lists.of(cliPath, "language-server", "-l", "info");
3438
String workingDir = SystemUtils.USER_DIR;
3539
setCommands(commands);
3640
setWorkingDirectory(workingDir);
3741
try {
3842
super.start();
3943
} catch (IOException e) {
40-
SnykLogger.logAndShow("Cannot start the Snyk CLI. Please check the CLI path.");
44+
SnykLogger.logAndShow("Cannot start the Snyk CLI. Please check the CLI path: " + cliPath);
45+
throw e;
46+
}
47+
}
48+
49+
/**
50+
* Returns CLI path if binary exists, logs and throws IOException with helpful message if not.
51+
*/
52+
static String getCliPathOrThrow(Preferences prefs) throws IOException {
53+
String cliPath = prefs.getCliPath();
54+
File cliBinary = new File(cliPath);
55+
if (!cliBinary.exists()) {
56+
var sb = new StringBuilder(200);
57+
sb.append("Snyk CLI binary not found at: '").append(cliPath).append("'. \n");
58+
if (prefs.isManagedBinaries()) {
59+
sb.append("'Manage Binaries Automatically' is enabled - check the Error Log for download details. \n");
60+
}
61+
sb.append("You can also specify a custom CLI path in Snyk Preferences.");
62+
String message = sb.toString();
63+
SnykLogger.logAndShow(message);
64+
throw new IOException(message);
4165
}
66+
return cliPath;
4267
}
4368

4469
public static void waitForInit() {

plugin/src/main/java/io/snyk/languageserver/download/ChecksumVerificationException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package io.snyk.languageserver.download;
22

3-
public class ChecksumVerificationException extends RuntimeException {
3+
public class ChecksumVerificationException extends Exception {
44
private static final long serialVersionUID = 7210924932018107815L;
55

66
public ChecksumVerificationException(String s) {

plugin/src/main/java/io/snyk/languageserver/download/LsDownloader.java

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public LsDownloader(HttpClientFactory factory, LsRuntimeEnvironment environment,
4545
}
4646
}
4747

48-
public void download(IProgressMonitor monitor) {
48+
public void download(IProgressMonitor monitor) throws IOException, ChecksumVerificationException {
4949
File destinationFile = new File(Preferences.getInstance().getCliPath());
5050
File tempFile = null;
5151
try {
@@ -82,7 +82,8 @@ public void download(IProgressMonitor monitor) {
8282

8383
monitor.subTask("Starting download of CLI version " + version);
8484
LsDownloadRequest binaryRequest = new LsDownloadRequest(version, runtimeEnvironment);
85-
tempFile = httpClient.execute(binaryRequest, new FileDownloadResponseHandler(tempFile, monitor), context);
85+
String downloadUrl = binaryRequest.getURI().toString();
86+
tempFile = httpClient.execute(binaryRequest, new FileDownloadResponseHandler(tempFile, monitor, downloadUrl), context);
8687
monitor.worked(80);
8788
logger.info("LS: Downloaded file.");
8889

@@ -113,17 +114,12 @@ public void download(IProgressMonitor monitor) {
113114
monitor.worked(5);
114115
if (!destinationFile.setExecutable(true))
115116
throw new IOException("Could not set executable permission on " + destinationFile);
116-
} catch (ChecksumVerificationException e) {
117-
throw e;
118-
} catch (Exception e) {
119-
logger.error("IOException", e);
120-
throw new RuntimeException(e);
121117
} finally {
122118
try {
123-
if (tempFile != null && tempFile.exists())
124-
if (!tempFile.delete())
125-
tempFile.deleteOnExit();
126-
} catch (Exception e) {
119+
if (tempFile != null && tempFile.exists() && !tempFile.delete()) {
120+
tempFile.deleteOnExit();
121+
}
122+
} catch (Exception e) { // NOPMD - intentional catch-all for cleanup
127123
SnykLogger.logError(e);
128124
}
129125
}
@@ -144,7 +140,7 @@ String getVersion(String releaseChannel) {
144140
return response;
145141
}
146142

147-
String getSha(String version) {
143+
String getSha(String version) throws ChecksumVerificationException {
148144
LsShaRequest shaRequest = new LsShaRequest(version);
149145
try (CloseableHttpResponse response = httpClient.execute(shaRequest, context)) {
150146
if (response.getStatusLine().getStatusCode() >= 400) { // NOPMD, AvoidLiteralsInIfCondition
@@ -164,7 +160,7 @@ String getSha(String version) {
164160
}
165161
}
166162

167-
private void verifyChecksum(String expectedSha, byte[] checksumDownloadedFile) {
163+
private void verifyChecksum(String expectedSha, byte[] checksumDownloadedFile) throws ChecksumVerificationException {
168164
try {
169165
byte[] sha = MessageDigest.getInstance("SHA-256").digest(checksumDownloadedFile);
170166
String actualSha = bytesToHex(sha).toLowerCase(Locale.getDefault());

tests/src/test/java/io/snyk/eclipse/plugin/utils/FileDownloadResponseHandlerTest.java

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.snyk.eclipse.plugin.utils;
22

3-
import io.snyk.languageserver.LsBaseTest;
43
import org.apache.http.HttpEntity;
54
import org.apache.http.ProtocolVersion;
65
import org.apache.http.entity.BasicHttpEntity;
@@ -15,27 +14,59 @@
1514
import java.nio.file.Files;
1615

1716
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertThrows;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
1819
import static org.mockito.Mockito.mock;
1920

20-
class FileDownloadResponseHandlerTest extends LsBaseTest {
21+
class FileDownloadResponseHandlerTest {
22+
23+
private static final String TEST_URL = "https://downloads.snyk.io/cli/v1.0.0/snyk-win-arm64.exe";
2124

2225
@Test
2326
void shouldSaveFile() throws IOException {
2427
String payload = "test test test";
25-
BasicHttpResponse response = getBasicHttpResponse(payload);
28+
BasicHttpResponse response = getBasicHttpResponse(200, "OK", payload);
2629

2730
File tempFile = File.createTempFile("pre", "fix");
2831
tempFile.deleteOnExit();
29-
var cut = new FileDownloadResponseHandler(tempFile, mock(IProgressMonitor.class));
32+
var cut = new FileDownloadResponseHandler(tempFile, mock(IProgressMonitor.class), TEST_URL);
3033
cut.handleResponse(response);
3134

3235
var actual = Files.readString(tempFile.toPath());
3336
assertEquals(payload, actual);
3437
}
3538

36-
private BasicHttpResponse getBasicHttpResponse(String payload) {
39+
@Test
40+
void shouldThrowOnHttp404WithUrlAndStatus() throws IOException {
41+
String errorBody = "<html>Not Found</html>";
42+
BasicHttpResponse response = getBasicHttpResponse(404, "Not Found", errorBody);
43+
44+
File tempFile = File.createTempFile("pre", "fix");
45+
tempFile.deleteOnExit();
46+
var cut = new FileDownloadResponseHandler(tempFile, mock(IProgressMonitor.class), TEST_URL);
47+
48+
IOException exception = assertThrows(IOException.class, () -> cut.handleResponse(response));
49+
assertTrue(exception.getMessage().contains("404"), "Should contain status code");
50+
assertTrue(exception.getMessage().contains(TEST_URL), "Should contain URL");
51+
}
52+
53+
@Test
54+
void shouldThrowOnHttp500WithUrlAndStatus() throws IOException {
55+
String errorBody = "Internal Server Error";
56+
BasicHttpResponse response = getBasicHttpResponse(500, "Internal Server Error", errorBody);
57+
58+
File tempFile = File.createTempFile("pre", "fix");
59+
tempFile.deleteOnExit();
60+
var cut = new FileDownloadResponseHandler(tempFile, mock(IProgressMonitor.class), TEST_URL);
61+
62+
IOException exception = assertThrows(IOException.class, () -> cut.handleResponse(response));
63+
assertTrue(exception.getMessage().contains("500"), "Should contain status code");
64+
assertTrue(exception.getMessage().contains(TEST_URL), "Should contain URL");
65+
}
66+
67+
private BasicHttpResponse getBasicHttpResponse(int statusCode, String reasonPhrase, String payload) {
3768
var response =
38-
new BasicHttpResponse(new BasicStatusLine(new ProtocolVersion("https", 1, 1), 200, "OK"));
69+
new BasicHttpResponse(new BasicStatusLine(new ProtocolVersion("https", 1, 1), statusCode, reasonPhrase));
3970
HttpEntity entity = getHttpEntity(payload);
4071
response.setEntity(entity);
4172
return response;

0 commit comments

Comments
 (0)