Files
sub2api/backend/internal/service/openai_gateway_upstream_errors.go
T

727 lines
24 KiB
Go
Raw Normal View History

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)
}