Skip to content

Commit 05a45a3

Browse files
committed
Retry direct connection
This will cover recent connections which connect directly without going through the whole setup flow. Pretty much the same logic as for listing editors but we display the errors in different ways since this all happens in a progress dialog. I tried to combine what I could in the retry. Also the SshException is misleading; it seems to wrap the real error so unwrap it otherwise it is impossible to tell what is really wrong. In particular this is causing us to retry on cancelations.
1 parent 73f9b19 commit 05a45a3

File tree

4 files changed

+114
-50
lines changed

4 files changed

+114
-50
lines changed

src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@
22

33
package com.coder.gateway
44

5+
import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff
56
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
7+
import com.intellij.openapi.application.ApplicationManager
68
import com.intellij.openapi.components.service
79
import com.intellij.openapi.diagnostic.Logger
810
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
11+
import com.intellij.openapi.ui.Messages
912
import com.jetbrains.gateway.api.ConnectionRequestor
1013
import com.jetbrains.gateway.api.GatewayConnectionHandle
1114
import com.jetbrains.gateway.api.GatewayConnectionProvider
1215
import com.jetbrains.gateway.api.GatewayUI
1316
import com.jetbrains.gateway.ssh.SshDeployFlowUtil
1417
import com.jetbrains.gateway.ssh.SshMultistagePanelContext
18+
import com.jetbrains.gateway.ssh.deploy.DeployException
1519
import com.jetbrains.rd.util.lifetime.LifetimeDefinition
1620
import kotlinx.coroutines.launch
21+
import net.schmizz.sshj.common.SSHException
22+
import net.schmizz.sshj.connection.ConnectionException
1723
import java.time.Duration
24+
import java.util.concurrent.TimeoutException
1825

