feat: экспоненциальный backoff (providers[].backoff / upstreams[].backoff)
Повторяющиеся сбои апстрима (5xx/429/402 и сетевые ошибки) увязываются экспоненциальным откатом: первая ошибка — отдых 1s, далее 2s, 4s, … до потолка из конфига (ISO-8601: P1M, PT15M…); успешный запрос сбрасывает счётчик. Приоритет: upstreams[].backoff (свой счётчик на модель) над providers[].backoff (общий счётчик на все модели провайдера). - Backoff.kt: BackoffGuard (Mutex/@Volatile, kotlinx-datetime) + BackoffRegistry (скоупы провайдер/модель); - pickFreeUpstream пропускает апстримы в откате (лог «~Ns — пропускаю»); - все апстримы модели в откате → 503 «all upstreams cooling, retry in Ns» + заголовок Retry-After; - некорректный ISO-8601 в backoff — warn + игнор поля; - BackoffTest (8 тестов) + обновлённые тесты pickFreeUpstream; - CONFIG.md/README/TESTING.md. Проверено: jvmTest, compileKotlinLinuxX64, linuxX64Test.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package pw.binom.llmproxy
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class BackoffTest {
|
||||
|
||||
private val t0 = Instant.fromEpochSeconds(1_700_000_000)
|
||||
|
||||
@Test
|
||||
fun intervalsDoubleUntilCap() = runTest {
|
||||
val g = BackoffGuard(cap = 30.seconds)
|
||||
val seq = (1..7).map { g.recordFailure(t0) }
|
||||
assertEquals(
|
||||
listOf(1.seconds, 2.seconds, 4.seconds, 8.seconds, 16.seconds, 30.seconds, 30.seconds),
|
||||
seq,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successResetsCounter() = runTest {
|
||||
val g = BackoffGuard(cap = 30.seconds)
|
||||
g.recordFailure(t0)
|
||||
g.recordFailure(t0)
|
||||
g.recordSuccess()
|
||||
assertEquals(1.seconds, g.recordFailure(t0))
|
||||
assertFalse(g.isCoolingAt(t0 + 1.days))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coolingWindow() = runTest {
|
||||
val g = BackoffGuard(cap = 30.seconds)
|
||||
g.recordFailure(t0) // отдых 1s
|
||||
assertTrue(g.isCoolingAt(t0 + 500.milliseconds))
|
||||
assertFalse(g.isCoolingAt(t0 + 1.seconds))
|
||||
assertEquals(500.milliseconds, g.remainingAt(t0 + 500.milliseconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iso8601CapParsing() {
|
||||
assertEquals(30.minutes, Duration.parse("PT30M"))
|
||||
assertEquals(1.days, Duration.parse("P1D"))
|
||||
assertEquals(90.seconds, Duration.parse("PT1M30S"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerScopeSharedBetweenItsModels() = runTest {
|
||||
val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes)
|
||||
val a = UpstreamConf(id = "a", provider = "p", model = "m1")
|
||||
val b = UpstreamConf(id = "b", provider = "p", model = "m2")
|
||||
val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b))
|
||||
|
||||
assertEquals(1.seconds, reg.recordFailure(a))
|
||||
|
||||
// общая скоуп-переменная провайдера: сбой на a охладил и b
|
||||
assertTrue(reg.isCooling(a))
|
||||
assertTrue(reg.isCooling(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upstreamBackoffOverridesProvider() = runTest {
|
||||
val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes)
|
||||
val a = UpstreamConf(id = "a", provider = "p", model = "m1")
|
||||
val b = UpstreamConf(id = "b", provider = "p", model = "m2", backoff = 30.seconds)
|
||||
val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b))
|
||||
|
||||
// своё backoff у b: cap 30s (не 5m провайдера): 1s, 2s, 4s, 8s, 16s, 30s (потолок)
|
||||
val seq = (1..6).map { reg.recordFailure(b) }
|
||||
assertEquals(listOf(1.seconds, 2.seconds, 4.seconds, 8.seconds, 16.seconds, 30.seconds), seq)
|
||||
// скоупы независимы: сбои b не охлаждают a (провайдерский скоуп)
|
||||
assertFalse(reg.isCooling(a))
|
||||
assertTrue(reg.isCooling(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerFailureDoesNotCoolUpstreamWithOwnBackoff() = runTest {
|
||||
val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes)
|
||||
val a = UpstreamConf(id = "a", provider = "p", model = "m1")
|
||||
val b = UpstreamConf(id = "b", provider = "p", model = "m2", backoff = 30.minutes)
|
||||
val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b))
|
||||
|
||||
// провайдерский скоуп заведён сбоем на a:
|
||||
reg.recordFailure(a)
|
||||
assertTrue(reg.isCooling(a))
|
||||
// b живёт своим (provider-scope на него не действует):
|
||||
assertFalse(reg.isCooling(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noBackoffConfiguredMeansNoCooling() = runTest {
|
||||
val p = ProviderConf(id = "p", url = "https://p")
|
||||
val a = UpstreamConf(id = "a", provider = "p", model = "m1")
|
||||
val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a))
|
||||
|
||||
assertNull(reg.recordFailure(a))
|
||||
assertFalse(reg.isCooling(a))
|
||||
assertEquals(0L, reg.remainingSeconds(a))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user