Ktor请求Job was cancelled错误解决:超时、协程及重试
2025-02-28 10:06:32
Ktor 请求中 "Job was cancelled" 错误解决之道
最近在用 Ktor 写安卓应用的 REST 服务,后端用的是 Back4App。发 POST 请求时,Ktor 的日志里老是蹦出这么个错误:
REQUEST https://parseapi.back4app.com/classes/Feed failed with exception: java.util.concurrent.CancellationException: Job was cancelled
应用里的协程逻辑我基本没怎么管,所以看到这个错误有点懵。奇怪的是,用 Insomnia 测试接口一切正常。而且,即使应用里出现这个错误,程序也没崩溃,还能继续跑。这到底咋回事?
问题原因探究
"Job was cancelled" 这个错误,通常意味着协程任务被取消了。尽管我没有直接操作协程,但 Ktor 的网络请求本身就是在协程里跑的。可能有几个原因导致了这个取消:
-
超时: Ktor 客户端有默认的超时设置。如果请求时间过长,超过了这个限制,请求所在的协程就会被取消。
-
客户端问题: 有时候,Ktor 客户端自身的某些内部问题,可能导致协程异常取消。
-
作用域问题 : 如果使用了不正确的作用域来启动网络请求所在的协程,这个作用域过早结束,也可能导致请求取消。
-
网络连接不稳定 : 偶发的网络不稳定也有一定概率导致此错误。
-
服务器端问题: 虽然概率小点,但是 Back4App 服务器那边出了问题也说不定,导致连接过早关闭。
问题解决大法
针对上面分析的原因,咱一个一个来解决:
1. 调整 Ktor 客户端超时设置
Ktor 允许自定义超时设置。可以根据实际情况,把超时时间调长一点。
原理: 通过修改 HttpClient
的配置, 延长允许的请求时间, 避免因为时间过短导致请求被取消。
代码示例:
import io.ktor.client.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
val client = HttpClient(Android) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30000 // 请求超时时间,设置为30秒
connectTimeoutMillis = 10000 // 连接超时
socketTimeoutMillis = 10000 // socket超时
}
}
在创建 HttpClient
的地方, 加一个 HttpTimeout
的配置。 requestTimeoutMillis
管的是整个请求的超时,包括连接、发送数据、接收数据。connectTimeoutMillis
是连接超时的设置。 socketTimeoutMillis
指的是两个连续数据包之间的最大间隔, 超时也会导致请求被取消。 可以按需设置。
2. 检查作用域 (CoroutineScope)
确保你在一个合适的作用域里启动了协程。一般来说,如果是在 ViewModel 里发请求,应该用 viewModelScope
。如果在 Composable 函数里,可以用 rememberCoroutineScope
。
原理: 正确的作用域保证了协程的生命周期和组件 (ViewModel 或 Composable) 绑定。组件销毁时,作用域也会取消,里面的协程自然也就停了,避免泄漏。
代码示例 (ViewModel):
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class MyViewModel : ViewModel() {
fun addFeed(feed: FeedEntity) {
viewModelScope.launch {
// 调用你的 suspend fun addFeed(newFeedDto: FeedEntity)
val result = repository.addFeed(feed)
// 处理请求结果
}
}
}
在 ViewModel 里,直接用 viewModelScope.launch
就行。
代码示例 (Composable):
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.*
@Composable
fun MyScreen(repository: Repository) { //假设的 Repository
val scope = rememberCoroutineScope()
var isSubmitting by remember{ mutableStateOf(false)} //处理按钮状态.
Button(onClick = {
if(!isSubmitting){
isSubmitting = true
scope.launch {
// 调用你的suspend函数
try{
repository.addFeed(feedData) // 假设的feedData
//更新状态
}finally{
isSubmitting = false
}
}
}
}, enabled=!isSubmitting) {
// ...
}
}
Composable 函数里,用rememberCoroutineScope
创建一个协程作用域,然后在点击事件或其他需要的地方,用这个 scope 启动协程。
3. 使用 withTimeoutOrNull
如果希望请求超时后,不抛出异常,而是返回 null
,可以用 withTimeoutOrNull
。
原理: withTimeoutOrNull
提供了一个更优雅的超时处理方式。它不会直接抛 TimeoutCancellationException
,而是返回 null
,可以更方便地处理超时情况。
代码示例:
import kotlinx.coroutines.withTimeoutOrNull
suspend fun addFeed(newFeedDto: FeedEntity): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val response = withTimeoutOrNull(20000) { // 20秒超时
client.post(BACK4APPURL) {
// ... 和之前一样
}
}
if (response == null) {
// 超时了
error("Request timed out") // 或者别的处理
} else if (response.status != HttpStatusCode.Created) {
//其他错误。
error("Status ${response.status}")
}
response!!.body() // response 不会是null。
}
}
这样,即使超时了,也不会抛 CancellationException
,而是进到 if (response == null)
的分支里, 更方便处理.
4. 切换引擎(比较少用,但是一种可能)
Ktor 支持不同的 HTTP 客户端引擎。默认的可能不太稳定,可以试试别的。例如,可以换成 OkHttp。
原理: 不同的引擎,底层实现不一样,对网络状况的适应性可能也不同。换个引擎,说不定就能解决问题。
代码示例:
// build.gradle.kts (:app)
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.4") //版本号按需更改。
// 其他依赖...
}
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
val client = HttpClient(OkHttp) {
// 其他配置...
engine {
// OkHttp 特有的配置,比如:
config {
retryOnConnectionFailure(true)
// ...
}
}
}
先在 build.gradle.kts
里添加 OkHttp 引擎的依赖。然后在创建 HttpClient
时,指定引擎为 OkHttp
。还可以通过 engine
块,对 OkHttp 进行一些额外配置。
5. 异常捕获和重试
如果问题还是没解决,可以考虑加一些异常捕获和重试机制。
原理: 网络请求难免会遇到各种问题。捕获异常,可以避免程序崩溃。重试,则可以在遇到暂时性问题时,增加请求成功的概率。
代码示例:
import kotlinx.coroutines.*
import io.ktor.client.plugins.*
import io.ktor.utils.io.errors.*
suspend fun <T> retryIO(
times: Int = 3,
initialDelay: Long = 1000, // 1 秒
maxDelay: Long = 5000, // 最大 5 秒
factor: Double = 2.0,
block: suspend () -> T): T
{
var currentDelay = initialDelay
repeat(times - 1) {
try {
return block()
} catch (e: IOException) { // 只针对 IOException 重试, TimeoutCancellationException 继承自 IOException。
// 可以根据 e 的具体类型,决定是否重试。
//e.printStackTrace() 不推荐直接print。 实际应该打log。
}catch(e: HttpRequestTimeoutException){
//... 超时相关的。
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
return block() // 最后一次,不捕获异常。 让调用者决定怎么处理。
}
//使用方法。
suspend fun addFeed(newFeedDto: FeedEntity): Result<Unit> = withContext(Dispatchers.IO){
runCatching{
val response = retryIO{
client.post(BACK4APPURL) {
//...
}
}
if (response.status != HttpStatusCode.Created) {
//其他错误
error("Status ${response.status}")
}
response.body()
}
}
retryIO
函数实现了简单的重试逻辑:最多重试 times
次, 每次重试之间有延迟, 延迟时间指数增长, 但不会超过 maxDelay
。只有遇到 IOException
才会重试。 如果最后一次还失败,就把异常抛出去。这样可以给用户提示,或者做其他处理.
6. 检查网络连接和服务器状态.
-
手机网络情况: 用别的App测试一下网速, 看是不是网络太慢或不稳定.
-
服务器状态: 可以到Back4App的状态页面或社区,看看是不是服务器那边有问题.
安全小贴士
代码里直接写密钥("X-Parse-Application-Id", "X-Parse-REST-API-Key")不太安全。最好是把这些密钥放到安全的地方,比如:
- 环境变量: 在构建系统或运行环境里设置环境变量,然后在代码里读取。
- 本地属性文件(local.properties): 这个文件不会提交到 Git 仓库,可以放一些本地的配置。
- Android Keystore: 这是安卓提供的安全存储机制,可以用来存密钥。
- Firebase Remote Config 或类似服务: 把配置放到云端,可以动态更新,也更安全。
希望上面这些方法能帮到你。 把 Ktor 的 "Job was cancelled" 错误给摆平!