openai_proxy_stream_circuit.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  8. "go.uber.org/zap"
  9. )
  10. const (
  11. defaultOpenAIProxyStreamFailureThreshold = 2
  12. defaultOpenAIProxyStreamFailureWindow = time.Minute
  13. defaultOpenAIProxyStreamQuarantineTTL = 10 * time.Minute
  14. defaultOpenAIProxyStreamCircuitMaxEntries = 4096
  15. // defaultOpenAIProxyStreamFailureCollapse merges disconnects that land
  16. // within this interval into a single failure event. One proxy or HTTP/2
  17. // connection loss kills every stream multiplexed on it at the same
  18. // moment; counting each stream separately would trip the breaker from a
  19. // single upstream event (#5056).
  20. defaultOpenAIProxyStreamFailureCollapse = 3 * time.Second
  21. // openAIProxyStreamFailOpenLogInterval rate-limits the fail-open warning
  22. // so an outage-mode burst does not flood the log.
  23. openAIProxyStreamFailOpenLogInterval = 5 * time.Second
  24. )
  25. type openAIProxyStreamCircuitSettings struct {
  26. disabled bool
  27. failureThreshold int
  28. failureWindow time.Duration
  29. quarantineTTL time.Duration
  30. collapseInterval time.Duration
  31. maxEntries int
  32. }
  33. type openAIProxyStreamCircuitEntry struct {
  34. failureCount int
  35. windowStart time.Time
  36. lastFailureAt time.Time
  37. blockedUntil time.Time
  38. lastTouched time.Time
  39. }
  40. // openAIProxyStreamCircuit is an in-process, proxy-ID keyed circuit. It is
  41. // intentionally bounded and ephemeral: a restart clears observations, while a
  42. // tripped entry expires automatically after its TTL.
  43. type openAIProxyStreamCircuit struct {
  44. mu sync.Mutex
  45. settings openAIProxyStreamCircuitSettings
  46. entries map[int64]openAIProxyStreamCircuitEntry
  47. }
  48. func resolveOpenAIProxyStreamCircuitSettings(s *OpenAIGatewayService) openAIProxyStreamCircuitSettings {
  49. settings := openAIProxyStreamCircuitSettings{
  50. failureThreshold: defaultOpenAIProxyStreamFailureThreshold,
  51. failureWindow: defaultOpenAIProxyStreamFailureWindow,
  52. quarantineTTL: defaultOpenAIProxyStreamQuarantineTTL,
  53. collapseInterval: defaultOpenAIProxyStreamFailureCollapse,
  54. maxEntries: defaultOpenAIProxyStreamCircuitMaxEntries,
  55. }
  56. if s == nil || s.cfg == nil {
  57. return settings
  58. }
  59. cfg := s.cfg.Gateway.OpenAIProxyStreamCircuit
  60. settings.disabled = cfg.Disabled
  61. if cfg.FailureThreshold > 0 {
  62. settings.failureThreshold = cfg.FailureThreshold
  63. }
  64. if cfg.WindowSeconds > 0 {
  65. settings.failureWindow = time.Duration(cfg.WindowSeconds) * time.Second
  66. }
  67. if cfg.TTLSeconds > 0 {
  68. settings.quarantineTTL = time.Duration(cfg.TTLSeconds) * time.Second
  69. }
  70. return settings
  71. }
  72. func newOpenAIProxyStreamCircuit(settings openAIProxyStreamCircuitSettings) *openAIProxyStreamCircuit {
  73. if settings.failureThreshold <= 0 {
  74. settings.failureThreshold = defaultOpenAIProxyStreamFailureThreshold
  75. }
  76. if settings.failureWindow <= 0 {
  77. settings.failureWindow = defaultOpenAIProxyStreamFailureWindow
  78. }
  79. if settings.quarantineTTL <= 0 {
  80. settings.quarantineTTL = defaultOpenAIProxyStreamQuarantineTTL
  81. }
  82. if settings.maxEntries <= 0 {
  83. settings.maxEntries = defaultOpenAIProxyStreamCircuitMaxEntries
  84. }
  85. if settings.collapseInterval < 0 {
  86. settings.collapseInterval = 0
  87. }
  88. return &openAIProxyStreamCircuit{
  89. settings: settings,
  90. entries: make(map[int64]openAIProxyStreamCircuitEntry),
  91. }
  92. }
  93. func (s *OpenAIGatewayService) getOpenAIProxyStreamCircuit() *openAIProxyStreamCircuit {
  94. if s == nil {
  95. return nil
  96. }
  97. s.openaiProxyStreamCircuitOnce.Do(func() {
  98. if s.openaiProxyStreamCircuit == nil {
  99. s.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(resolveOpenAIProxyStreamCircuitSettings(s))
  100. }
  101. })
  102. return s.openaiProxyStreamCircuit
  103. }
  104. func (c *openAIProxyStreamCircuit) recordFailure(proxyID int64, now time.Time) (bool, time.Time) {
  105. if c == nil || c.settings.disabled || proxyID <= 0 {
  106. return false, time.Time{}
  107. }
  108. c.mu.Lock()
  109. defer c.mu.Unlock()
  110. entry, exists := c.entries[proxyID]
  111. if exists && now.Before(entry.blockedUntil) {
  112. entry.lastTouched = now
  113. c.entries[proxyID] = entry
  114. return false, entry.blockedUntil
  115. }
  116. if !exists {
  117. c.ensureCapacityLocked(now)
  118. }
  119. if entry.windowStart.IsZero() || now.Before(entry.windowStart) || now.Sub(entry.windowStart) > c.settings.failureWindow {
  120. entry.failureCount = 0
  121. entry.windowStart = now
  122. entry.blockedUntil = time.Time{}
  123. }
  124. // Collapse a burst of disconnects into one failure event: when a proxy or
  125. // a multiplexed HTTP/2 connection dies, every in-flight stream reports the
  126. // same underlying incident within moments of each other.
  127. if c.settings.collapseInterval > 0 && !entry.lastFailureAt.IsZero() &&
  128. now.Sub(entry.lastFailureAt) >= 0 && now.Sub(entry.lastFailureAt) < c.settings.collapseInterval {
  129. entry.lastTouched = now
  130. c.entries[proxyID] = entry
  131. return false, time.Time{}
  132. }
  133. entry.failureCount++
  134. entry.lastFailureAt = now
  135. entry.lastTouched = now
  136. tripped := entry.failureCount >= c.settings.failureThreshold
  137. if tripped {
  138. entry.blockedUntil = now.Add(c.settings.quarantineTTL)
  139. }
  140. c.entries[proxyID] = entry
  141. return tripped, entry.blockedUntil
  142. }
  143. func (c *openAIProxyStreamCircuit) recordSuccess(proxyID int64) bool {
  144. if c == nil || proxyID <= 0 {
  145. return false
  146. }
  147. c.mu.Lock()
  148. defer c.mu.Unlock()
  149. if _, ok := c.entries[proxyID]; !ok {
  150. return false
  151. }
  152. delete(c.entries, proxyID)
  153. return true
  154. }
  155. func (c *openAIProxyStreamCircuit) isBlocked(proxyID int64, now time.Time) bool {
  156. if c == nil || c.settings.disabled || proxyID <= 0 {
  157. return false
  158. }
  159. c.mu.Lock()
  160. defer c.mu.Unlock()
  161. entry, ok := c.entries[proxyID]
  162. if !ok || entry.blockedUntil.IsZero() {
  163. return false
  164. }
  165. if !now.Before(entry.blockedUntil) {
  166. delete(c.entries, proxyID)
  167. return false
  168. }
  169. return true
  170. }
  171. // activeBlockCount reports how many proxies are currently quarantined. It
  172. // gates the fail-open retry: a "no available accounts" selection result only
  173. // warrants a second, quarantine-blind pass when the circuit is actually
  174. // withholding capacity.
  175. func (c *openAIProxyStreamCircuit) activeBlockCount(now time.Time) int {
  176. if c == nil || c.settings.disabled {
  177. return 0
  178. }
  179. c.mu.Lock()
  180. defer c.mu.Unlock()
  181. count := 0
  182. for _, entry := range c.entries {
  183. if !entry.blockedUntil.IsZero() && now.Before(entry.blockedUntil) {
  184. count++
  185. }
  186. }
  187. return count
  188. }
  189. func (c *openAIProxyStreamCircuit) ensureCapacityLocked(now time.Time) {
  190. if len(c.entries) < c.settings.maxEntries {
  191. return
  192. }
  193. for proxyID, entry := range c.entries {
  194. staleObservation := entry.blockedUntil.IsZero() && now.Sub(entry.lastTouched) > c.settings.failureWindow
  195. expiredQuarantine := !entry.blockedUntil.IsZero() && !now.Before(entry.blockedUntil)
  196. if staleObservation || expiredQuarantine {
  197. delete(c.entries, proxyID)
  198. }
  199. }
  200. if len(c.entries) < c.settings.maxEntries {
  201. return
  202. }
  203. var oldestProxyID int64
  204. var oldest time.Time
  205. for proxyID, entry := range c.entries {
  206. if oldestProxyID == 0 || entry.lastTouched.Before(oldest) {
  207. oldestProxyID = proxyID
  208. oldest = entry.lastTouched
  209. }
  210. }
  211. if oldestProxyID > 0 {
  212. delete(c.entries, oldestProxyID)
  213. }
  214. }
  215. func openAIProxyStreamCircuitProxyID(account *Account) (int64, bool) {
  216. if account == nil || account.Platform != PlatformOpenAI || account.ProxyID == nil || *account.ProxyID <= 0 {
  217. return 0, false
  218. }
  219. return *account.ProxyID, true
  220. }
  221. func (s *OpenAIGatewayService) recordOpenAIProxyStreamDisconnect(account *Account, streamErr error, upstreamRequestID string) {
  222. proxyID, ok := openAIProxyStreamCircuitProxyID(account)
  223. if !ok || streamErr == nil || errors.Is(streamErr, context.Canceled) || errors.Is(streamErr, context.DeadlineExceeded) {
  224. return
  225. }
  226. circuit := s.getOpenAIProxyStreamCircuit()
  227. tripped, until := circuit.recordFailure(proxyID, time.Now())
  228. if !tripped {
  229. return
  230. }
  231. logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
  232. "openai.proxy_quarantined_stream_disconnect",
  233. zap.Int64("proxy_id", proxyID),
  234. zap.Int64("account_id", account.ID),
  235. zap.Time("until", until),
  236. zap.String("upstream_request_id", upstreamRequestID),
  237. zap.String("error", sanitizeUpstreamErrorMessage(streamErr.Error())),
  238. )
  239. }
  240. func (s *OpenAIGatewayService) clearOpenAIProxyStreamDisconnect(account *Account) {
  241. proxyID, ok := openAIProxyStreamCircuitProxyID(account)
  242. if !ok {
  243. return
  244. }
  245. if circuit := s.getOpenAIProxyStreamCircuit(); circuit != nil {
  246. circuit.recordSuccess(proxyID)
  247. }
  248. }
  249. // openAIProxyStreamQuarantineBypassKey marks a selection pass that must ignore
  250. // proxy quarantine. It is set for the second, fail-open selection attempt when
  251. // the first pass found no available account while the circuit was withholding
  252. // proxies: a degraded proxy is strictly better than answering 502 (#5056).
  253. type openAIProxyStreamQuarantineBypassKey struct{}
  254. func withOpenAIProxyStreamQuarantineBypass(ctx context.Context) context.Context {
  255. return context.WithValue(ctx, openAIProxyStreamQuarantineBypassKey{}, true)
  256. }
  257. func openAIProxyStreamQuarantineBypassed(ctx context.Context) bool {
  258. if ctx == nil {
  259. return false
  260. }
  261. bypassed, _ := ctx.Value(openAIProxyStreamQuarantineBypassKey{}).(bool)
  262. return bypassed
  263. }
  264. func (s *OpenAIGatewayService) isOpenAIProxyStreamQuarantined(ctx context.Context, account *Account) bool {
  265. proxyID, ok := openAIProxyStreamCircuitProxyID(account)
  266. if !ok {
  267. return false
  268. }
  269. if openAIProxyStreamQuarantineBypassed(ctx) {
  270. return false
  271. }
  272. circuit := s.getOpenAIProxyStreamCircuit()
  273. return circuit != nil && circuit.isBlocked(proxyID, time.Now())
  274. }
  275. // logOpenAIProxyStreamQuarantineFailOpen emits a rate-limited warning when a
  276. // selection pass had to re-admit quarantined proxies to serve at all.
  277. func (s *OpenAIGatewayService) logOpenAIProxyStreamQuarantineFailOpen(requestedModel string, blockedProxies int) {
  278. now := time.Now().UnixNano()
  279. last := s.openaiProxyStreamFailOpenLogAt.Load()
  280. if now-last < int64(openAIProxyStreamFailOpenLogInterval) ||
  281. !s.openaiProxyStreamFailOpenLogAt.CompareAndSwap(last, now) {
  282. return
  283. }
  284. logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
  285. "openai.proxy_stream_quarantine_fail_open",
  286. zap.Int("blocked_proxies", blockedProxies),
  287. zap.String("model", requestedModel),
  288. )
  289. }