1926
class CoderGatewayConnectionProvider : GatewayConnectionProvider {
2027
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
@@ -24,12 +31,42 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
2431
// TODO: If this fails determine if it is an auth error and if so prompt
2532
// for a new token, configure the CLI, then try again.
2633
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) {
27-
val context = SshMultistagePanelContext(parameters.toHostDeployInputs())
28-
logger.info("Deploying and starting IDE with $context")
29-
launch {
30-
@Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle(
31-
clientLifetime, context, Duration.ofMinutes(10)
32-
)
34+
val context = suspendingRetryWithExponentialBackOff(
35+
label = "connect",
36+
logger = logger,
37+
action = { attempt ->
38+
logger.info("Deploying (attempt $attempt)...")
39+
indicator.text =
40+
if (attempt > 1) CoderGatewayBundle.message("gateway.connector.coder.connection.retry.text", attempt)
41+
else CoderGatewayBundle.message("gateway.connector.coder.connection.loading.text")
42+
SshMultistagePanelContext(parameters.toHostDeployInputs())
43+
},
44+
predicate = { e ->
45+
e is ConnectionException || e is TimeoutException
46+
|| e is SSHException || e is DeployException
47+
},
48+
update = { _, e, remaining, ->
49+
if (remaining != null) {
50+
indicator.text2 = e?.message ?: CoderGatewayBundle.message("gateway.connector.no-details")
51+
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connection.retry-error.text", remaining)
52+
} else {
53+
ApplicationManager.getApplication().invokeAndWait {
54+
Messages.showMessageDialog(
55+
e?.message ?: CoderGatewayBundle.message("gateway.connector.no-details"),
56+
CoderGatewayBundle.message("gateway.connector.coder.connection.error.text"),
57+
Messages.getErrorIcon())
58+
}
59+
}
60+
},
61+
)
62+
if (context != null) {
63+
launch {
64+
logger.info("Deploying and starting IDE with $context")
65+
// At this point JetBrains takes over with their own UI.
66+
@Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle(
67+
clientLifetime, context, Duration.ofMinutes(10)
68+
)
69+
}
3370
}
3471
}
3572

src/main/kotlin/com/coder/gateway/sdk/Retry.kt

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,73 @@
11
package com.coder.gateway.sdk
22

3+
import com.intellij.openapi.diagnostic.Logger
4+
import com.intellij.openapi.progress.ProcessCanceledException
5+
import com.intellij.ssh.SshException
36
import kotlinx.coroutines.delay
47
import java.util.Random
58
import java.util.concurrent.TimeUnit
9+
import kotlin.coroutines.cancellation.CancellationException
610
import kotlin.math.min
711

12+
fun unwrap(ex: Exception): Throwable? {
13+
var cause = ex.cause
14+
while(cause?.cause != null) {
15+
cause = cause.cause
16+
}
17+
return cause ?: ex
18+
}
19+
820
/**
9-
* Similar to Intellij's except it gives you the next delay, does not do its own
10-
* logging, updates periodically (for counting down), and runs forever.
21+
* Similar to Intellij's except it gives you the next delay, logs differently,
22+
* updates periodically (for counting down), runs forever, and takes a
23+
* predicate for determining whether we should retry.
24+
*
25+
* The update will have a boolean to indicate whether it is the first update (so
26+
* things like duplicate logs can be avoided). If remaining is null then no
27+
* more retries will be attempted.
28+
*
29+
* If an exception related to canceling is received then return null.
1130
*/
1231
suspend fun <T> suspendingRetryWithExponentialBackOff(
1332
initialDelayMs: Long = TimeUnit.SECONDS.toMillis(5),
1433
backOffLimitMs: Long = TimeUnit.MINUTES.toMillis(3),
1534
backOffFactor: Int = 2,
1635
backOffJitter: Double = 0.1,
17-
update: (attempt: Int, remainingMs: Long, e: Exception) -> Unit,
18-
action: suspend (attempt: Int) -> T
19-
): T {
36+
label: String,
37+
logger: Logger,
38+
predicate: (e: Throwable?) -> Boolean,
39+
update: (attempt: Int, e: Throwable?, remaining: String?) -> Unit,
40+
action: suspend (attempt: Int) -> T?
41+
): T? {
2042
val random = Random()
2143
var delayMs = initialDelayMs
2244
for (attempt in 1..Int.MAX_VALUE) {
2345
try {
2446
return action(attempt)
2547
}
26-
catch (e: Exception) {
48+
catch (originalEx: Exception) {
49+
// SshException can happen due to anything from a timeout to being
50+
// canceled so unwrap to find out.
51+
val unwrappedEx = if (originalEx is SshException) unwrap(originalEx) else originalEx
52+
when (unwrappedEx) {
53+
is InterruptedException,
54+
is CancellationException,
55+
is ProcessCanceledException -> {
56+
logger.info("Retrying $label canceled due to ${unwrappedEx.javaClass}")
57+
return null
58+
}
59+
}
60+
if (!predicate(unwrappedEx)) {
61+
logger.error("Failed to $label (attempt $attempt; will not retry)", originalEx)
62+
update(attempt, unwrappedEx, null)
63+
return null
64+
}
65+
logger.error("Failed to $label (attempt $attempt; will retry in $delayMs ms)", originalEx)
2766
var remainingMs = delayMs
2867
while (remainingMs > 0) {
29-
update(attempt, remainingMs, e)
68+
val remainingS = TimeUnit.MILLISECONDS.toSeconds(remainingMs)
69+
val remaining = if (remainingS < 1) "now" else "in $remainingS second${if (remainingS > 1) "s" else ""}"
70+
update(attempt, unwrappedEx, remaining)
3071
val next = min(remainingMs, TimeUnit.SECONDS.toMillis(1))
3172
remainingMs -= next
3273
delay(next)

src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ import net.schmizz.sshj.connection.ConnectionException
6868
import java.awt.Component
6969
import java.awt.FlowLayout
7070
import java.util.Locale
71-
import java.util.concurrent.TimeUnit
7271
import java.util.concurrent.TimeoutException
7372
import javax.swing.ComboBoxModel
7473
import javax.swing.DefaultComboBoxModel
@@ -79,7 +78,6 @@ import javax.swing.JPanel
7978
import javax.swing.ListCellRenderer
8079
import javax.swing.SwingConstants
8180
import javax.swing.event.DocumentEvent
82-
import kotlin.coroutines.cancellation.CancellationException
8381

8482
class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable {
8583
private val cs = CoroutineScope(Dispatchers.Main)
@@ -179,6 +177,8 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
179177

180178
ideResolvingJob = cs.launch {
181179
val ides = suspendingRetryWithExponentialBackOff(
180+
label = "retrieve IDEs",
181+
logger = logger,
182182
action={ attempt ->
183183
logger.info("Deploying to ${selectedWorkspace.name} on $deploymentURL (attempt $attempt)")
184184
// Reset text in the select dropdown.
@@ -187,46 +187,27 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
187187
if (attempt > 1) CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry.text", attempt)
188188
else CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text"))
189189
}
190-
try {
191-
val executor = createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
192-
if (ComponentValidator.getInstance(tfProject).isEmpty) {
193-
installRemotePathValidator(executor)
194-
}
195-
retrieveIDEs(executor, selectedWorkspace)
196-
} catch (e: Exception) {
197-
when(e) {
198-
is InterruptedException -> Unit
199-
is CancellationException -> Unit
200-
// Throw to retry these. The main one is
201-
// DeployException which fires when dd times out.
202-
is ConnectionException, is TimeoutException,
203-
is SSHException, is DeployException -> throw e
204-
else -> {
205-
withContext(Dispatchers.Main) {
206-
logger.error("Failed to retrieve IDEs (attempt $attempt)", e)
207-
cbIDEComment.foreground = UIUtil.getErrorForeground()
208-
cbIDEComment.text = e.message ?: "The error did not provide any further details"
209-
cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text"), UIUtil.getBalloonErrorIcon())
210-
}
211-
}
212-
}
213-
null
190+
val executor = createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
191+
if (ComponentValidator.getInstance(tfProject).isEmpty) {
192+
installRemotePathValidator(executor)
214193
}
194+
retrieveIDEs(executor, selectedWorkspace)
195+
},
196+
predicate = { e ->
197+
e is ConnectionException || e is TimeoutException
198+
|| e is SSHException || e is DeployException
215199
},
216-
update = { attempt, retryMs, e ->
217-
logger.error("Failed to retrieve IDEs (attempt $attempt; will retry in $retryMs ms)", e)
200+
update = { _, e, remaining ->
218201
cbIDEComment.foreground = UIUtil.getErrorForeground()
219-
cbIDEComment.text = e.message ?: "The error did not provide any further details"
220-
val delayS = TimeUnit.MILLISECONDS.toSeconds(retryMs)
221-
val delay = if (delayS < 1) "now" else "in $delayS second${if (delayS > 1) "s" else ""}"
222-
cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry-error.text", delay))
202+
cbIDEComment.text = e?.message ?: CoderGatewayBundle.message("gateway.connector.no-details")
203+
cbIDE.renderer =
204+
if (remaining != null) IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry-error.text", remaining))
205+
else IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text"), UIUtil.getBalloonErrorIcon())
223206
},
224207
)
225-
if (ides != null) {
226-
withContext(Dispatchers.Main) {
227-
ideComboBoxModel.addAll(ides)
228-
cbIDE.selectedIndex = 0
229-
}
208+
withContext(Dispatchers.Main) {
209+
ideComboBoxModel.addAll(ides)
210+
cbIDE.selectedIndex = 0
230211
}
231212
}
232213
}

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ gateway.connector.recentconnections.new.wizard.button.tooltip=Open a new Coder W
4242
gateway.connector.recentconnections.remove.button.tooltip=Remove from Recent Connections
4343
gateway.connector.recentconnections.terminal.button.tooltip=Open SSH Web Terminal
4444
gateway.connector.coder.connection.provider.title=Connecting to Coder workspace...
45+
gateway.connector.coder.connection.loading.text=Connecting...
46+
gateway.connector.coder.connection.retry.text=Connecting (attempt {0})...
47+
gateway.connector.coder.connection.retry-error.text=Failed to connect...retrying {0}
48+
gateway.connector.coder.connection.error.text=Failed to connect
4549
gateway.connector.settings.binary-source.title=CLI source:
4650
gateway.connector.settings.binary-source.comment=Used to download the Coder \
4751
CLI which is necessary to make SSH connections. The If-None-Matched header \
@@ -54,3 +58,4 @@ gateway.connector.settings.binary-destination.comment=Directories are created \
5458
here that store the CLI and credentials for each domain to which the plugin \
5559
connects. \
5660
Defaults to {0}.
61+
gateway.connector.no-details="The error did not provide any further details"

0 commit comments

Comments
 (0)