返回

Ktor请求Job was cancelled错误解决:超时、协程及重试

Android

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 的网络请求本身就是在协程里跑的。可能有几个原因导致了这个取消:

  1. 超时: Ktor 客户端有默认的超时设置。如果请求时间过长,超过了这个限制,请求所在的协程就会被取消。

  2. 客户端问题: 有时候,Ktor 客户端自身的某些内部问题,可能导致协程异常取消。

  3. 作用域问题 : 如果使用了不正确的作用域来启动网络请求所在的协程,这个作用域过早结束,也可能导致请求取消。

  4. 网络连接不稳定 : 偶发的网络不稳定也有一定概率导致此错误。

  5. 服务器端问题: 虽然概率小点,但是 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. 检查网络连接和服务器状态.

  1. 手机网络情况: 用别的App测试一下网速, 看是不是网络太慢或不稳定.

  2. 服务器状态: 可以到Back4App的状态页面或社区,看看是不是服务器那边有问题.

安全小贴士

代码里直接写密钥("X-Parse-Application-Id", "X-Parse-REST-API-Key")不太安全。最好是把这些密钥放到安全的地方,比如:

  • 环境变量: 在构建系统或运行环境里设置环境变量,然后在代码里读取。
  • 本地属性文件(local.properties): 这个文件不会提交到 Git 仓库,可以放一些本地的配置。
  • Android Keystore: 这是安卓提供的安全存储机制,可以用来存密钥。
  • Firebase Remote Config 或类似服务: 把配置放到云端,可以动态更新,也更安全。

希望上面这些方法能帮到你。 把 Ktor 的 "Job was cancelled" 错误给摆平!