openai_upstream_transport_error.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "net"
  6. "net/http"
  7. "strings"
  8. "syscall"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/gin-gonic/gin"
  12. "go.uber.org/zap"
  13. )
  14. // openAITransportErrorTempUnschedDuration is how long an account is temporarily
  15. // unscheduled after a durable transport failure (matches tokenRefreshTempUnschedDuration).
  16. const openAITransportErrorTempUnschedDuration = 10 * time.Minute
  17. // openAITransportFailoverBody is the OpenAI-format error body attached to the
  18. // failover error for a transport-level failure. Kept identical to the legacy
  19. // inline 502 body so the client-visible payload is unchanged if failover is
  20. // ultimately exhausted.
  21. var openAITransportFailoverBody = []byte(`{"error":{"type":"upstream_error","message":"Upstream request failed"}}`)
  22. // upstreamTransportErrorClass describes how to react to a transport-level upstream
  23. // failure — i.e. the HTTP round-trip never completed (proxy / DNS / TCP / TLS
  24. // error, no HTTP status code received).
  25. type upstreamTransportErrorClass struct {
  26. // Persistent marks failures where retrying the same proxy/account is
  27. // pointless: expired or rejected proxy credentials, a dead proxy endpoint,
  28. // or DNS/routing failure. Such accounts should be temporarily unscheduled
  29. // (and alerted on) instead of being repeatedly scheduled into hard failures.
  30. Persistent bool
  31. }
  32. // persistentUpstreamTransportErrorMarkers are substrings (matched case-insensitively
  33. // against the raw transport error) that indicate a durable proxy/network fault.
  34. // Matched signals are intentionally specific failure *reasons*, not the operation
  35. // (e.g. we match "connection refused", not "proxyconnect") so that a transient
  36. // failure of the same operation (a proxy timeout) is NOT misclassified as durable.
  37. var persistentUpstreamTransportErrorMarkers = []string{
  38. "authentication failed", // SOCKS5 RFC1929 / proxy credentials rejected (expired account)
  39. "proxy authentication required", // HTTP proxy 407
  40. "connection refused", // proxy/upstream endpoint down
  41. "no route to host",
  42. "network is unreachable",
  43. "no such host", // DNS resolution failure (bad/expired proxy hostname)
  44. }
  45. // classifyUpstreamTransportError decides whether a transport-level upstream error
  46. // is durable (Persistent — evict the account + alert) or a transient blip
  47. // (fail over to a healthy account but keep this one schedulable).
  48. //
  49. // Motivating incident: a SOCKS5 proxy whose subscription lapsed returned
  50. // `username/password authentication failed`; the account was nonetheless
  51. // rescheduled on every request, hard-failing users with 502s.
  52. //
  53. // Classification strategy (mirrors sanitizeStreamError in gateway_service.go):
  54. // 1. Typed-error checks first (syscall constants, *net.DNSError) — portable and
  55. // unambiguous.
  56. // 2. String-marker fallback for errors that have no typed form (e.g. the plain
  57. // string returned by golang.org/x/net/proxy for SOCKS5 credential rejection).
  58. // The network-layer string markers ("connection refused", "no route to host",
  59. // "network is unreachable", "no such host") are kept as a cross-platform safety
  60. // net even though the typed checks should cover them on modern Go+Linux.
  61. func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
  62. if err == nil {
  63. return upstreamTransportErrorClass{}
  64. }
  65. // — Typed checks (preferred) ——————————————————————————————————————————————
  66. if errors.Is(err, syscall.ECONNREFUSED) ||
  67. errors.Is(err, syscall.EHOSTUNREACH) ||
  68. errors.Is(err, syscall.ENETUNREACH) {
  69. return upstreamTransportErrorClass{Persistent: true}
  70. }
  71. var dnsErr *net.DNSError
  72. if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
  73. return upstreamTransportErrorClass{Persistent: true}
  74. }
  75. // — String-marker fallback ————————————————————————————————————————————————
  76. msg := strings.ToLower(err.Error())
  77. for _, marker := range persistentUpstreamTransportErrorMarkers {
  78. if strings.Contains(msg, marker) {
  79. return upstreamTransportErrorClass{Persistent: true}
  80. }
  81. }
  82. return upstreamTransportErrorClass{}
  83. }
  84. // handleOpenAIUpstreamTransportError handles a transport-level upstream failure
  85. // (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It:
  86. // 1. records the failure in Ops error logs (status 0, kind=request_error);
  87. // 2. for durable faults (expired/rejected proxy creds, dead proxy, DNS/routing)
  88. // temporarily unschedules the account (DB + in-memory) and logs a stable
  89. // warn event that alert rules can key on;
  90. // 3. returns an error that is *UpstreamFailoverError (so the handler fails over
  91. // to a healthy account) for all non-canceled errors, or a plain error for
  92. // context.Canceled (client gone — no failover, no eviction).
  93. //
  94. // It deliberately does NOT write to the response: the handler owns the response
  95. // (failover, or a protocol-correct error once failover is exhausted).
  96. //
  97. // passthrough tags the Ops error event for the OpenAI passthrough forward path.
  98. func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error {
  99. if local := AccountTrafficFailover(err); local != nil {
  100. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
  101. return local
  102. }
  103. safeErr := sanitizeUpstreamErrorMessage(err.Error())
  104. setOpsUpstreamError(c, 0, safeErr, "")
  105. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  106. ProxyID: opsUpstreamProxyID(account),
  107. ProxyName: opsUpstreamProxyName(account),
  108. Platform: account.Platform,
  109. AccountID: account.ID,
  110. AccountName: account.Name,
  111. UpstreamStatusCode: 0,
  112. Passthrough: passthrough,
  113. Kind: "request_error",
  114. Message: safeErr,
  115. })
  116. // Client disconnected: do NOT fail over to another account and do NOT evict
  117. // this one — the upstream never had a chance to exhibit a fault.
  118. if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) {
  119. return err
  120. }
  121. // Transport attempt reached the network path; count as Ollama Cloud activity.
  122. if s != nil {
  123. scheduleOllamaCloudUsageActivity(s.deferredService, account)
  124. }
  125. // 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
  126. var pluginErr *PluginTransportError
  127. if errors.As(err, &pluginErr) && pluginErr.RequestSent {
  128. return err
  129. }
  130. if classifyUpstreamTransportError(err).Persistent {
  131. s.tempUnscheduleOpenAITransportError(ctx, account, safeErr)
  132. }
  133. return &UpstreamFailoverError{
  134. StatusCode: http.StatusBadGateway,
  135. ResponseBody: openAITransportFailoverBody,
  136. }
  137. }
  138. // tempUnscheduleOpenAITransportError marks an account temporarily unschedulable
  139. // after a durable transport failure, both persistently (DB, survives restart)
  140. // and in-memory (immediate scheduler effect before the DB/account cache propagates).
  141. //
  142. // Log semantics:
  143. // - "openai.account_temp_unscheduled_transport" — emitted ONLY after a
  144. // successful DB write (both in-memory + persisted).
  145. // - "openai.account_temp_unscheduled_transport_memory_only" — emitted when
  146. // accountRepo is nil (in-memory only; no persistence).
  147. // - "openai.account_temp_unscheduled_transport_failed" — DB write attempted
  148. // but returned an error.
  149. func (s *OpenAIGatewayService) tempUnscheduleOpenAITransportError(ctx context.Context, account *Account, safeErr string) {
  150. if s == nil || account == nil {
  151. return
  152. }
  153. until := time.Now().Add(openAITransportErrorTempUnschedDuration)
  154. reason := "upstream transport error (proxy/network): " + safeErr
  155. // Immediate in-memory block so this process skips the account until the
  156. // persisted cooldown is visible on the scheduling Account. Selection is
  157. // fail-open: empty snapshot/DB cooldown fields drop a stale local block.
  158. s.BlockAccountScheduling(account, until, "transport_error")
  159. if s.accountRepo == nil {
  160. // No DB configured — block is in-memory only; emit a distinct event so
  161. // operators are not misled into thinking the block survived a restart.
  162. logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
  163. "openai.account_temp_unscheduled_transport_memory_only",
  164. zap.Int64("account_id", account.ID),
  165. zap.String("account_name", account.Name),
  166. zap.String("platform", account.Platform),
  167. zap.Time("until", until),
  168. zap.String("reason", reason),
  169. )
  170. return
  171. }
  172. bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAccountStateUpdateTimeout)
  173. defer cancel()
  174. if err := s.accountRepo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil {
  175. logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
  176. "openai.account_temp_unscheduled_transport_failed",
  177. zap.Int64("account_id", account.ID),
  178. zap.Error(err),
  179. )
  180. return
  181. }
  182. // DB write succeeded: both in-memory and persisted.
  183. logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
  184. "openai.account_temp_unscheduled_transport",
  185. zap.Int64("account_id", account.ID),
  186. zap.String("account_name", account.Name),
  187. zap.String("platform", account.Platform),
  188. zap.Time("until", until),
  189. zap.String("reason", reason),
  190. )
  191. }