| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- //go:build unit
- package service
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/gin-gonic/gin"
- )
- type transportTempUnschedRepoStub struct {
- AccountRepository
- calls int
- lastID int64
- lastUntil time.Time
- lastReason string
- }
- func (r *transportTempUnschedRepoStub) SetTempUnschedulable(_ context.Context, id int64, until time.Time, reason string) error {
- r.calls++
- r.lastID = id
- r.lastUntil = until
- r.lastReason = reason
- return nil
- }
- func newTransportErrorTestGin(t *testing.T) *gin.Context {
- t.Helper()
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- return c
- }
- // TestHandleUpstreamTransportError_TransientFailsOverWithoutEviction pins the
- // contract for transient transport blips (EOF / connection reset): the request
- // fails over to another account, the current account stays schedulable, and
- // nothing is written to the response (the handler owns it).
- func TestHandleUpstreamTransportError_TransientFailsOverWithoutEviction(t *testing.T) {
- repo := &transportTempUnschedRepoStub{}
- s := &GatewayService{accountRepo: repo}
- c := newTransportErrorTestGin(t)
- account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
- err := s.handleUpstreamTransportError(context.Background(), c, account,
- errors.New(`Post "http://upstream/v1/messages?beta=true": EOF`), OpsUpstreamErrorEvent{})
- var failoverErr *UpstreamFailoverError
- if !errors.As(err, &failoverErr) {
- t.Fatalf("expected *UpstreamFailoverError, got %T: %v", err, err)
- }
- if failoverErr.StatusCode != http.StatusBadGateway {
- t.Fatalf("StatusCode = %d, want 502", failoverErr.StatusCode)
- }
- if string(failoverErr.ResponseBody) != string(gatewayTransportFailoverBody) {
- t.Fatalf("ResponseBody = %s, want legacy 502 body", failoverErr.ResponseBody)
- }
- if !failoverErr.ShouldRetryNextAccount() {
- t.Fatal("transient transport error must allow retrying the next account")
- }
- if repo.calls != 0 {
- t.Fatalf("SetTempUnschedulable called %d times for a transient error, want 0", repo.calls)
- }
- if c.Writer.Written() {
- t.Fatal("handler owns the response; service must not write on transport failover")
- }
- }
- // TestHandleUpstreamTransportError_AttributesEventTimeProxy pins that the
- // shared helper stamps proxy attribution from the same account snapshot the
- // transport used, so every Anthropic/Bedrock caller inherits it.
- func TestHandleUpstreamTransportError_AttributesEventTimeProxy(t *testing.T) {
- proxyID := int64(10060)
- tests := []struct {
- name string
- account *Account
- wantID *int64
- wantName string
- }{
- {
- name: "managed proxy",
- account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "wldsg82-ipv6-10060"}},
- wantID: &proxyID,
- wantName: "wldsg82-ipv6-10060",
- },
- {
- name: "direct",
- account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic},
- wantName: opsProxyNameDirect,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
- c := newTransportErrorTestGin(t)
- _ = s.handleUpstreamTransportError(context.Background(), c, tt.account,
- errors.New("EOF"), OpsUpstreamErrorEvent{UpstreamURL: "https://api.anthropic.com/v1/messages", Passthrough: true})
- raw, ok := c.Get(OpsUpstreamErrorsKey)
- if !ok {
- t.Fatal("no upstream events recorded")
- }
- events := raw.([]*OpsUpstreamErrorEvent)
- if len(events) != 1 {
- t.Fatalf("events = %d, want 1", len(events))
- }
- ev := events[0]
- if tt.wantID == nil {
- if ev.ProxyID != nil {
- t.Fatalf("proxy_id = %d, want null", *ev.ProxyID)
- }
- } else if ev.ProxyID == nil || *ev.ProxyID != *tt.wantID {
- t.Fatalf("proxy_id = %v, want %d", ev.ProxyID, *tt.wantID)
- }
- if ev.ProxyName != tt.wantName {
- t.Fatalf("proxy_name = %q, want %q", ev.ProxyName, tt.wantName)
- }
- if !ev.Passthrough || ev.UpstreamURL == "" || ev.Kind != "request_error" {
- t.Fatalf("caller-supplied fields lost: %+v", ev)
- }
- })
- }
- }
- // TestHandleUpstreamTransportError_PersistentEvictsAccount pins the contract
- // for durable faults (dead endpoint / DNS / proxy credentials): fail over AND
- // temporarily unschedule the account for the transport cooldown.
- func TestHandleUpstreamTransportError_PersistentEvictsAccount(t *testing.T) {
- repo := &transportTempUnschedRepoStub{}
- s := &GatewayService{accountRepo: repo}
- c := newTransportErrorTestGin(t)
- account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
- before := time.Now()
- err := s.handleUpstreamTransportError(context.Background(), c, account,
- errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`), OpsUpstreamErrorEvent{})
- var failoverErr *UpstreamFailoverError
- if !errors.As(err, &failoverErr) {
- t.Fatalf("expected *UpstreamFailoverError, got %T: %v", err, err)
- }
- if repo.calls != 1 {
- t.Fatalf("SetTempUnschedulable called %d times for a persistent error, want 1", repo.calls)
- }
- if repo.lastID != account.ID {
- t.Fatalf("unscheduled account = %d, want %d", repo.lastID, account.ID)
- }
- wantUntil := before.Add(gatewayTransportErrorTempUnschedDuration)
- if repo.lastUntil.Before(wantUntil.Add(-time.Minute)) || repo.lastUntil.After(wantUntil.Add(time.Minute)) {
- t.Fatalf("until = %v, want ~%v", repo.lastUntil, wantUntil)
- }
- if !strings.HasPrefix(repo.lastReason, "upstream transport error (proxy/network): ") {
- t.Fatalf("reason = %q, want transport-error prefix", repo.lastReason)
- }
- }
- // TestHandleUpstreamTransportError_ClientCanceledNoFailover pins that a
- // canceled client neither fails over nor evicts: the upstream never had a
- // chance to exhibit a fault.
- func TestHandleUpstreamTransportError_ClientCanceledNoFailover(t *testing.T) {
- repo := &transportTempUnschedRepoStub{}
- s := &GatewayService{accountRepo: repo}
- c := newTransportErrorTestGin(t)
- account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
- inErr := context.Canceled
- err := s.handleUpstreamTransportError(context.Background(), c, account, inErr, OpsUpstreamErrorEvent{})
- var failoverErr *UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- t.Fatal("canceled client must not fail over to another account")
- }
- if !errors.Is(err, context.Canceled) {
- t.Fatalf("err = %v, want context.Canceled passthrough", err)
- }
- if repo.calls != 0 {
- t.Fatalf("SetTempUnschedulable called %d times on client cancel, want 0", repo.calls)
- }
- }
- // TestHandleUpstreamTransportError_UpstreamDeadlineStillFailsOver pins that an
- // upstream-side timeout (request context still alive) is treated as a
- // transient fault: fail over, no eviction.
- func TestHandleUpstreamTransportError_UpstreamDeadlineStillFailsOver(t *testing.T) {
- repo := &transportTempUnschedRepoStub{}
- s := &GatewayService{accountRepo: repo}
- c := newTransportErrorTestGin(t)
- account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
- err := s.handleUpstreamTransportError(context.Background(), c, account,
- context.DeadlineExceeded, OpsUpstreamErrorEvent{})
- var failoverErr *UpstreamFailoverError
- if !errors.As(err, &failoverErr) {
- t.Fatalf("upstream deadline with live request context must fail over, got %T: %v", err, err)
- }
- if repo.calls != 0 {
- t.Fatalf("SetTempUnschedulable called %d times for upstream deadline, want 0", repo.calls)
- }
- }
|