@@ -32,18 +32,28 @@ class HttpClient(
3232 private val jsonMedia = "application/json".toMediaType()
3333
3434 suspend fun get(url: String, auth: String?): Result<HttpResp> =
35− exec(Request.Builder().url(url).get(), auth)
35+ exec(url, auth) { get() }
3636
3737 suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38− exec(Request.Builder().url(url).post(body.toRequestBody(jsonMedia)), auth)
38+ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
3939
4040 suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41− exec(Request.Builder().url(url).delete(), auth)
41+ exec(url, auth) { delete() }
4242
43− private suspend fun exec(builder: Request.Builder, auth: String?): Result<HttpResp> = withContext(Dispatchers.IO) {
44− if (auth != null) builder.header("Authorization", auth)
45− val request = builder.build()
46− runCatching { await(okhttp.newCall(request)) }
43+ // Build and run the request off the main thread. Building it — parsing [url]
44+ // in particular — can throw (e.g. a URL with no scheme), so that happens
45+ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46+ // instead of crashing the caller's coroutine.
47+ private suspend fun exec(
48+ url: String,
49+ auth: String?,
50+ method: Request.Builder.() -> Request.Builder,
51+ ): Result<HttpResp> = withContext(Dispatchers.IO) {
52+ runCatching {
53+ val builder = Request.Builder().url(url).method()
54+ if (auth != null) builder.header("Authorization", auth)
55+ await(okhttp.newCall(builder.build()))
56+ }
4757 }
4858
4959 private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->