f70a9fdc9c
Build LLM Proxy / Build and push (release) Successful in 40s
- GET /metrics (Prometheus text 0.0.4): llm_proxy_requests_total{model,upstream,provider,result}
(ok/4xx/429/402/5xx/net_err/cancelled), llm_proxy_upstream_inflight,
llm_proxy_upstream_fail_streak, llm_proxy_upstream_cooling_seconds
- учёт исходов в handleChat (каждая фейловер-попытка — отдельно)
- parseIsoDuration: PnM≈n×30d, PnY≈n×365d (kotlin.time Duration.parse не принимает Y/M)
- remainingSeconds округляет остаток вверх (открытое окно ≥1s)
- доки (CONFIG.md/README/TESTING) + тесты (MetricsTest, BackoffTest)
132 lines
5.1 KiB
Kotlin
132 lines
5.1 KiB
Kotlin
package pw.binom.llmproxy
|
||
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlinx.datetime.Instant
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertFails
|
||
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.hours
|
||
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 configDurationParsingWithYearsAndMonths() {
|
||
// kotlin.time не принимает Y/M — конфиг-парсер расширяет: 1Y≈365d, 1M≈30d
|
||
assertEquals(30.days, parseIsoDuration("P1M"))
|
||
assertEquals(365.days, parseIsoDuration("P1Y"))
|
||
assertEquals(455.days, parseIsoDuration("P1Y3M"))
|
||
assertEquals(7.days, parseIsoDuration("P1W"))
|
||
assertEquals(2.days + 3.hours + 15.minutes, parseIsoDuration("P2DT3H15M"))
|
||
// базовый ISO-8601 без Y/M — как kotlin.time
|
||
assertEquals(30.minutes, parseIsoDuration("PT30M"))
|
||
}
|
||
|
||
@Test
|
||
fun configDurationParsingRejectsGarbage() {
|
||
assertFails { parseIsoDuration("PT30") }
|
||
assertFails { parseIsoDuration("1d") }
|
||
assertFails { parseIsoDuration("P") }
|
||
assertFails { parseIsoDuration("") }
|
||
}
|
||
|
||
@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))
|
||
}
|
||
}
|