Skip to content

Commit c408d9d

Browse files
committed
fix: show login screen when token expires during workspace polling
- in fact we will now jump to the login screen for any error other than socket timeout because of an OS wake-up - this patch also contains a re-work of the REST API exception. Coder backend sends very detailed messages with the reason for the http calls to be rejected. We now un-marshall those responses and fill the exception system with better details.
1 parent 6f604bb commit c408d9d

File tree

5 files changed

+178
-33
lines changed

5 files changed

+178
-33
lines changed

CHANGELOG.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- login screen is shown instead of an empty list of workspaces when token expired
8+
59
## 0.1.4 - 2025-04-11
610

711
### Fixed
812

9-
- SSH connection to a Workspace is no longer established only once
10-
- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder deployment
13+
- SSH connection to a Workspace is no longer established only once
14+
- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder
15+
deployment
1116

1217
### Changed
1318

14-
- action buttons on the token input step were swapped to achieve better keyboard navigation
19+
- action buttons on the token input step were swapped to achieve better keyboard navigation
1520
- URI `project_path` query parameter was renamed to `folder`
1621

1722
## 0.1.3 - 2025-04-09

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.coder.toolbox
22

33
import com.coder.toolbox.cli.CoderCLIManager
44
import com.coder.toolbox.sdk.CoderRestClient
5+
import com.coder.toolbox.sdk.ex.APIResponseException
56
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
67
import com.coder.toolbox.util.CoderProtocolHandler
78
import com.coder.toolbox.util.DialogUi
@@ -145,10 +146,17 @@ class CoderRemoteProvider(
145146
logout()
146147
break
147148
}
149+
} catch (ex: APIResponseException) {
150+
context.logger.error(ex, "error in contacting ${client.url} while polling the available workspaces")
151+
pollError = ex
152+
logout()
153+
goToEnvironmentsPage()
154+
break
148155
} catch (ex: Exception) {
149156
context.logger.error(ex, "workspace polling error encountered")
150157
pollError = ex
151158
logout()
159+
goToEnvironmentsPage()
152160
break
153161
}
154162

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

