Files
sub2api/backend/internal/service/openai_gateway_upstream_errors.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

727 lines
24 KiB
Go
Raw 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 service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"go.uber.org/zap"
)
func logOpenAIInstructionsRequiredDebug(
ctx context.Context,
c *gin.Context,
account *Account,
upstreamStatusCode int,
upstreamMsg string,
requestBody []byte,
upstreamBody []byte,
) {
msg := strings.TrimSpace(upstreamMsg)
if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) {
return
}
if ctx == nil {
ctx = context.Background()
}
accountID := int64(0)
accountName := ""
if account != nil {
accountID = account.ID
accountName = strings.TrimSpace(account.Name)
}
userAgent := ""
originator := ""
if c != nil {
userAgent = strings.TrimSpace(c.GetHeader("User-Agent"))
originator = strings.TrimSpace(c.GetHeader("originator"))
}
fields := []zap.Field{
zap.String("component", "service.openai_gateway"),
zap.Int64("account_id", accountID),
zap.String("account_name", accountName),
zap.Int("upstream_status_code", upstreamStatusCode),
zap.String("upstream_error_message", msg),
zap.String("request_user_agent", userAgent),
zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)),
}
fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody)
logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查")
}
func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
if upstreamStatusCode != http.StatusBadRequest {
return false
}
hasInstructionRequired := func(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if strings.Contains(lower, "instructions are required") {
return true
}
if strings.Contains(lower, "required parameter: 'instructions'") {
return true
}
if strings.Contains(lower, "required parameter: instructions") {
return true
}
if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") {
return true
}
return strings.Contains(lower, "instruction") && strings.Contains(lower, "required")
}
if hasInstructionRequired(upstreamMsg) {
return true
}
if len(upstreamBody) == 0 {
return false
}
errMsg := gjson.GetBytes(upstreamBody, "error.message").String()
errMsgLower := strings.ToLower(strings.TrimSpace(errMsg))
errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String()))
errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String()))
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String()))
if errParam == "instructions" {
return true
}
if hasInstructionRequired(errMsg) {
return true
}
if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") {
return true
}
if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") {
return true
}
return false
}
func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
if upstreamStatusCode < http.StatusBadRequest {
return false
}
hasOpenAIServerOverloadedCode := func(payload []byte) bool {
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
if code == "" {
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
}
return code == "server_is_overloaded" || code == "slow_down"
}
if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) {
return true
}
if isOpenAICapacityShedMessage(upstreamMsg) ||
isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) ||
isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
isOpenAICapacityShedMessage(string(upstreamBody)) {
return true
}
if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable {
return false
}
if upstreamStatusCode != http.StatusBadRequest {
return false
}
match := func(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if strings.Contains(lower, "an error occurred while processing your request") {
return true
}
if strings.Contains(lower, "selected model is at capacity") {
return true
}
return strings.Contains(lower, "you can retry your request") &&
strings.Contains(lower, "help.openai.com") &&
strings.Contains(lower, "request id")
}
if match(upstreamMsg) {
return true
}
if len(upstreamBody) == 0 {
return false
}
if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
return true
}
return match(string(upstreamBody))
}
func isOpenAICapacityShedMessage(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return strings.Contains(lower, "server is overloaded") ||
strings.Contains(lower, "servers are overloaded") ||
strings.Contains(lower, "servers are currently overloaded")
}
func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool {
return isOpenAIUpstreamCapacityShedEvent(upstreamBody) ||
isOpenAICapacityShedMessage(upstreamMsg) ||
isOpenAICapacityShedMessage(string(upstreamBody))
}
func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
match := func(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") {
return true
}
if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") {
return true
}
hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long")
if strings.Contains(lower, "context window") && hasExceeded {
return true
}
if strings.Contains(lower, "context length") && hasExceeded {
return true
}
return strings.Contains(lower, "token limit") &&
strings.Contains(lower, "context") &&
hasExceeded
}
if match(upstreamMsg) {
return true
}
if len(upstreamBody) == 0 {
return false
}
for _, path := range []string{
"error.message",
"response.error.message",
"message",
"error.code",
"response.error.code",
"code",
} {
if match(gjson.GetBytes(upstreamBody, path).String()) {
return true
}
}
return match(string(upstreamBody))
}
func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
switch statusCode {
case 401, 402, 403, 405, 429, 529:
return true
default:
return statusCode >= 500
}
}
func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
return false
}
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
return true
}
if s.shouldFailoverUpstreamError(statusCode) {
return true
}
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
}
// OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used
// after all account-specific request body limit failovers are exhausted.
const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large"
const openAIRequestBodyTooLargeReason = GatewayFailureReason("openai_request_body_too_large")
func isOpenAIRequestBodyTooLargeError(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
return statusCode == http.StatusRequestEntityTooLarge && !isOpenAIContextWindowError(upstreamMsg, upstreamBody)
}
func newOpenAIUpstreamFailoverError(
statusCode int,
responseHeaders http.Header,
responseBody []byte,
upstreamMsg string,
retryableOnSameAccount bool,
) *UpstreamFailoverError {
requestScopedCapacity := isOpenAIRequestScopedCapacityShed(upstreamMsg, responseBody)
failoverErr := &UpstreamFailoverError{
StatusCode: statusCode,
ResponseBody: responseBody,
ResponseHeaders: responseHeaders.Clone(),
RetryableOnSameAccount: retryableOnSameAccount || requestScopedCapacity,
RequestScopedTransient: requestScopedCapacity,
}
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) {
failoverErr.RetryableOnSameAccount = false
failoverErr.RequestScopedTransient = false
failoverErr.Scope = GatewayFailureScopeAccount
failoverErr.Reason = openAIRequestBodyTooLargeReason
failoverErr.NextAccountAction = NextAccountRetry
failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
}
return failoverErr
}
// IsOpenAIRequestBodyTooLarge reports whether another account may accept the
// same request even though the selected account rejected its serialized size.
func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
return e != nil && e.Reason == openAIRequestBodyTooLargeReason
}
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 {
limit := openAIUpstreamErrorBodyReadLimit
if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) {
limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
}
return limit
}
func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte {
if resp == nil || resp.Body == nil {
return nil
}
cfg := (*config.Config)(nil)
if s != nil {
cfg = s.cfg
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg)))
return body
}
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, canonicalModel ...string) bool {
if len(canonicalModel) > 0 {
return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, canonicalModel[0])
}
return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody)
}
func (s *OpenAIGatewayService) handleErrorResponse(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
requestBody []byte,
requestedModel ...string,
) (*OpenAIForwardResult, error) {
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
// cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。
// 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover
// shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。
if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: cyberMsg,
Body: truncateString(string(body), 4096),
UpstreamStatus: resp.StatusCode,
})
setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/json"
}
c.Data(resp.StatusCode, contentType, body)
if cyberMsg == "" {
return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
}
return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
}
if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
clientMsg := grokContentPolicyClientMessage(body)
setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
MarkResponseCommitted(c)
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"type": "invalid_request_error",
"message": clientMsg,
},
})
return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
}
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
if maxBytes <= 0 {
maxBytes = 2048
}
upstreamDetail = truncateString(string(body), maxBytes)
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
logger.LegacyPrintf("service.openai_gateway",
"OpenAI upstream error %d (account=%d platform=%s type=%s): %s",
resp.StatusCode,
account.ID,
account.Platform,
account.Type,
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
)
}
if isOpenAIRequestBodyTooLargeError(resp.StatusCode, upstreamMsg, body) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "failover",
Message: upstreamMsg,
Detail: upstreamDetail,
})
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel...)
return nil, newOpenAIUpstreamFailoverError(
resp.StatusCode,
resp.Header,
body,
upstreamMsg,
false,
)
}
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c,
PlatformOpenAI,
resp.StatusCode,
body,
http.StatusBadGateway,
"upstream_error",
"Upstream request failed",
); matched {
MarkResponseCommitted(c)
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
if upstreamMsg == "" {
upstreamMsg = errMsg
}
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
}
// Check custom error codes
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "http_error",
Message: upstreamMsg,
Detail: upstreamDetail,
})
MarkResponseCommitted(c)
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"type": "upstream_error",
"message": "Upstream gateway error",
},
})
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
}
// Handle upstream error (mark account status)
var reqModel string
if len(requestedModel) > 0 {
reqModel = strings.TrimSpace(requestedModel[0])
}
if reqModel == "" {
reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody)
reqModel = canonicalOpenAIAccountSchedulingModel(account, reqModel)
}
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
kind := "http_error"
if shouldDisable {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: kind,
Message: upstreamMsg,
Detail: upstreamDetail,
})
if shouldDisable {
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
RetryableOnSameAccount: false,
}
}
MarkResponseCommitted(c)
// 上游 400 是确定性的请求错误:同一份请求体换账号、重试多少次都会失败。归一成
// 502 upstream_error 会让下游网关把它当成可重试的上游故障反复重放(#5479 实测
// 30 个失败请求被放大成 60 次上游调用),同时抹掉客户端定位问题所需的 code/param。
//
// 走到这里说明 shouldFailoverOpenAIUpstreamResponse 已判定该 400 不可 failover
// 即 server_is_overloaded / at capacity 这类可重试的 400 不会到达此处。
//
// 兄弟路径早已这么做:handleCompatErrorResponseChatCompletions / Anthropic
// 回真实状态码 + invalid_request_error + 真实 message/v1/images 还额外透传
// code/param。原生 Responses 是唯一漏掉的一条。
if isOpenAIDeterministicClientError(resp.StatusCode) {
writeOpenAIUpstreamClientError(c, resp.StatusCode, body, upstreamMsg)
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
}
// Return appropriate error response
var errType, errMsg string
var statusCode int
switch resp.StatusCode {
case 401:
statusCode = http.StatusBadGateway
errType = "upstream_error"
errMsg = "Upstream authentication failed, please contact administrator"
case 402:
statusCode = http.StatusBadGateway
errType = "upstream_error"
errMsg = "Upstream payment required: insufficient balance or billing issue"
case 403:
statusCode = http.StatusBadGateway
errType = "upstream_error"
errMsg = "Upstream access forbidden, please contact administrator"
case 429:
statusCode = http.StatusTooManyRequests
errType = "rate_limit_error"
errMsg = "Upstream rate limit exceeded, please retry later"
default:
statusCode = http.StatusBadGateway
errType = "upstream_error"
errMsg = "Upstream request failed"
}
if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
errMsg = upstreamMsg
}
c.JSON(statusCode, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
}
// compatErrorWriter is the signature for format-specific error writers used by
// the compat paths (Chat Completions and Anthropic Messages).
type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string)
// handleCompatErrorResponse is the shared non-failover error handler for the
// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of
// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit
// tracking, secondary failover) but delegates the final error write to the
// format-specific writer function.
func (s *OpenAIGatewayService) handleCompatErrorResponse(
resp *http.Response,
c *gin.Context,
account *Account,
writeError compatErrorWriter,
requestedModel ...string,
) (*OpenAIForwardResult, error) {
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(context.Background(), account, body)
// cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误,
// 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络
// 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方
// handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: cyberMsg,
Body: truncateString(string(body), 4096),
UpstreamStatus: resp.StatusCode,
})
setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
clientMsg := cyberMsg
if clientMsg == "" {
clientMsg = "Request blocked by upstream cyber-security policy"
}
writeError(c, resp.StatusCode, "invalid_request_error", clientMsg)
if cyberMsg == "" {
return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
}
return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
}
if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
clientMsg := grokContentPolicyClientMessage(body)
setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
MarkResponseCommitted(c)
writeError(c, http.StatusForbidden, "invalid_request_error", clientMsg)
return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
}
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
if upstreamMsg == "" {
upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode)
}
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
if maxBytes <= 0 {
maxBytes = 2048
}
upstreamDetail = truncateString(string(body), maxBytes)
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
// Apply error passthrough rules
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c, account.Platform, resp.StatusCode, body,
http.StatusBadGateway, "api_error", "Upstream request failed",
); matched {
MarkResponseCommitted(c)
writeError(c, status, errType, errMsg)
if upstreamMsg == "" {
upstreamMsg = errMsg
}
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
}
// Check custom error codes — if the account does not handle this status,
// return a generic error without exposing upstream details.
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "http_error",
Message: upstreamMsg,
Detail: upstreamDetail,
})
MarkResponseCommitted(c)
writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error")
if upstreamMsg == "" {
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
}
return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
}
// Track rate limits and decide whether to trigger secondary failover.
var modelForCooldown string
if len(requestedModel) > 0 {
modelForCooldown = requestedModel[0]
}
shouldDisable := s.handleOpenAIAccountUpstreamError(
c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown,
)
kind := "http_error"
if shouldDisable {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: kind,
Message: upstreamMsg,
Detail: upstreamDetail,
})
if shouldDisable {
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
RetryableOnSameAccount: false,
}
}
MarkResponseCommitted(c)
// Map status code to error type and write response
errType := "api_error"
switch {
case resp.StatusCode == 400:
errType = "invalid_request_error"
case resp.StatusCode == 404:
errType = "not_found_error"
case resp.StatusCode == 429:
errType = "rate_limit_error"
case resp.StatusCode >= 500:
errType = "api_error"
}
writeError(c, resp.StatusCode, errType, upstreamMsg)
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
}