137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
package llm
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"testing"
|
||
)
|
||
|
||
// fakeLLM — дублёр интерфейса (стиль донора): фиксированный ответ или ошибка.
|
||
type fakeLLM struct {
|
||
resp *LLMResponse
|
||
err error
|
||
calls int
|
||
}
|
||
|
||
func (f *fakeLLM) Complete(_ context.Context, _ LLMRequest) (*LLMResponse, error) {
|
||
f.calls++
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
return f.resp, nil
|
||
}
|
||
|
||
func quietLogger() *slog.Logger { return slog.New(slog.DiscardHandler) }
|
||
|
||
// newTestFailover собирает декоратор без пробера (healthy выставляется руками
|
||
// через noteProbe) — детерминированная машина брейкера, как в failover_test
|
||
// донора.
|
||
func newTestFailover(primary, fallback LLMClient) *failoverClient {
|
||
return &failoverClient{
|
||
primary: primary, fallback: fallback,
|
||
log: quietLogger(), legTimeout: 1e9, needOK: 1,
|
||
}
|
||
}
|
||
|
||
func TestFailoverUnhealthyGoesStraightToCloud(t *testing.T) {
|
||
local := &fakeLLM{resp: &LLMResponse{Text: "local"}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud) // healthy=false на старте
|
||
|
||
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||
if err != nil || resp.Text != "cloud" {
|
||
t.Fatalf("resp=%v err=%v", resp, err)
|
||
}
|
||
if local.calls != 0 {
|
||
t.Fatal("unhealthy leg must not be tried")
|
||
}
|
||
}
|
||
|
||
func TestFailoverHealthyServesLocal(t *testing.T) {
|
||
local := &fakeLLM{resp: &LLMResponse{Text: "local", Model: "local-model"}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud)
|
||
f.noteProbe(true)
|
||
|
||
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||
if err != nil || resp.Text != "local" {
|
||
t.Fatalf("resp=%v err=%v", resp, err)
|
||
}
|
||
if cloud.calls != 0 {
|
||
t.Fatal("cloud must not be billed when local serves")
|
||
}
|
||
}
|
||
|
||
func TestFailoverEmptyLocalRetriesOnCloud(t *testing.T) {
|
||
// Пустой 2xx локальной ноги (thinking съел бюджет) — бесплатный, поэтому
|
||
// ретраим в облако; брейкер НЕ срабатывает.
|
||
local := &fakeLLM{resp: &LLMResponse{Text: " "}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud)
|
||
f.noteProbe(true)
|
||
|
||
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||
if err != nil || resp.Text != "cloud" {
|
||
t.Fatalf("resp=%v err=%v", resp, err)
|
||
}
|
||
if !f.isHealthy() {
|
||
t.Fatal("empty content must not trip the breaker")
|
||
}
|
||
}
|
||
|
||
func TestFailoverTerminal4xxFailsLoud(t *testing.T) {
|
||
// Терминальный 4xx локальной ноги — конфиг-ошибка (неверный тег модели);
|
||
// облако её маскировать не должно.
|
||
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 404, Body: "no such model"}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud)
|
||
f.noteProbe(true)
|
||
|
||
_, err := f.Complete(context.Background(), LLMRequest{})
|
||
var se *HTTPStatusError
|
||
if err == nil || !errors.As(err, &se) || se.Status != 404 {
|
||
t.Fatalf("want loud 404, got %v", err)
|
||
}
|
||
if cloud.calls != 0 {
|
||
t.Fatal("terminal 4xx must not be masked by the cloud")
|
||
}
|
||
}
|
||
|
||
func TestFailover5xxTripsBreakerAndFallsBack(t *testing.T) {
|
||
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 502, Body: "boom"}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud)
|
||
f.noteProbe(true)
|
||
|
||
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||
if err != nil || resp.Text != "cloud" {
|
||
t.Fatalf("resp=%v err=%v", resp, err)
|
||
}
|
||
if f.isHealthy() {
|
||
t.Fatal("5xx must trip the breaker")
|
||
}
|
||
// Анти-flapping: после request-trip одной успешной пробы мало.
|
||
f.noteProbe(true)
|
||
if f.isHealthy() {
|
||
t.Fatal("one probe success must not re-close a request-tripped breaker")
|
||
}
|
||
f.noteProbe(true)
|
||
if !f.isHealthy() {
|
||
t.Fatal("two consecutive probe successes must re-close the breaker")
|
||
}
|
||
}
|
||
|
||
func Test429FallsBackLikeTimeout(t *testing.T) {
|
||
// 429 — занятый single-slot GPU, падает в облако как таймаут (не как
|
||
// терминальный 4xx).
|
||
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 429, Body: "busy"}}
|
||
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||
f := newTestFailover(local, cloud)
|
||
f.noteProbe(true)
|
||
|
||
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||
if err != nil || resp.Text != "cloud" {
|
||
t.Fatalf("resp=%v err=%v", resp, err)
|
||
}
|
||
}
|