+69-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.coder.toolbox.sdk.convertors.OSConverter
77
import com.coder.toolbox.sdk.convertors.UUIDConverter
88
import com.coder.toolbox.sdk.ex.APIResponseException
99
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
10+
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
1011
import com.coder.toolbox.sdk.v2.models.BuildInfo
1112
import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest
1213
import com.coder.toolbox.sdk.v2.models.Template
@@ -24,6 +25,7 @@ import com.coder.toolbox.util.getOS
2425
import com.squareup.moshi.Moshi
2526
import okhttp3.Credentials
2627
import okhttp3.OkHttpClient
28+
import retrofit2.Response
2729
import retrofit2.Retrofit
2830
import retrofit2.converter.moshi.MoshiConverterFactory
2931
import java.net.HttpURLConnection
@@ -55,6 +57,7 @@ open class CoderRestClient(
5557
private val pluginVersion: String = "development",
5658
) {
5759
private val settings = context.settingsStore.readOnly()
60+
private lateinit var moshi: Moshi
5861
private lateinit var httpClient: OkHttpClient
5962
private lateinit var retroRestClient: CoderV2RestFacade
6063

@@ -66,7 +69,7 @@ open class CoderRestClient(
6669
}
6770

6871
fun setupSession() {
69-
val moshi =
72+
moshi =
7073
Moshi.Builder()
7174
.add(ArchConverter())
7275
.add(InstantConverter())
@@ -152,7 +155,7 @@ open class CoderRestClient(
152155
suspend fun me(): User {
153156
val userResponse = retroRestClient.me()
154157
if (!userResponse.isSuccessful) {
155-
throw APIResponseException("authenticate", url, userResponse)
158+
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
156159
}
157160

158161
return userResponse.body()!!
@@ -165,7 +168,12 @@ open class CoderRestClient(
165168
suspend fun workspaces(): List<Workspace> {
166169
val workspacesResponse = retroRestClient.workspaces("owner:me")
167170
if (!workspacesResponse.isSuccessful) {
168-
throw APIResponseException("retrieve workspaces", url, workspacesResponse)
171+
throw APIResponseException(
172+
"retrieve workspaces",
173+
url,
174+
workspacesResponse.code(),
175+
workspacesResponse.parseErrorBody(moshi)
176+
)
169177
}
170178

171179
return workspacesResponse.body()!!.workspaces
@@ -178,7 +186,12 @@ open class CoderRestClient(
178186
suspend fun workspace(workspaceID: UUID): Workspace {
179187
val workspacesResponse = retroRestClient.workspace(workspaceID)
180188
if (!workspacesResponse.isSuccessful) {
181-
throw APIResponseException("retrieve workspace", url, workspacesResponse)
189+
throw APIResponseException(
190+
"retrieve workspace",
191+
url,
192+
workspacesResponse.code(),
193+
workspacesResponse.parseErrorBody(moshi)
194+
)
182195
}
183196

184197
return workspacesResponse.body()!!
@@ -209,15 +222,25 @@ open class CoderRestClient(
209222
val resourcesResponse =
210223
retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID)
211224
if (!resourcesResponse.isSuccessful) {
212-
throw APIResponseException("retrieve resources for ${workspace.name}", url, resourcesResponse)
225+
throw APIResponseException(
226+
"retrieve resources for ${workspace.name}",
227+
url,
228+
resourcesResponse.code(),
229+
resourcesResponse.parseErrorBody(moshi)
230+
)
213231
}
214232
return resourcesResponse.body()!!
215233
}
216234

217235
suspend fun buildInfo(): BuildInfo {
218236
val buildInfoResponse = retroRestClient.buildInfo()
219237
if (!buildInfoResponse.isSuccessful) {
220-
throw APIResponseException("retrieve build information", url, buildInfoResponse)
238+
throw APIResponseException(
239+
"retrieve build information",
240+
url,
241+
buildInfoResponse.code(),
242+
buildInfoResponse.parseErrorBody(moshi)
243+
)
221244
}
222245
return buildInfoResponse.body()!!
223246
}
@@ -228,7 +251,12 @@ open class CoderRestClient(
228251
private suspend fun template(templateID: UUID): Template {
229252
val templateResponse = retroRestClient.template(templateID)
230253
if (!templateResponse.isSuccessful) {
231-
throw APIResponseException("retrieve template with ID $templateID", url, templateResponse)
254+
throw APIResponseException(
255+
"retrieve template with ID $templateID",
256+
url,
257+
templateResponse.code(),
258+
templateResponse.parseErrorBody(moshi)
259+
)
232260
}
233261
return templateResponse.body()!!
234262
}
@@ -240,7 +268,12 @@ open class CoderRestClient(
240268
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START)
241269
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
242270
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
243-
throw APIResponseException("start workspace ${workspace.name}", url, buildResponse)
271+
throw APIResponseException(
272+
"start workspace ${workspace.name}",
273+
url,
274+
buildResponse.code(),
275+
buildResponse.parseErrorBody(moshi)
276+
)
244277
}
245278
return buildResponse.body()!!
246279
}
@@ -251,7 +284,12 @@ open class CoderRestClient(
251284
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP)
252285
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
253286
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
254-
throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse)
287+
throw APIResponseException(
288+
"stop workspace ${workspace.name}",
289+
url,
290+
buildResponse.code(),
291+
buildResponse.parseErrorBody(moshi)
292+
)
255293
}
256294
return buildResponse.body()!!
257295
}
@@ -263,7 +301,12 @@ open class CoderRestClient(
263301
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false)
264302
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
265303
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
266-
throw APIResponseException("delete workspace ${workspace.name}", url, buildResponse)
304+
throw APIResponseException(
305+
"delete workspace ${workspace.name}",
306+
url,
307+
buildResponse.code(),
308+
buildResponse.parseErrorBody(moshi)
309+
)
267310
}
268311
}
269312

@@ -283,7 +326,12 @@ open class CoderRestClient(
283326
CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START)
284327
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
285328
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
286-
throw APIResponseException("update workspace ${workspace.name}", url, buildResponse)
329+
throw APIResponseException(
330+
"update workspace ${workspace.name}",
331+
url,
332+
buildResponse.code(),
333+
buildResponse.parseErrorBody(moshi)
334+
)
287335
}
288336
return buildResponse.body()!!
289337
}
@@ -296,3 +344,13 @@ open class CoderRestClient(
296344
}
297345
}
298346
}
347+
348+
private fun Response<*>.parseErrorBody(moshi: Moshi): ApiErrorResponse? {
349+
val errorBody = this.errorBody() ?: return null
350+
return try {
351+
val adapter = moshi.adapter(ApiErrorResponse::class.java)
352+
adapter.fromJson(errorBody.string())
353+
} catch (e: Exception) {
354+
null
355+
}
356+
}
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,90 @@
11
package com.coder.toolbox.sdk.ex
22

