feat: watchdog на первый байт стрима (first_byte_timeout) — фейловер + backoff при молчании апстрима

This commit is contained in:
2026-09-15 00:37:51 +03:00
parent 293964d8f1
commit 249b83eb51
7 changed files with 465 additions and 11 deletions
+72 -4
View File
@@ -322,6 +322,69 @@ upstreams:
При старте выводится, что настроено: При старте выводится, что настроено:
`[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`. `[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`.
### Таймаут первого байта стрима (`first_byte_timeout`)
Для `stream=true` запросов прокси следит, чтобы апстрим начал отдавать
**что-нибудь** (первый байт тела) в течение заданного окна. Если апстрим
молчит дольше — это трактуется как его сбой: слот конкурентности
освобождается, ошибка идёт в backoff-откат, и роутер **фейловерит** на
следующего свободного апстрима модели (при его наличии). Это закрывает
кейс, когда апстрим принял запрос, но «завис» и никогда не начинает
стримить.
| Поле | Тип / дефолт | Значение |
|---|---|---|
| `providers[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно ожидания первого байта для всех моделей провайдера |
| `upstreams[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно для конкретной модели (приоритет над провайдерским) |
**Дефолт** (если не задан нигде): `PT30S`.
**Приоритет — модели.** Если `upstreams[].first_byte_timeout` задан, он
перекрывает провайдерский; если нет — берётся провайдерский; если нет и там —
дефолт `PT30S`.
**Отключение:** `PT0S` на провайдере или апстриме отключает watchdog
для этого апстрима — тогда первый байт ждём без лимита (в рамках общего
`upstream: request_timeout`, см. ниже).
**Применяется только к `stream=true`.** Для `stream=false` (не-стриминговый
ответ) действует только общий лимит `UPSTREAM_REQUEST_TIMEOUT_MS` (`5m`) —
строки SSE там склеиваются в один ответ, и таймаут первого байта к нему
неприменим.
Поведение:
- watchdog висит **на первом чтении** из тела стрим-ответа; как только
пришёл хоть какой-то байт — лимит отключается, дальше стрим читается
свободно (вплоть до общего request_timeout);
- `data: [DONE]` приходит в теле — это уже «первый байт», таймаут не
сработает;
- при срабатывании — лог:
`[llm-proxy] chat model=<m> upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`
(+ `(backoff: отдых …)` если задан backoff), метрика
`llm_proxy_requests_total{result="timeout"}`;
- закрытие апстримом пустого стрима (EOF без единого байта) — **не** таймаут:
это валидный короткий ответ, он проксируется как обычно.
Значение — ISO-8601-длительность, формат как у `backoff` (`PT30S`,
`PT1M30S`, `P1D`, …).
```yaml
providers:
- id: routerai
url: "https://routerai.ru/api/v1"
first_byte_timeout: PT30S # окно для всех моделей провайдера
upstreams:
- id: routerai-gpt4o
provider: routerai
model: gpt-4o
first_byte_timeout: PT0S # у модели watchdog отключён
```
При старте выводится, что настроено:
`[llm-proxy] first_byte_timeout: upstreams=routerai-gpt4o=PT0S providers=routerai=PT30S default=PT30S`.
### Prometheus-метрики (`/metrics`) ### Prometheus-метрики (`/metrics`)
Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics` Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics`
@@ -331,7 +394,7 @@ upstreams:
| Метрика | Тип | Смысл | | Метрика | Тип | Смысл |
|---|---|---| |---|---|---|
| `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `cancelled` — клиент отвалился посреди стрима | | `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `timeout` — апстрим не прислал первый байт стрима за `first_byte_timeout`, `cancelled` — клиент отвалился посреди стрима |
| `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) | | `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) |
| `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал | | `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал |
| `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) | | `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) |
@@ -343,7 +406,7 @@ upstreams:
- оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`; - оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`;
- оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`; - оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`;
- «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт; - «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт;
- доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`. - доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err|timeout"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`.
### Пример сборки тела (многослойный `patch`) ### Пример сборки тела (многослойный `patch`)
@@ -429,10 +492,10 @@ data class ProviderConf(
val think_tags: String? = null, val think_tags: String? = null,
val reasoning_field: String? = null, val reasoning_field: String? = null,
val reasoning_empty_ok: Boolean = false, val reasoning_empty_ok: Boolean = false,
val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601) val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601)
val first_byte_timeout: Duration? = null, // окно первого байта стрима (ISO-8601)
) )
@Serializable
data class UpstreamConf( data class UpstreamConf(
val id: String, // наш внутренний id апстрима val id: String, // наш внутренний id апстрима
val provider: String, // ссылка на ProviderConf.id val provider: String, // ссылка на ProviderConf.id
@@ -441,6 +504,7 @@ data class UpstreamConf(
val patch: JsonObject? = null, // к запросам этой апстрим-модели val patch: JsonObject? = null, // к запросам этой апстрим-модели
val think_tags: String? = null, // переопределение think-режима модели val think_tags: String? = null, // переопределение think-режима модели
val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским) val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским)
val first_byte_timeout: Duration? = null, // окно первого байта стрима (приоритет над провайдерским)
) )
@Serializable @Serializable
@@ -605,6 +669,10 @@ fun release(u: UpstreamConf) = active.getValue(u.id).decrementAndGet()
(`(backoff: отдых PT…)`); при пропуске апстрима в откате — (`(backoff: отдых PT…)`); при пропуске апстрима в откате —
`upstream=<id> в backoff-откате (~Ns) — пропускаю`; при старте — список `upstream=<id> в backoff-откате (~Ns) — пропускаю`; при старте — список
настроенных backoff (`backoff: upstreams=… providers=…`). настроенных backoff (`backoff: upstreams=… providers=…`).
- **Таймаут первого байта стрима**: при срабатывании watchdog'а —
`upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`;
при старте — список настроенных `first_byte_timeout`
(`first_byte_timeout: upstreams=… providers=… default=PT30S`).
- **Все в откате** — `503 all upstreams cooling, retry in Ns` для - **Все в откате** — `503 all upstreams cooling, retry in Ns` для
`model=<витрина>` с заголовком `Retry-After: N`. `model=<витрина>` с заголовком `Retry-After: N`.
+6
View File
@@ -30,6 +30,12 @@ non-stream.
1s, далее 2s, 4s, … до потолка; успех сбрасывает. Все апстримы модели в откате 1s, далее 2s, 4s, … до потолка; успех сбрасывает. Все апстримы модели в откате
— `503` с `Retry-After`. — `503` с `Retry-After`.
Для `stream=true` запросов действует watchdog на первый байт ответа:
`first_byte_timeout` (ISO-8601, дефолт `PT30S`, `PT0S` отключает) — если
апстрим не прислал ни одного байта за окно, слот освобождается, ошибка идёт
в backoff и роутер фейловерит на следующего свободного апстрима. Приоритет:
`upstreams[].first_byte_timeout` → `providers[].first_byte_timeout` → `PT30S`.
Прокси отдаёт Prometheus-метрики по `GET /metrics` (запросы по Прокси отдаёт Prometheus-метрики по `GET /metrics` (запросы по
`model/upstream/provider/result`, занятые слоты, счётчик и окно backoff-отката) — `model/upstream/provider/result`, занятые слоты, счётчик и окно backoff-отката) —
подключите скрейп в Prometheus и стройте дашборды/алерты в Grafana. Список подключите скрейп в Prometheus и стройте дашборды/алерты в Grafana. Список
+126 -6
View File
@@ -28,6 +28,11 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.writeFully import io.ktor.utils.io.writeFully
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -103,6 +108,13 @@ fun main() {
" providers=" + " providers=" +
config.providers.filter { it.backoff != null }.map { p -> "${p.id}=${p.backoff?.toIsoString()}" }.joinToString(", ") config.providers.filter { it.backoff != null }.map { p -> "${p.id}=${p.backoff?.toIsoString()}" }.joinToString(", ")
} }
log.info {
"[llm-proxy] first_byte_timeout: upstreams=" +
config.upstreams.filter { it.first_byte_timeout != null }.map { up -> "${up.id}=${up.first_byte_timeout?.toIsoString()}" }.joinToString(", ") +
" providers=" +
config.providers.filter { it.first_byte_timeout != null }.map { p -> "${p.id}=${p.first_byte_timeout?.toIsoString()}" }.joinToString(", ") +
" default=${DEFAULT_FIRST_BYTE_TIMEOUT.toIsoString()}"
}
log.info { log.info {
"[llm-proxy] server: bind host=${config.server.host} port=${config.server.port}" "[llm-proxy] server: bind host=${config.server.host} port=${config.server.port}"
} }
@@ -279,13 +291,14 @@ private suspend fun handleChat(
val ct = resp.headers["Content-Type"] ?: "text/event-stream" val ct = resp.headers["Content-Type"] ?: "text/event-stream"
val status = HttpStatusCode.fromValue(upstreamStatus) val status = HttpStatusCode.fromValue(upstreamStatus)
val thinkMode = effectiveThinkTags(up, provider) val thinkMode = effectiveThinkTags(up, provider)
val firstByteTimeout = effectiveFirstByteTimeout(up, provider)
call.respondBytesWriter(ContentType.parse(ct), status) { call.respondBytesWriter(ContentType.parse(ct), status) {
val ch = resp.body<ByteReadChannel>() val ch = resp.body<ByteReadChannel>()
if (thinkMode == "off") { if (thinkMode == "off") {
// Флажок не выставлен — сырой байтовый passthrough + выравнивание конца потока. // Флажок не выставлен — сырой байтовый passthrough + выравнивание конца потока.
streamRawWithDoneContract(ch) streamRawWithDoneContract(ch, firstByteTimeout)
} else { } else {
streamSseWithThinkTags(ch, thinkMode) streamSseWithThinkTags(ch, thinkMode, firstByteTimeout)
} }
} }
metrics.record(modelName, up, "ok") metrics.record(modelName, up, "ok")
@@ -322,6 +335,15 @@ private suspend fun handleChat(
if (failover) continue if (failover) continue
if (responded) return if (responded) return
} catch (e: FirstByteTimeoutException) {
val rest = backoff.recordFailure(up)
metrics.record(modelName, up, "timeout")
log.warn {
"[llm-proxy] chat model=$modelName upstream=${up.id} TIMEOUT: первый байт не пришёл за ${e.timeoutMs}ms → фейловер" +
(rest?.let { " (backoff: отдых ${it.toIsoString()})" } ?: "")
}
failed.add(up.id)
continue
} catch (e: CancellationException) { } catch (e: CancellationException) {
metrics.record(modelName, up, "cancelled") metrics.record(modelName, up, "cancelled")
log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" } log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" }
@@ -493,6 +515,27 @@ internal fun effectiveThinkTags(up: UpstreamConf, provider: ProviderConf?): Stri
} }
} }
/**
* Эффективный first-byte-timeout для стрим-ответа: значение у апстрима, если
* задано; иначе у провайдера; иначе [DEFAULT_FIRST_BYTE_TIMEOUT]. Возвращает
* null, если явно отключено (`Duration.ZERO` — `PT0S`): тогда стрим
* ждёт первого байта без лимита (в пределах общего
* [UPSTREAM_REQUEST_TIMEOUT_MS]).
*/
internal fun effectiveFirstByteTimeout(up: UpstreamConf, provider: ProviderConf?): Duration? {
val raw = up.first_byte_timeout ?: provider?.first_byte_timeout ?: DEFAULT_FIRST_BYTE_TIMEOUT
return if (raw <= Duration.ZERO) null else raw
}
/**
* Сигнализирует, что апстрим не прислал ни одного байта тела стрим-ответа за
* [timeoutMs] мс. Обрабатывается как обычный сбой (фейловер, backoff,
* метрика `timeout`), но отделён от [CancellationException] (отмена клиентом)
* и от generic `Exception` (сетевые ошибки).
*/
class FirstByteTimeoutException(val timeoutMs: Long) :
RuntimeException("upstream did not send first byte within ${timeoutMs}ms")
/** Освободить слот апстрима (в finally по завершении проксирования). */ /** Освободить слот апстрима (в finally по завершении проксирования). */
internal fun release(up: UpstreamConf, active: Map<String, UpstreamCounter>) { internal fun release(up: UpstreamConf, active: Map<String, UpstreamCounter>) {
active.getValue(up.id).release() active.getValue(up.id).release()
@@ -827,12 +870,26 @@ internal class StreamEndDetector(private val windowSize: Int = STREAM_SCAN_WINDO
* Сырой байтовый passthrough стрима (`think_tags: off`) с выравниванием контракта * Сырой байтовый passthrough стрима (`think_tags: off`) с выравниванием контракта
* конца потока: байты уходят клиенту как есть, а если апстрим закрыл поток, не * конца потока: байты уходят клиенту как есть, а если апстрим закрыл поток, не
* прислав `data: [DONE]`, но `finish_reason` в потоке был — дописываем маркер. * прислав `data: [DONE]`, но `finish_reason` в потоке был — дописываем маркер.
* Если задан [firstByteTimeout] (non-null), первый байт тела должен прийти
* за это время, иначе бросаем [FirstByteTimeoutException]; после первого
* байта watchdog выключается (ждём ответа вплоть до общего
* [UPSTREAM_REQUEST_TIMEOUT_MS]).
*/ */
internal suspend fun ByteWriteChannel.streamRawWithDoneContract(source: ByteReadChannel) { internal suspend fun ByteWriteChannel.streamRawWithDoneContract(
source: ByteReadChannel,
firstByteTimeout: Duration? = null,
) {
val detector = StreamEndDetector() val detector = StreamEndDetector()
val buf = ByteArray(8192) val buf = ByteArray(8192)
val timeoutMs = firstByteTimeout?.inWholeMilliseconds ?: 0L
var isFirstRead = timeoutMs > 0L
while (true) { while (true) {
val n = source.readAvailable(buf) val n = if (isFirstRead) {
isFirstRead = false
readAvailableWithTimeout(source, buf, timeoutMs)
} else {
source.readAvailable(buf)
}
if (n == -1) break if (n == -1) break
if (n > 0) { if (n > 0) {
writeFully(buf, 0, n) writeFully(buf, 0, n)
@@ -853,14 +910,30 @@ internal suspend fun ByteWriteChannel.streamRawWithDoneContract(source: ByteRead
* записывается как `data: <json>\n\n` (событие-граница SSE), а при ошибке парса — * записывается как `data: <json>\n\n` (событие-граница SSE), а при ошибке парса —
* исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в * исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в
* буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком. * буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком.
* Если задан [firstByteTimeout] (non-null), первая строка должна прийти за это
* время, иначе [FirstByteTimeoutException] (watchdog только на первой строке).
*/ */
internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChannel, thinkMode: String) { internal suspend fun ByteWriteChannel.streamSseWithThinkTags(
source: ByteReadChannel,
thinkMode: String,
firstByteTimeout: Duration? = null,
) {
val splitters = mutableMapOf<Int, ThinkTagSplitter>() val splitters = mutableMapOf<Int, ThinkTagSplitter>()
val addReasoning = thinkMode == "split" val addReasoning = thinkMode == "split"
var sawDone = false var sawDone = false
var sawFinishReason = false var sawFinishReason = false
val timeoutMs = firstByteTimeout?.inWholeMilliseconds ?: 0L
var isFirstRead = timeoutMs > 0L
suspend fun readNext(): String? = if (isFirstRead) {
isFirstRead = false
readLineWithTimeout(source, timeoutMs)
} else {
source.readLine(LineEnding.Lenient)
}
while (true) { while (true) {
val line = source.readLine(LineEnding.Lenient) ?: break val line = readNext() ?: break
when { when {
line.startsWith("data:") -> { line.startsWith("data:") -> {
val payload = line.removePrefix("data:").trim() val payload = line.removePrefix("data:").trim()
@@ -913,6 +986,49 @@ internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadCha
} }
} }
/**
* Прочитать из [source] в [buf] с жёстким лимитом [timeoutMs]: если за это
* время ни одного байта не пришло, бросить [FirstByteTimeoutException].
* `select` отменяет «проигравшую» ветку; при таймауте явно отменяем и
* дожидаемся чтение (`cancelAndJoin`), чтобы соединение с апстримом
* корректно закрылось. Сентинел [Int.MIN_VALUE] невозможен как результат
* [ByteReadChannel.readAvailable] (это `-1` для EOF или `>= 0` для байт).
*/
internal suspend fun readAvailableWithTimeout(source: ByteReadChannel, buf: ByteArray, timeoutMs: Long): Int =
coroutineScope {
val readJob = async { source.readAvailable(buf) }
val result = select<Int> {
readJob.onAwait { it }
onTimeout(timeoutMs) { Int.MIN_VALUE }
}
if (result == Int.MIN_VALUE) {
readJob.cancelAndJoin()
throw FirstByteTimeoutException(timeoutMs)
}
result
}
/**
* Прочитать строку из [source] с лимитом [timeoutMs]: если за это время ни
* одной строки/байта не пришло, бросить [FirstByteTimeoutException].
* [ByteReadChannel.readLine] возвращает `null` и при EOF, и (через `onTimeout`)
* при таймауте; различаем по состоянию [readJob]: если ветка чтения не
* завершилась к моменту возврата из `select` — таймаут (select отменил её).
*/
internal suspend fun readLineWithTimeout(source: ByteReadChannel, timeoutMs: Long): String? =
coroutineScope {
val readJob = async { source.readLine(LineEnding.Lenient) }
val result = select<String?> {
readJob.onAwait { it }
onTimeout(timeoutMs) { null }
}
if (result == null && !readJob.isCompleted) {
readJob.cancelAndJoin()
throw FirstByteTimeoutException(timeoutMs)
}
result
}
/** /**
* Пересобрать SSE-чанк: по каждому choice (ключ `index`, дефолт 0) взять * Пересобрать SSE-чанк: по каждому choice (ключ `index`, дефолт 0) взять
* `delta.content` (только если это JSON-строка; массив частей не трогаем) и * `delta.content` (только если это JSON-строка; массив частей не трогаем) и
@@ -966,6 +1082,7 @@ data class ProviderConf(
val reasoning_field: String? = null, val reasoning_field: String? = null,
val reasoning_empty_ok: Boolean = false, val reasoning_empty_ok: Boolean = false,
val backoff: Duration? = null, val backoff: Duration? = null,
val first_byte_timeout: Duration? = null,
) )
data class UpstreamConf( data class UpstreamConf(
@@ -976,6 +1093,7 @@ data class UpstreamConf(
val patch: JsonObject? = null, val patch: JsonObject? = null,
val think_tags: String? = null, val think_tags: String? = null,
val backoff: Duration? = null, val backoff: Duration? = null,
val first_byte_timeout: Duration? = null,
) )
data class ModelConf( data class ModelConf(
@@ -1034,6 +1152,7 @@ internal fun parseConfig(root: YamlElement): Config {
reasoning_field = m.strOrNull("reasoning_field"), reasoning_field = m.strOrNull("reasoning_field"),
reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false, reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false,
backoff = m.durationOrNull("backoff"), backoff = m.durationOrNull("backoff"),
first_byte_timeout = m.durationOrNull("first_byte_timeout"),
) )
} }
@@ -1047,6 +1166,7 @@ internal fun parseConfig(root: YamlElement): Config {
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject }, patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
think_tags = m.strOrNull("think_tags"), think_tags = m.strOrNull("think_tags"),
backoff = m.durationOrNull("backoff"), backoff = m.durationOrNull("backoff"),
first_byte_timeout = m.durationOrNull("first_byte_timeout"),
) )
} }
@@ -9,7 +9,7 @@ import kotlinx.coroutines.sync.withLock
* *
* Метрики (всё в памяти, сброс при рестарте): * Метрики (всё в памяти, сброс при рестарте):
* - `llm_proxy_requests_total{model,upstream,provider,result}` — счётчик * - `llm_proxy_requests_total{model,upstream,provider,result}` — счётчик
* chat-запросов; `result`: `ok | 4xx | 429 | 402 | 5xx | net_err | cancelled`; * chat-запросов; `result`: `ok | 4xx | 429 | 402 | 5xx | net_err | timeout | cancelled`;
* - `llm_proxy_upstream_inflight{upstream,provider}` — сейчас в работе (слоты); * - `llm_proxy_upstream_inflight{upstream,provider}` — сейчас в работе (слоты);
* - `llm_proxy_upstream_fail_streak{upstream,provider}` — текущая серия сбоев * - `llm_proxy_upstream_fail_streak{upstream,provider}` — текущая серия сбоев
* (счётчик backoff); * (счётчик backoff);
@@ -1,4 +1,17 @@
package pw.binom.llmproxy package pw.binom.llmproxy
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** Предел времени на весь запрос к апстриму, мс: от отправки до приёма всего ответа (включая стриминг). */ /** Предел времени на весь запрос к апстриму, мс: от отправки до приёма всего ответа (включая стриминг). */
const val UPSTREAM_REQUEST_TIMEOUT_MS: Long = 5 * 60_000L const val UPSTREAM_REQUEST_TIMEOUT_MS: Long = 5 * 60_000L
/**
* Default «watchdog» на первый байт тела стрим-ответа: если апстрим не прислал
* ни одного байта за это время, прокси считает это сбоем апстрима и
* фейловерит (освобождает слот конкурентности, считает ошибку в backoff и
* идёт к следующему апстриму модели). Применяется ТОЛЬКО к stream=true
* запросам; для non-stream работает общий [UPSTREAM_REQUEST_TIMEOUT_MS].
* Отключается явным `first_byte_timeout: PT0S` на апстриме или провайдере.
*/
val DEFAULT_FIRST_BYTE_TIMEOUT: Duration = 30.seconds
@@ -4,6 +4,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import io.ktor.http.headersOf import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -586,4 +587,32 @@ class ConfigLogicTest {
assertEquals(null, cfg.providers[0].reasoning_field) assertEquals(null, cfg.providers[0].reasoning_field)
assertEquals(false, cfg.providers[0].reasoning_empty_ok) assertEquals(false, cfg.providers[0].reasoning_empty_ok)
} }
@Test
fun parseConfigReadsFirstByteTimeoutOnProviderAndUpstream() {
val yaml = """
providers:
- id: p1
url: "https://x.ru/api/v1"
first_byte_timeout: PT45S
- id: p2
url: "https://y.ru/api/v1"
upstreams:
- id: u1
provider: p1
model: real-1
first_byte_timeout: PT0S
- id: u2
provider: p2
model: real-2
models:
- name: m1
upstreams: [u1, u2]
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals(45.seconds, cfg.providers[0].first_byte_timeout)
assertEquals(null, cfg.providers[1].first_byte_timeout)
assertEquals(0.seconds, cfg.upstreams[0].first_byte_timeout)
assertEquals(null, cfg.upstreams[1].first_byte_timeout)
}
} }
@@ -0,0 +1,218 @@
package pw.binom.llmproxy
import io.ktor.utils.io.ByteChannel
import io.ktor.utils.io.close
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.writeFully
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
/**
* Тесты watchdog'а на первый байт тела стрим-ответа.
*
* Сценарии:
* - Апстрим не прислал ничего за N мс → [FirstByteTimeoutException] на первой
* попытке чтения (после первой попытки watchdog отключается).
* - Апстрим закрыл канал до таймаута, не прислав данных → возврат EOF (-1 /
* null), НЕ [FirstByteTimeoutException].
* - Апстрим прислал первый байт вовремя → читаем его, watchdog отключён.
* - Когда таймаут не задан (`null`) → читаем без лимита (как и было).
*/
class FirstByteTimeoutTest {
/** Окно для watchdog'а в тестах: виртуальное время `runTest` его мгновенно прокручивает. */
private val watchWindow = 100.milliseconds
@Test
fun readAvailableTimesOutWhenNoBytesAndNotClosed() = runTest {
val src = ByteChannel(autoFlush = true)
val buf = ByteArray(64)
val done = CompletableDeferred<Throwable?>()
launch {
try {
readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
done.complete(AssertionError("expected FirstByteTimeoutException"))
} catch (e: FirstByteTimeoutException) {
done.complete(null)
assertEquals(watchWindow.inWholeMilliseconds, e.timeoutMs)
} catch (e: Throwable) {
done.complete(e)
}
}
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
runCurrent()
val err = done.await()
if (err != null) throw err
}
@Test
fun readAvailableReturnsEofImmediatelyWhenChannelClosedEmpty() = runTest {
// Канал сразу закрыт без данных: -1 (EOF), никакого таймаута.
val src = ByteChannel(autoFlush = true)
src.close(null)
val buf = ByteArray(64)
val n = readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
assertEquals(-1, n, "закрытый пустой канал → EOF, не таймаут")
}
@Test
fun readAvailableReturnsBytesWhenDataArrivesInTime() = runTest {
val src = ByteChannel(autoFlush = true)
val payload = "hello".encodeToByteArray()
src.writeFully(payload)
val buf = ByteArray(64)
val n = readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
assertEquals(payload.size, n)
assertEquals(payload.decodeToString(), buf.decodeToString(0, n))
}
@Test
fun readLineTimesOutWhenNoLinesAndNotClosed() = runTest {
val src = ByteChannel(autoFlush = true)
val done = CompletableDeferred<Throwable?>()
launch {
try {
readLineWithTimeout(src, watchWindow.inWholeMilliseconds)
done.complete(AssertionError("expected FirstByteTimeoutException"))
} catch (e: FirstByteTimeoutException) {
done.complete(null)
} catch (e: Throwable) {
done.complete(e)
}
}
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
runCurrent()
val err = done.await()
if (err != null) throw err
}
@Test
fun readLineReturnsNullWhenChannelClosedEmpty() = runTest {
// Закрытый пустой канал → null (EOF), не таймаут. Это критично: иначе бы
// мы фейловерили легитимные короткие ответы, где апстрим закрыл стрим
// до первой строки.
val src = ByteChannel(autoFlush = true)
src.close(null)
val line = readLineWithTimeout(src, watchWindow.inWholeMilliseconds)
assertEquals(null, line)
}
@Test
fun rawStreamTimesOutWhenUpstreamSilent() = runTest {
// Сырой passthrough: апстрим молчит вечно → на первой попытке чтения
// вылетает FirstByteTimeoutException, в out ничего не пишется.
val src = ByteChannel(autoFlush = true)
val out = ByteChannel(autoFlush = true)
val done = CompletableDeferred<Throwable?>()
launch {
try {
out.streamRawWithDoneContract(src, watchWindow)
done.complete(AssertionError("expected FirstByteTimeoutException"))
} catch (e: FirstByteTimeoutException) {
done.complete(null)
} catch (e: Throwable) {
done.complete(e)
}
}
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
runCurrent()
val err = done.await()
if (err != null) throw err
// Подтверждаем, что в out ничего не утекло.
out.close(null)
val buf = ByteArray(64)
val n = out.readAvailable(buf)
assertEquals(-1, n, "до таймаута клиенту ничего не должно уйти")
}
@Test
fun thinkStreamTimesOutWhenUpstreamSilent() = runTest {
// SSE-вариант: первая строка не приходит → FirstByteTimeoutException,
// в out ничего не пишется.
val src = ByteChannel(autoFlush = true)
val out = ByteChannel(autoFlush = true)
val done = CompletableDeferred<Throwable?>()
launch {
try {
out.streamSseWithThinkTags(src, "split", watchWindow)
done.complete(AssertionError("expected FirstByteTimeoutException"))
} catch (e: FirstByteTimeoutException) {
done.complete(null)
} catch (e: Throwable) {
done.complete(e)
}
}
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
runCurrent()
val err = done.await()
if (err != null) throw err
out.close(null)
val buf = ByteArray(64)
val n = out.readAvailable(buf)
assertEquals(-1, n)
}
@Test
fun thinkStreamFirstLineInTimeThenReadsUnlimited() = runTest {
// Первый байт/строка пришли вовремя → watchdog выключен, остальной
// поток читается обычным порядком. Проверяем, что НЕ бросило
// FirstByteTimeoutException и данные дошли до out.
val src = ByteChannel(autoFlush = true)
val out = ByteChannel(autoFlush = true)
val payload = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n"
src.writeFully(payload.encodeToByteArray())
src.close(null)
out.streamSseWithThinkTags(src, "split", watchWindow)
out.close(null)
val sb = StringBuilder()
val buf = ByteArray(256)
while (true) {
val n = out.readAvailable(buf)
if (n == -1) break
if (n > 0) sb.append(buf.decodeToString(0, n))
}
assertTrue(sb.toString().contains("data:"), "данные дошли до out: $sb")
}
@Test
fun effectiveFirstByteTimeoutPrefersUpstreamThenProviderThenDefault() {
// upstream > provider > default
val provider = ProviderConf("p", "https://x", "", null, null, first_byte_timeout = 20.milliseconds)
val upstream = UpstreamConf("u", "p", "m", null, null, null, null, first_byte_timeout = 5.milliseconds)
val upstreamNoOwn = UpstreamConf("u", "p", "m")
assertEquals(5.milliseconds, effectiveFirstByteTimeout(upstream, provider))
assertEquals(20.milliseconds, effectiveFirstByteTimeout(upstreamNoOwn, provider))
assertEquals(DEFAULT_FIRST_BYTE_TIMEOUT, effectiveFirstByteTimeout(upstreamNoOwn, null))
}
@Test
fun effectiveFirstByteTimeoutZeroDisablesWatchdog() {
// 0s / PT0S → null: passthrough без watchdog'а.
val up = UpstreamConf("u", "p", "m", null, null, null, null, first_byte_timeout = 0.milliseconds)
assertEquals(null, effectiveFirstByteTimeout(up, null))
val providerZero = ProviderConf("p", "https://x", "", null, null, first_byte_timeout = 0.milliseconds)
val upNoOwn = UpstreamConf("u", "p", "m")
assertEquals(null, effectiveFirstByteTimeout(upNoOwn, providerZero))
}
}