textmachine/backend/internal/llm/failover_test.go

137 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}