gateway_upstream_transport_error_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/gin-gonic/gin"
  12. )
  13. type transportTempUnschedRepoStub struct {
  14. AccountRepository
  15. calls int
  16. lastID int64
  17. lastUntil time.Time
  18. lastReason string
  19. }
  20. func (r *transportTempUnschedRepoStub) SetTempUnschedulable(_ context.Context, id int64, until time.Time, reason string) error {
  21. r.calls++
  22. r.lastID = id
  23. r.lastUntil = until
  24. r.lastReason = reason
  25. return nil
  26. }
  27. func newTransportErrorTestGin(t *testing.T) *gin.Context {
  28. t.Helper()
  29. gin.SetMode(gin.TestMode)
  30. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  31. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  32. return c
  33. }
  34. // TestHandleUpstreamTransportError_TransientFailsOverWithoutEviction pins the
  35. // contract for transient transport blips (EOF / connection reset): the request
  36. // fails over to another account, the current account stays schedulable, and
  37. // nothing is written to the response (the handler owns it).
  38. func TestHandleUpstreamTransportError_TransientFailsOverWithoutEviction(t *testing.T) {
  39. repo := &transportTempUnschedRepoStub{}
  40. s := &GatewayService{accountRepo: repo}
  41. c := newTransportErrorTestGin(t)
  42. account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
  43. err := s.handleUpstreamTransportError(context.Background(), c, account,
  44. errors.New(`Post "http://upstream/v1/messages?beta=true": EOF`), OpsUpstreamErrorEvent{})
  45. var failoverErr *UpstreamFailoverError
  46. if !errors.As(err, &failoverErr) {
  47. t.Fatalf("expected *UpstreamFailoverError, got %T: %v", err, err)
  48. }
  49. if failoverErr.StatusCode != http.StatusBadGateway {
  50. t.Fatalf("StatusCode = %d, want 502", failoverErr.StatusCode)
  51. }
  52. if string(failoverErr.ResponseBody) != string(gatewayTransportFailoverBody) {
  53. t.Fatalf("ResponseBody = %s, want legacy 502 body", failoverErr.ResponseBody)
  54. }
  55. if !failoverErr.ShouldRetryNextAccount() {
  56. t.Fatal("transient transport error must allow retrying the next account")
  57. }
  58. if repo.calls != 0 {
  59. t.Fatalf("SetTempUnschedulable called %d times for a transient error, want 0", repo.calls)
  60. }
  61. if c.Writer.Written() {
  62. t.Fatal("handler owns the response; service must not write on transport failover")
  63. }
  64. }
  65. // TestHandleUpstreamTransportError_AttributesEventTimeProxy pins that the
  66. // shared helper stamps proxy attribution from the same account snapshot the
  67. // transport used, so every Anthropic/Bedrock caller inherits it.
  68. func TestHandleUpstreamTransportError_AttributesEventTimeProxy(t *testing.T) {
  69. proxyID := int64(10060)
  70. tests := []struct {
  71. name string
  72. account *Account
  73. wantID *int64
  74. wantName string
  75. }{
  76. {
  77. name: "managed proxy",
  78. account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "wldsg82-ipv6-10060"}},
  79. wantID: &proxyID,
  80. wantName: "wldsg82-ipv6-10060",
  81. },
  82. {
  83. name: "direct",
  84. account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic},
  85. wantName: opsProxyNameDirect,
  86. },
  87. }
  88. for _, tt := range tests {
  89. t.Run(tt.name, func(t *testing.T) {
  90. s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
  91. c := newTransportErrorTestGin(t)
  92. _ = s.handleUpstreamTransportError(context.Background(), c, tt.account,
  93. errors.New("EOF"), OpsUpstreamErrorEvent{UpstreamURL: "https://api.anthropic.com/v1/messages", Passthrough: true})
  94. raw, ok := c.Get(OpsUpstreamErrorsKey)
  95. if !ok {
  96. t.Fatal("no upstream events recorded")
  97. }
  98. events := raw.([]*OpsUpstreamErrorEvent)
  99. if len(events) != 1 {
  100. t.Fatalf("events = %d, want 1", len(events))
  101. }
  102. ev := events[0]
  103. if tt.wantID == nil {
  104. if ev.ProxyID != nil {
  105. t.Fatalf("proxy_id = %d, want null", *ev.ProxyID)
  106. }
  107. } else if ev.ProxyID == nil || *ev.ProxyID != *tt.wantID {
  108. t.Fatalf("proxy_id = %v, want %d", ev.ProxyID, *tt.wantID)
  109. }
  110. if ev.ProxyName != tt.wantName {
  111. t.Fatalf("proxy_name = %q, want %q", ev.ProxyName, tt.wantName)
  112. }
  113. if !ev.Passthrough || ev.UpstreamURL == "" || ev.Kind != "request_error" {
  114. t.Fatalf("caller-supplied fields lost: %+v", ev)
  115. }
  116. })
  117. }
  118. }
  119. // TestHandleUpstreamTransportError_PersistentEvictsAccount pins the contract
  120. // for durable faults (dead endpoint / DNS / proxy credentials): fail over AND
  121. // temporarily unschedule the account for the transport cooldown.
  122. func TestHandleUpstreamTransportError_PersistentEvictsAccount(t *testing.T) {
  123. repo := &transportTempUnschedRepoStub{}
  124. s := &GatewayService{accountRepo: repo}
  125. c := newTransportErrorTestGin(t)
  126. account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
  127. before := time.Now()
  128. err := s.handleUpstreamTransportError(context.Background(), c, account,
  129. errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`), OpsUpstreamErrorEvent{})
  130. var failoverErr *UpstreamFailoverError
  131. if !errors.As(err, &failoverErr) {
  132. t.Fatalf("expected *UpstreamFailoverError, got %T: %v", err, err)
  133. }
  134. if repo.calls != 1 {
  135. t.Fatalf("SetTempUnschedulable called %d times for a persistent error, want 1", repo.calls)
  136. }
  137. if repo.lastID != account.ID {
  138. t.Fatalf("unscheduled account = %d, want %d", repo.lastID, account.ID)
  139. }
  140. wantUntil := before.Add(gatewayTransportErrorTempUnschedDuration)
  141. if repo.lastUntil.Before(wantUntil.Add(-time.Minute)) || repo.lastUntil.After(wantUntil.Add(time.Minute)) {
  142. t.Fatalf("until = %v, want ~%v", repo.lastUntil, wantUntil)
  143. }
  144. if !strings.HasPrefix(repo.lastReason, "upstream transport error (proxy/network): ") {
  145. t.Fatalf("reason = %q, want transport-error prefix", repo.lastReason)
  146. }
  147. }
  148. // TestHandleUpstreamTransportError_ClientCanceledNoFailover pins that a
  149. // canceled client neither fails over nor evicts: the upstream never had a
  150. // chance to exhibit a fault.
  151. func TestHandleUpstreamTransportError_ClientCanceledNoFailover(t *testing.T) {
  152. repo := &transportTempUnschedRepoStub{}
  153. s := &GatewayService{accountRepo: repo}
  154. c := newTransportErrorTestGin(t)
  155. account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
  156. inErr := context.Canceled
  157. err := s.handleUpstreamTransportError(context.Background(), c, account, inErr, OpsUpstreamErrorEvent{})
  158. var failoverErr *UpstreamFailoverError
  159. if errors.As(err, &failoverErr) {
  160. t.Fatal("canceled client must not fail over to another account")
  161. }
  162. if !errors.Is(err, context.Canceled) {
  163. t.Fatalf("err = %v, want context.Canceled passthrough", err)
  164. }
  165. if repo.calls != 0 {
  166. t.Fatalf("SetTempUnschedulable called %d times on client cancel, want 0", repo.calls)
  167. }
  168. }
  169. // TestHandleUpstreamTransportError_UpstreamDeadlineStillFailsOver pins that an
  170. // upstream-side timeout (request context still alive) is treated as a
  171. // transient fault: fail over, no eviction.
  172. func TestHandleUpstreamTransportError_UpstreamDeadlineStillFailsOver(t *testing.T) {
  173. repo := &transportTempUnschedRepoStub{}
  174. s := &GatewayService{accountRepo: repo}
  175. c := newTransportErrorTestGin(t)
  176. account := &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic}
  177. err := s.handleUpstreamTransportError(context.Background(), c, account,
  178. context.DeadlineExceeded, OpsUpstreamErrorEvent{})
  179. var failoverErr *UpstreamFailoverError
  180. if !errors.As(err, &failoverErr) {
  181. t.Fatalf("upstream deadline with live request context must fail over, got %T: %v", err, err)
  182. }
  183. if repo.calls != 0 {
  184. t.Fatalf("SetTempUnschedulable called %d times for upstream deadline, want 0", repo.calls)
  185. }
  186. }