3+
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
34
import java.io.IOException
45
import java.net.HttpURLConnection
56
import java.net.URL
67

7-
class APIResponseException(action: String, url: URL, res: retrofit2.Response<*>) :
8-
IOException(
9-
"Unable to $action: url=$url, code=${res.code()}, details=${
10-
when (res.code()) {
11-
HttpURLConnection.HTTP_NOT_FOUND -> "The requested resource could not be found"
12-
else -> res.errorBody()?.charStream()?.use {
13-
val text = it.readText()
14-
// Be careful with the length because if you try to show a
15-
// notification in Toolbox that is too large it crashes the
16-
// application.
17-
if (text.length > 500) {
18-
"${text.substring(0, 500)}"
19-
} else {
20-
text
21-
}
22-
} ?: "no details provided"
23-
}}",
24-
) {
25-
val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED
8+
class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) :
9+
IOException(formatToPretty(action, url, code, errorResponse)) {
10+
11+
12+
val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code
13+
14+
companion object {
15+
private fun formatToPretty(
16+
action: String,
17+
url: URL,
18+
code: Int,
19+
errorResponse: ApiErrorResponse?,
20+
): String {
21+
return if (errorResponse == null) {
22+
"Unable to $action: url=$url, code=$code, details=${HttpErrorStatusMapper.getMessage(code)}"
23+
} else {
24+
var msg = "Unable to $action: url=$url, code=$code, message=${errorResponse.message}"
25+
if (errorResponse.detail?.isNotEmpty() == true) {
26+
msg += ", reason=${errorResponse.detail}"
27+
}
28+
29+
// Be careful with the length because if you try to show a
30+
// notification in Toolbox that is too large it crashes the
31+
// application.
32+
if (msg.length > 500) {
33+
msg = "${msg.substring(0, 500)}"
34+
}
35+
msg
36+
}
37+
}
38+
}
39+
}
40+
41+
private object HttpErrorStatusMapper {
42+
private val errorStatusMap = mapOf(
43+
// 4xx: Client Errors
44+
400 to "Bad Request",
45+
401 to "Unauthorized",
46+
402 to "Payment Required",
47+
403 to "Forbidden",
48+
404 to "Not Found",
49+
405 to "Method Not Allowed",
50+
406 to "Not Acceptable",
51+
407 to "Proxy Authentication Required",
52+
408 to "Request Timeout",
53+
409 to "Conflict",
54+
410 to "Gone",
55+
411 to "Length Required",
56+
412 to "Precondition Failed",
57+
413 to "Payload Too Large",
58+
414 to "URI Too Long",
59+
415 to "Unsupported Media Type",
60+
416 to "Range Not Satisfiable",
61+
417 to "Expectation Failed",
62+
418 to "I'm a teapot",
63+
421 to "Misdirected Request",
64+
422 to "Unprocessable Entity",
65+
423 to "Locked",
66+
424 to "Failed Dependency",
67+
425 to "Too Early",
68+
426 to "Upgrade Required",
69+
428 to "Precondition Required",
70+
429 to "Too Many Requests",
71+
431 to "Request Header Fields Too Large",
72+
451 to "Unavailable For Legal Reasons",
73+
74+
// 5xx: Server Errors
75+
500 to "Internal Server Error",
76+
501 to "Not Implemented",
77+
502 to "Bad Gateway",
78+
503 to "Service Unavailable",
79+
504 to "Gateway Timeout",
80+
505 to "HTTP Version Not Supported",
81+
506 to "Variant Also Negotiates",
82+
507 to "Insufficient Storage",
83+
508 to "Loop Detected",
84+
510 to "Not Extended",
85+
511 to "Network Authentication Required"
86+
)
87+
88+
fun getMessage(code: Int): String =
89+
errorStatusMap[code] ?: "Unknown Error Status"
2690
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.coder.toolbox.sdk.v2.models
2+
3+
import com.squareup.moshi.Json
4+
import com.squareup.moshi.JsonClass
5+
6+
@JsonClass(generateAdapter = true)
7+
data class ApiErrorResponse(
8+
@Json(name = "message") val message: String,
9+
@Json(name = "detail") val detail: String?,
10+
)

0 commit comments

Comments
 (0)