You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android中Ktor Client处理401/403状态码并刷新Token问题

Ktor Client 401/403 Token刷新重试问题修复

代码中的核心问题

  1. Auth插件的Token发送范围错误
    当前sendWithoutRequest仅针对www.googleapis.com发送Bearer Token,你的业务接口不会带上Token,这会导致401/403异常,同时Auth插件也无法识别需要处理的请求。

  2. HttpResponseValidator干扰Auth插件的自动处理
    你在validateResponse中捕获401/403但未做正确衔接,且如果expectSuccess为true,Ktor会自动将非2xx状态转为异常,跳过Auth插件的挑战处理逻辑。

  3. refreshTokens函数返回值不严谨
    链式调用ktorLoginResponse.data?.token?.let如果中间任何一步为null,会直接返回null触发登出,但没有明确的错误处理,也未更新本地保存的Token。

  4. HttpRequestRetry与Auth刷新逻辑冲突
    当前Retry插件对所有非成功状态码重试,包括401/403,会在Auth插件处理刷新前就发起无效重复请求。

  5. 未配置Auth插件处理403状态码
    默认Bearer认证只处理401的WWW-Authenticate挑战,403不会触发刷新逻辑,需要手动添加挑战管理器。

修复后的完整代码

private fun httpClient() = HttpClient(engine = configuration.engine()) {
    configuration.requestRetry?.let { config ->
        val retryConfig: HttpRequestRetryConfig = HttpRequestRetryConfig().apply(config)

        install(HttpRequestRetry) {
            retryConfig.maxRetry?.let { maxRetries ->
                val max = maxRetries.invoke()
                // 仅在异常时重试,排除401/403交给Auth插件处理
                retryOnException(
                    maxRetries = max,
                    retryOnTimeout = retryConfig.retryOnTimeout.invoke()
                )
                // 重试非成功状态,但排除401/403
                retryIf(maxRetries = max) { _, response ->
                    !response.status.isSuccess() && 
                    response.status != HttpStatusCode.Unauthorized && 
                    response.status != HttpStatusCode.Forbidden
                }
            }
            exponentialDelay()
            modifyRequest {
                request.headers.append("x-retry-count", retryCount.toString())
            }
        }
    }

    install(ContentNegotiation) {
        json(
            Json {
                ignoreUnknownKeys = true
                encodeDefaults = true
                isLenient = true
                prettyPrint = true
                explicitNulls = false
            },
        )
    }

    Logging {
        logger = object : Logger {
            override fun log(message: String) {
                if (configuration.logging) {
                    httpMetrics.log(message)
                }
            }
        }
        level = LogLevel.ALL
    }

    install(HttpTimeout) {
        requestTimeoutMillis = timeoutConfig.requestTimeoutMillis
        connectTimeoutMillis = timeoutConfig.connectTimeoutMillis
        socketTimeoutMillis = timeoutConfig.socketTimeoutMillis
    }

    defaultRequest {
        url {
            protocol = URLProtocol.createOrDefault(environment.protocol)
            host = environment.baseUrl
        }
        contentType(configuration.contentType)
    }

    HttpResponseValidator {
        // 关闭自动抛出非2xx异常,让Auth插件先处理401/403
        expectSuccess = false
        validateResponse { response ->
            val statusCode = response.status.value
            val originCall = response.call

            // 处理204无数据的情况
            if (statusCode == 204) {
                throw AppException(ErrorResponse(statusCode.toString(), NO_DATA_FOUND))
            }

            // 跳过已标记为重试的请求,避免循环验证
            if (statusCode < 300 || originCall.attributes.contains(ValidateMark)) {
                return@validateResponse
            }

            // 除401/403外,其他错误抛出自定义异常
            if (statusCode != 401 && statusCode != 403) {
                val exceptionCall = originCall.save().apply {
                    attributes.put(ValidateMark, Unit)
                }
                val exceptionResponseText = try {
                    exceptionCall.response.bodyAsText()
                } catch (_: MalformedInputException) {
                    BODY_FAILED_DECODING
                }
                val errorResponse = Json.decodeFromString<ErrorResponse>(exceptionResponseText)
                throw AppException(errorResponse)
            }
        }
    }

    install(Auth) {
        bearer {
            // 对业务接口域名发送Token(根据实际需求调整)
            sendWithoutRequest { request ->
                request.url.host == environment.baseUrl
            }

            // 加载本地保存的Token
            loadTokens {
                val authToken = authToken()
                BearerTokens(
                    accessToken = authToken.accessToken.orEmpty(),
                    refreshToken = authToken.refreshToken.orEmpty()
                )
            }

            // 添加挑战管理器,让403也触发刷新逻辑
            challengeManager {
                challenge(BearerAuthChallengeKey) { challenge, call ->
                    val response = call.response
                    if (response.status == HttpStatusCode.Unauthorized || response.status == HttpStatusCode.Forbidden) {
                        // 触发Token刷新
                        val newTokens = refreshTokens()
                        if (newTokens != null) {
                            // 重试原请求
                            retryWithNewTokens(newTokens)
                        } else {
                            // 刷新失败,执行登出
                            println("application will logout")
                            // 这里添加你的登出逻辑:清空本地Token、跳转登录页等
                            // logout()
                            challenge.complete()
                        }
                    } else {
                        challenge.complete()
                    }
                }
            }

            // 刷新Token逻辑
            refreshTokens {
                val authToken = authToken()
                val workEmail = authToken.workEmail.orEmpty()
                val currentRefreshToken = authToken.refreshToken.orEmpty()

                // 调用刷新Token接口
                val response = client.post {
                    markAsRefreshTokenRequest()
                    this.apply(
                        RefreshToken(
                            refreshTokenId = "",
                            refreshToken = currentRefreshToken,
                            email = workEmail,
                            deviceId = deviceId!!
                        )
                    )
                }

                return@refreshTokens if (response.status == HttpStatusCode.Unauthorized || response.status == HttpStatusCode.Forbidden) {
                    // 刷新Token失败,返回null触发登出
                    null
                } else {
                    val ktorLoginResponse = response.body<RefreshTokenModel>()
                    val newAccessToken = ktorLoginResponse.data?.token.orEmpty()
                    val newRefreshToken = ktorLoginResponse.data?.refreshToken.orEmpty()

                    if (newAccessToken.isNotEmpty() && newRefreshToken.isNotEmpty()) {
                        // 更新本地保存的Token
                        // saveAuthToken(authToken.copy(accessToken = newAccessToken, refreshToken = newRefreshToken))
                        BearerTokens(newAccessToken, newRefreshToken)
                    } else {
                        // Token返回不完整,视为刷新失败
                        null
                    }
                }
            }
        }
    }
}

// 定义属性标记,避免重复验证请求
private val ValidateMark = AttributeKey<Unit>("ValidateMark")

关键修复说明

  • 调整Token发送范围:sendWithoutRequest改为匹配你的业务接口域名,确保所有需要认证的请求都带上Bearer Token。
  • 分离Retry与Auth逻辑:Retry插件排除401/403,让Auth插件专门处理Token刷新重试,避免逻辑冲突。
  • 配置403挑战处理:通过challengeManager让403状态码也触发Token刷新流程。
  • 完善refreshTokens逻辑:增加Token非空判断,刷新成功后更新本地Token,避免无效返回导致误登出。
  • 调整HttpResponseValidator:关闭expectSuccess,让Auth插件优先处理401/403,其他错误再抛出自定义异常。

内容的提问来源于stack exchange,提问作者Rahul Abrol

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 03:12:34