mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
retry: add exponential backoff
This commit is contained in:
@@ -135,6 +135,10 @@ profileAutoInjectMaxUsers: 4
|
|||||||
profileAutoInjectSummaryMaxChars: 300
|
profileAutoInjectSummaryMaxChars: 300
|
||||||
# 备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。0为禁用
|
# 备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。0为禁用
|
||||||
fallbackCooldownMinutes: 5
|
fallbackCooldownMinutes: 5
|
||||||
|
# 对话、画像和视觉模型失败后的统一退避:首轮基础毫秒数、单次最大毫秒数;设为0可禁用
|
||||||
|
# 实际等待会按失败次数指数增长,并加入20%以内的随机抖动,两个值最大均为60000
|
||||||
|
retryBackoffBaseMillis: 1000
|
||||||
|
retryBackoffMaxMillis: 10000
|
||||||
# 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}}
|
# 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}}
|
||||||
reasoningModelExtraBody: ''
|
reasoningModelExtraBody: ''
|
||||||
# 视觉模型额外请求体JSON,会合并到请求体中。
|
# 视觉模型额外请求体JSON,会合并到请求体中。
|
||||||
@@ -428,10 +432,10 @@ fallbackCooldownMinutes: 5
|
|||||||
### 工作机制
|
### 工作机制
|
||||||
|
|
||||||
- 主接入点为列表首位,备用接入点按配置顺序排在其后。
|
- 主接入点为列表首位,备用接入点按配置顺序排在其后。
|
||||||
- **单次对话内**:某接入点流式调用失败时,立即切换到下一个接入点重试,而非反复重试同一个故障点。
|
- **单次对话内**:某接入点流式调用失败时,经短暂指数退避后切换到下一个接入点重试,而非反复重试同一个故障点。
|
||||||
- **跨对话冷却**:失败的接入点进入冷却期(`fallbackCooldownMinutes` 分钟),冷却期内会被排到重试队尾。这样主接入点 key 到期后,后续消息会直接走健康的备用接入点,不必每条都先卡一次超时。调用成功或冷却到期后自动恢复。
|
- **跨对话冷却**:失败的接入点进入冷却期(`fallbackCooldownMinutes` 分钟),冷却期内会被排到重试队尾。这样主接入点 key 到期后,后续消息会直接走健康的备用接入点,不必每条都先卡一次超时。调用成功或冷却到期后自动恢复。
|
||||||
- 仅在**LLM 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。
|
- 仅在**LLM 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。
|
||||||
- 推理模型、视觉模型不受影响,仍各自独立配置。
|
- 备用接入点切换只作用于聊天模型;推理模型和视觉模型仍使用各自接入点,其中视觉模型自身的失败重试也使用统一退避。
|
||||||
- `/jgpt reload` 会重建接入点列表并清空冷却状态。
|
- `/jgpt reload` 会重建接入点列表并清空冷却状态。
|
||||||
|
|
||||||
## 工具系统
|
## 工具系统
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
|||||||
@ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟")
|
@ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟")
|
||||||
val fallbackCooldownMinutes: Long by value(5L)
|
val fallbackCooldownMinutes: Long by value(5L)
|
||||||
|
|
||||||
|
@ValueDescription("失败后首次重试的基础退避时间(毫秒),后续按指数增长并加入抖动。设为0禁用退避,最大60000")
|
||||||
|
val retryBackoffBaseMillis: Long by value(1000L)
|
||||||
|
|
||||||
|
@ValueDescription("失败重试的最大退避时间(毫秒)。设为0禁用退避,最大60000")
|
||||||
|
val retryBackoffMaxMillis: Long by value(10000L)
|
||||||
|
|
||||||
@ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
|
@ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
|
||||||
val reasoningModelExtraBody: String by value("")
|
val reasoningModelExtraBody: String by value("")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.aallam.openai.api.chat.ToolCall
|
|||||||
import com.aallam.openai.api.core.Usage
|
import com.aallam.openai.api.core.Usage
|
||||||
import com.aallam.openai.api.model.ModelId
|
import com.aallam.openai.api.model.ModelId
|
||||||
import io.ktor.util.collections.ConcurrentSet
|
import io.ktor.util.collections.ConcurrentSet
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -46,6 +47,7 @@ import top.jie65535.mirai.tools.VisitWeb
|
|||||||
import top.jie65535.mirai.tools.VisualAgent
|
import top.jie65535.mirai.tools.VisualAgent
|
||||||
import top.jie65535.mirai.tools.WeatherService
|
import top.jie65535.mirai.tools.WeatherService
|
||||||
import top.jie65535.mirai.tools.WebSearch
|
import top.jie65535.mirai.tools.WebSearch
|
||||||
|
import top.jie65535.mirai.util.RetryBackoff
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
@@ -128,6 +130,8 @@ internal object ConversationEngine {
|
|||||||
var endpointIndex = 0
|
var endpointIndex = 0
|
||||||
var done: Boolean
|
var done: Boolean
|
||||||
var retry = max(PluginConfig.retryMax, 3)
|
var retry = max(PluginConfig.retryMax, 3)
|
||||||
|
val retryBackoff = RetryBackoff.fromConfig()
|
||||||
|
var consecutiveFailures = 0
|
||||||
do {
|
do {
|
||||||
val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)]
|
val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)]
|
||||||
var streamingOk = false
|
var streamingOk = false
|
||||||
@@ -183,6 +187,7 @@ internal object ConversationEngine {
|
|||||||
|
|
||||||
streamingOk = true
|
streamingOk = true
|
||||||
LargeLanguageModels.reportSuccess(endpoint)
|
LargeLanguageModels.reportSuccess(endpoint)
|
||||||
|
consecutiveFailures = 0
|
||||||
val answer = responseContent?.replace(thinkRegex, "")?.trim()
|
val answer = responseContent?.replace(thinkRegex, "")?.trim()
|
||||||
JChatGPT.logger.info("LLM Response: $answer")
|
JChatGPT.logger.info("LLM Response: $answer")
|
||||||
history += ChatMessage(
|
history += ChatMessage(
|
||||||
@@ -217,24 +222,34 @@ internal object ConversationEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (cause: Exception) {
|
} catch (cause: Exception) {
|
||||||
if (!streamingOk) {
|
if (cause is CancellationException) throw cause
|
||||||
|
val failureMessage = if (!streamingOk) {
|
||||||
LargeLanguageModels.reportFailure(endpoint)
|
LargeLanguageModels.reportFailure(endpoint)
|
||||||
if (endpointIndex < endpoints.lastIndex) {
|
if (endpointIndex < endpoints.lastIndex) {
|
||||||
endpointIndex++
|
endpointIndex++
|
||||||
|
"接入点[${endpoint.label}]调用失败,将切换备用接入点[${endpoints[endpointIndex].label}]"
|
||||||
|
} else {
|
||||||
|
"接入点[${endpoint.label}]调用失败,无更多备用接入点"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"调用llm后处理时发生异常"
|
||||||
|
}
|
||||||
|
if (retry <= 1) {
|
||||||
|
JChatGPT.logger.warning("$failureMessage,已无剩余尝试", cause)
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
consecutiveFailures++
|
||||||
|
val retryDelayMillis = retryBackoff.delayMillis(consecutiveFailures)
|
||||||
JChatGPT.logger.warning(
|
JChatGPT.logger.warning(
|
||||||
"接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试",
|
"$failureMessage,将在 ${retryDelayMillis}ms 后重试",
|
||||||
cause,
|
cause,
|
||||||
)
|
)
|
||||||
} else {
|
if (retryDelayMillis > 0) delay(retryDelayMillis)
|
||||||
JChatGPT.logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", cause)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
JChatGPT.logger.warning("调用llm后处理时发生异常,重试中", cause)
|
|
||||||
}
|
|
||||||
if (retry <= 1) throw cause
|
|
||||||
done = false
|
done = false
|
||||||
}
|
}
|
||||||
} while (!done && 0 < --retry)
|
} while (!done && 0 < --retry)
|
||||||
|
} catch (cause: CancellationException) {
|
||||||
|
throw cause
|
||||||
} catch (cause: Throwable) {
|
} catch (cause: Throwable) {
|
||||||
JChatGPT.logger.warning(cause)
|
JChatGPT.logger.warning(cause)
|
||||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package top.jie65535.mirai.profile
|
|||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
import top.jie65535.mirai.config.PluginConfig
|
import top.jie65535.mirai.config.PluginConfig
|
||||||
import top.jie65535.mirai.data.ChatHistoryStore
|
import top.jie65535.mirai.data.ChatHistoryStore
|
||||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||||
|
import top.jie65535.mirai.util.RetryBackoff
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -465,6 +467,7 @@ object UserProfileAnalysisService {
|
|||||||
onRetryFailure: (String, Throwable) -> Unit,
|
onRetryFailure: (String, Throwable) -> Unit,
|
||||||
): Pair<ConversationProfileModelResult, List<ProfileReduction>> {
|
): Pair<ConversationProfileModelResult, List<ProfileReduction>> {
|
||||||
val attempts = retryMax.coerceIn(0, 3) + 1
|
val attempts = retryMax.coerceIn(0, 3) + 1
|
||||||
|
val retryBackoff = RetryBackoff.fromConfig()
|
||||||
var lastFailure: Throwable? = null
|
var lastFailure: Throwable? = null
|
||||||
repeat(attempts) { attempt ->
|
repeat(attempts) { attempt ->
|
||||||
try {
|
try {
|
||||||
@@ -482,10 +485,14 @@ object UserProfileAnalysisService {
|
|||||||
} catch (cause: Exception) {
|
} catch (cause: Exception) {
|
||||||
if (cause is CancellationException) throw cause
|
if (cause is CancellationException) throw cause
|
||||||
lastFailure = cause
|
lastFailure = cause
|
||||||
onRetryFailure(
|
handleRetryFailure(
|
||||||
"群 ${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " +
|
attempt = attempt,
|
||||||
|
attempts = attempts,
|
||||||
|
message = "群 ${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " +
|
||||||
"第 ${attempt + 1}/$attempts 次分析失败",
|
"第 ${attempt + 1}/$attempts 次分析失败",
|
||||||
cause,
|
cause = cause,
|
||||||
|
retryBackoff = retryBackoff,
|
||||||
|
logFailure = onRetryFailure,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +509,7 @@ object UserProfileAnalysisService {
|
|||||||
advanceBackfillCursor: Boolean = true,
|
advanceBackfillCursor: Boolean = true,
|
||||||
): Pair<ProfileModelResult, ProfileReduction> {
|
): Pair<ProfileModelResult, ProfileReduction> {
|
||||||
val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1
|
val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1
|
||||||
|
val retryBackoff = RetryBackoff.fromConfig()
|
||||||
var lastFailure: Throwable? = null
|
var lastFailure: Throwable? = null
|
||||||
repeat(attempts) { attempt ->
|
repeat(attempts) { attempt ->
|
||||||
try {
|
try {
|
||||||
@@ -523,10 +531,14 @@ object UserProfileAnalysisService {
|
|||||||
} catch (cause: Exception) {
|
} catch (cause: Exception) {
|
||||||
if (cause is CancellationException) throw cause
|
if (cause is CancellationException) throw cause
|
||||||
lastFailure = cause
|
lastFailure = cause
|
||||||
JChatGPT.logger.warning(
|
handleRetryFailure(
|
||||||
"用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime}) " +
|
attempt = attempt,
|
||||||
|
attempts = attempts,
|
||||||
|
message = "用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime}) " +
|
||||||
"第 ${attempt + 1}/$attempts 次分析失败",
|
"第 ${attempt + 1}/$attempts 次分析失败",
|
||||||
cause,
|
cause = cause,
|
||||||
|
retryBackoff = retryBackoff,
|
||||||
|
logFailure = JChatGPT.logger::warning,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,6 +554,7 @@ object UserProfileAnalysisService {
|
|||||||
supportStats: Map<String, ProfileItemSupportStats>,
|
supportStats: Map<String, ProfileItemSupportStats>,
|
||||||
): Pair<ProfileCompactionModelResult, ProfileCompactionPlan> {
|
): Pair<ProfileCompactionModelResult, ProfileCompactionPlan> {
|
||||||
val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1
|
val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1
|
||||||
|
val retryBackoff = RetryBackoff.fromConfig()
|
||||||
var lastFailure: Throwable? = null
|
var lastFailure: Throwable? = null
|
||||||
repeat(attempts) { attempt ->
|
repeat(attempts) { attempt ->
|
||||||
try {
|
try {
|
||||||
@@ -558,9 +571,13 @@ object UserProfileAnalysisService {
|
|||||||
} catch (cause: Exception) {
|
} catch (cause: Exception) {
|
||||||
if (cause is CancellationException) throw cause
|
if (cause is CancellationException) throw cause
|
||||||
lastFailure = cause
|
lastFailure = cause
|
||||||
JChatGPT.logger.warning(
|
handleRetryFailure(
|
||||||
"用户 ${profile.userId} 画像压缩第 ${attempt + 1}/$attempts 次失败",
|
attempt = attempt,
|
||||||
cause,
|
attempts = attempts,
|
||||||
|
message = "用户 ${profile.userId} 画像压缩第 ${attempt + 1}/$attempts 次失败",
|
||||||
|
cause = cause,
|
||||||
|
retryBackoff = retryBackoff,
|
||||||
|
logFailure = JChatGPT.logger::warning,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +587,23 @@ object UserProfileAnalysisService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun handleRetryFailure(
|
||||||
|
attempt: Int,
|
||||||
|
attempts: Int,
|
||||||
|
message: String,
|
||||||
|
cause: Throwable,
|
||||||
|
retryBackoff: RetryBackoff,
|
||||||
|
logFailure: (String, Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
if (attempt + 1 >= attempts) {
|
||||||
|
logFailure("$message,已无剩余尝试", cause)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val retryDelayMillis = retryBackoff.delayMillis(attempt + 1)
|
||||||
|
logFailure("$message,将在 ${retryDelayMillis}ms 后重试", cause)
|
||||||
|
if (retryDelayMillis > 0) delay(retryDelayMillis)
|
||||||
|
}
|
||||||
|
|
||||||
private fun compactionBatch(profile: UserProfileSnapshot, rawResponse: String): ProfileHistoryBatch {
|
private fun compactionBatch(profile: UserProfileSnapshot, rawResponse: String): ProfileHistoryBatch {
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
.digest("${profile.userId}|${profile.version}|$rawResponse".toByteArray(Charsets.UTF_8))
|
.digest("${profile.userId}|${profile.version}|$rawResponse".toByteArray(Charsets.UTF_8))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import net.mamoe.mirai.event.events.MessageEvent
|
|||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
import top.jie65535.mirai.config.PluginConfig
|
import top.jie65535.mirai.config.PluginConfig
|
||||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||||
|
import top.jie65535.mirai.util.RetryBackoff
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
class VisualAgent : BaseAgent(
|
class VisualAgent : BaseAgent(
|
||||||
@@ -118,6 +119,7 @@ class VisualAgent : BaseAgent(
|
|||||||
val messageContent = buildMessageContent(imageGroups, prompt)
|
val messageContent = buildMessageContent(imageGroups, prompt)
|
||||||
|
|
||||||
val maxAttempts = PluginConfig.visualRetryMax.coerceIn(1, 3)
|
val maxAttempts = PluginConfig.visualRetryMax.coerceIn(1, 3)
|
||||||
|
val retryBackoff = RetryBackoff.fromConfig()
|
||||||
var lastError: Throwable? = null
|
var lastError: Throwable? = null
|
||||||
repeat(maxAttempts) { attempt ->
|
repeat(maxAttempts) { attempt ->
|
||||||
try {
|
try {
|
||||||
@@ -150,11 +152,12 @@ class VisualAgent : BaseAgent(
|
|||||||
if (!isRetryable(e)) throw e
|
if (!isRetryable(e)) throw e
|
||||||
lastError = e
|
lastError = e
|
||||||
if (attempt + 1 < maxAttempts) {
|
if (attempt + 1 < maxAttempts) {
|
||||||
|
val retryDelayMillis = retryBackoff.delayMillis(attempt + 1)
|
||||||
JChatGPT.logger.warning(
|
JChatGPT.logger.warning(
|
||||||
"视觉模型调用失败,将进行第 ${attempt + 2}/$maxAttempts 次尝试",
|
"视觉模型调用失败,将在 ${retryDelayMillis}ms 后进行第 ${attempt + 2}/$maxAttempts 次尝试",
|
||||||
e
|
e
|
||||||
)
|
)
|
||||||
delay(RETRY_BASE_DELAY_MILLIS * (attempt + 1L))
|
if (retryDelayMillis > 0) delay(retryDelayMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +168,6 @@ class VisualAgent : BaseAgent(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val VISUAL_MAX_CONCURRENCY = 2
|
private const val VISUAL_MAX_CONCURRENCY = 2
|
||||||
private const val RETRY_BASE_DELAY_MILLIS = 800L
|
|
||||||
private const val MAX_SOURCE_IMAGES = 16
|
private const val MAX_SOURCE_IMAGES = 16
|
||||||
private const val MAX_MODEL_IMAGES = 32
|
private const val MAX_MODEL_IMAGES = 32
|
||||||
private const val MAX_TOTAL_PAYLOAD_CHARS = 48_000_000
|
private const val MAX_TOTAL_PAYLOAD_CHARS = 48_000_000
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package top.jie65535.mirai.util
|
||||||
|
|
||||||
|
import top.jie65535.mirai.config.PluginConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
internal class RetryBackoff(
|
||||||
|
initialDelayMillis: Long,
|
||||||
|
maxDelayMillis: Long,
|
||||||
|
private val randomFraction: () -> Double = { Random.nextDouble() },
|
||||||
|
) {
|
||||||
|
private val initialDelayMillis = initialDelayMillis.coerceIn(0L, MAX_DELAY_MILLIS)
|
||||||
|
private val maxDelayMillis = maxDelayMillis.coerceIn(0L, MAX_DELAY_MILLIS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the delay before the given retry. Retry numbers start at 1.
|
||||||
|
* A 20% downward jitter prevents concurrent failures from retrying in lockstep.
|
||||||
|
*/
|
||||||
|
fun delayMillis(retryNumber: Int): Long {
|
||||||
|
if (retryNumber <= 0 || initialDelayMillis == 0L || maxDelayMillis == 0L) return 0L
|
||||||
|
|
||||||
|
var nominalDelay = minOf(initialDelayMillis, maxDelayMillis)
|
||||||
|
repeat((retryNumber - 1).coerceAtMost(MAX_EXPONENT)) {
|
||||||
|
nominalDelay = when {
|
||||||
|
nominalDelay >= maxDelayMillis -> maxDelayMillis
|
||||||
|
nominalDelay > maxDelayMillis / 2 -> maxDelayMillis
|
||||||
|
else -> nominalDelay * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jitterWindow = nominalDelay / JITTER_DIVISOR
|
||||||
|
if (jitterWindow == 0L) return nominalDelay
|
||||||
|
val fraction = randomFraction().coerceIn(0.0, 1.0)
|
||||||
|
return nominalDelay - jitterWindow + (jitterWindow * fraction).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val JITTER_DIVISOR = 5L
|
||||||
|
private const val MAX_EXPONENT = 62
|
||||||
|
const val MAX_DELAY_MILLIS = 60_000L
|
||||||
|
|
||||||
|
fun fromConfig(): RetryBackoff = RetryBackoff(
|
||||||
|
initialDelayMillis = PluginConfig.retryBackoffBaseMillis,
|
||||||
|
maxDelayMillis = PluginConfig.retryBackoffMaxMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package top.jie65535.mirai.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class RetryBackoffTest {
|
||||||
|
@Test
|
||||||
|
fun growsExponentiallyAndStopsAtMaximum() {
|
||||||
|
val backoff = RetryBackoff(
|
||||||
|
initialDelayMillis = 1_000,
|
||||||
|
maxDelayMillis = 10_000,
|
||||||
|
randomFraction = { 1.0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1_000, backoff.delayMillis(1))
|
||||||
|
assertEquals(2_000, backoff.delayMillis(2))
|
||||||
|
assertEquals(4_000, backoff.delayMillis(3))
|
||||||
|
assertEquals(8_000, backoff.delayMillis(4))
|
||||||
|
assertEquals(10_000, backoff.delayMillis(5))
|
||||||
|
assertEquals(10_000, backoff.delayMillis(Int.MAX_VALUE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appliesBoundedDownwardJitter() {
|
||||||
|
val minimum = RetryBackoff(1_000, 10_000) { 0.0 }
|
||||||
|
val maximum = RetryBackoff(1_000, 10_000) { 1.0 }
|
||||||
|
|
||||||
|
assertEquals(800, minimum.delayMillis(1))
|
||||||
|
assertEquals(1_000, maximum.delayMillis(1))
|
||||||
|
assertEquals(3_200, minimum.delayMillis(3))
|
||||||
|
assertEquals(4_000, maximum.delayMillis(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun zeroOrInvalidRetryDisablesDelay() {
|
||||||
|
assertEquals(0, RetryBackoff(1_000, 10_000).delayMillis(0))
|
||||||
|
assertEquals(0, RetryBackoff(0, 10_000).delayMillis(1))
|
||||||
|
assertEquals(0, RetryBackoff(1_000, 0).delayMillis(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clampsMisconfiguredValuesToSafetyLimit() {
|
||||||
|
val backoff = RetryBackoff(Long.MAX_VALUE, Long.MAX_VALUE) { 1.0 }
|
||||||
|
|
||||||
|
assertEquals(RetryBackoff.MAX_DELAY_MILLIS, backoff.delayMillis(1))
|
||||||
|
assertEquals(RetryBackoff.MAX_DELAY_MILLIS, backoff.delayMillis(Int.MAX_VALUE))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user