mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3009 from liulinhuai/fix/images-upstream-error-passthrough
fix(images): surface real upstream error instead of generic 502
This commit is contained in:
@@ -645,7 +645,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody)
|
||||
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
|
||||
@@ -579,6 +579,176 @@ func openAIImagesUpstreamErrorFromGJSON(errorObj gjson.Result, upstreamRequestID
|
||||
}
|
||||
}
|
||||
|
||||
// openAIImagesErrorTypeForStatus returns an OpenAI-style error type when the
|
||||
// upstream body does not provide one of its own.
|
||||
func openAIImagesErrorTypeForStatus(status int) string {
|
||||
switch {
|
||||
case status == http.StatusBadRequest:
|
||||
return "invalid_request_error"
|
||||
case status == http.StatusUnauthorized:
|
||||
return "authentication_error"
|
||||
case status == http.StatusForbidden:
|
||||
return "permission_error"
|
||||
case status == http.StatusNotFound:
|
||||
return "not_found_error"
|
||||
case status == http.StatusTooManyRequests:
|
||||
return "rate_limit_error"
|
||||
case status >= 500:
|
||||
return "api_error"
|
||||
default:
|
||||
return "upstream_error"
|
||||
}
|
||||
}
|
||||
|
||||
// openAIImagesUpstreamErrorFromHTTP builds an OpenAIImagesUpstreamError from a
|
||||
// non-2xx upstream HTTP response, preserving the real status code, type, code,
|
||||
// message and param so the client sees the actual upstream error instead of a
|
||||
// generic 502.
|
||||
func openAIImagesUpstreamErrorFromHTTP(statusCode int, header http.Header, body []byte) *OpenAIImagesUpstreamError {
|
||||
errType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String())
|
||||
code := strings.TrimSpace(extractUpstreamErrorCode(body))
|
||||
param := strings.TrimSpace(gjson.GetBytes(body, "error.param").String())
|
||||
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Upstream request failed (status %d)", statusCode)
|
||||
}
|
||||
if errType == "" {
|
||||
errType = openAIImagesErrorTypeForStatus(statusCode)
|
||||
}
|
||||
requestID := ""
|
||||
if header != nil {
|
||||
requestID = strings.TrimSpace(header.Get("x-request-id"))
|
||||
}
|
||||
return &OpenAIImagesUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
ErrorType: errType,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Param: param,
|
||||
UpstreamRequestID: requestID,
|
||||
}
|
||||
}
|
||||
|
||||
// handleOpenAIImagesErrorResponse is the non-failover error handler for the
|
||||
// images endpoints (/v1/images/generations and /v1/images/edits). Unlike the
|
||||
// generic handleErrorResponse — which collapses every non-failover upstream
|
||||
// error into a generic 502 "Upstream request failed" — it surfaces the real
|
||||
// upstream status code and error message/type/code/param to the client. This
|
||||
// mirrors how the Chat Completions and Messages compat paths use
|
||||
// handleCompatErrorResponse.
|
||||
//
|
||||
// It returns an *OpenAIImagesUpstreamError (already written to the client) so
|
||||
// the images handler treats it as a terminal user-facing error rather than
|
||||
// re-writing a fallback response.
|
||||
func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
|
||||
ctx context.Context,
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
requestedModel ...string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
body := s.readUpstreamErrorBody(resp)
|
||||
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
|
||||
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)
|
||||
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
logger.LegacyPrintf("service.openai_gateway",
|
||||
"OpenAI images upstream error %d (account=%d platform=%s type=%s): %s",
|
||||
resp.StatusCode,
|
||||
account.ID,
|
||||
account.Platform,
|
||||
account.Type,
|
||||
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||
)
|
||||
}
|
||||
|
||||
// Honor admin-configured error passthrough rules first.
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
account.Platform,
|
||||
resp.StatusCode,
|
||||
body,
|
||||
http.StatusBadGateway,
|
||||
"upstream_error",
|
||||
"Upstream request failed",
|
||||
); matched {
|
||||
upErr := &OpenAIImagesUpstreamError{
|
||||
StatusCode: status,
|
||||
ErrorType: errType,
|
||||
Message: errMsg,
|
||||
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
|
||||
}
|
||||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||||
return nil, upErr
|
||||
}
|
||||
|
||||
// If the account is not configured to handle this status code, fall back to
|
||||
// a generic gateway error without exposing upstream internals (mirrors
|
||||
// handleCompatErrorResponse).
|
||||
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,
|
||||
})
|
||||
upErr := &OpenAIImagesUpstreamError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
ErrorType: "upstream_error",
|
||||
Message: "Upstream gateway error",
|
||||
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
|
||||
}
|
||||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||||
return nil, upErr
|
||||
}
|
||||
|
||||
// Track rate limits / decide whether to disable the account (secondary failover).
|
||||
var modelForCooldown string
|
||||
if len(requestedModel) > 0 {
|
||||
modelForCooldown = strings.TrimSpace(requestedModel[0])
|
||||
}
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, 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: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the real upstream error to the client.
|
||||
upErr := openAIImagesUpstreamErrorFromHTTP(resp.StatusCode, resp.Header, body)
|
||||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||||
return nil, upErr
|
||||
}
|
||||
|
||||
func buildOpenAIImagesAPIResponse(
|
||||
results []openAIResponsesImageResult,
|
||||
createdAt int64,
|
||||
@@ -1195,7 +1365,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody)
|
||||
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
|
||||
@@ -656,6 +656,64 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
require.Equal(t, "draw a cat 3", gjson.Get(rec.Body.String(), "data.2.revised_prompt").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
|
||||
// The non-failover upstream error path is shared by /generations and /edits;
|
||||
// use /generations here so the request parses without an uploaded image.
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("api_key", &APIKey{ID: 42})
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc.httpUpstream = &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"req_img_badreq"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":{"message":"Invalid value for 'size': expected one of 1024x1024, 1536x1024.","type":"invalid_request_error","param":"size","code":"unknown_parameter"}}`,
|
||||
)),
|
||||
},
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.Nil(t, result)
|
||||
|
||||
var upstreamErr *OpenAIImagesUpstreamError
|
||||
require.ErrorAs(t, err, &upstreamErr)
|
||||
require.Equal(t, http.StatusBadRequest, upstreamErr.StatusCode)
|
||||
require.Equal(t, "invalid_request_error", upstreamErr.ErrorType)
|
||||
require.Equal(t, "unknown_parameter", upstreamErr.Code)
|
||||
|
||||
// The client must receive the actual upstream status code and message instead
|
||||
// of a generic 502 "Upstream request failed".
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String())
|
||||
require.Equal(t, "unknown_parameter", gjson.Get(rec.Body.String(), "error.code").String())
|
||||
require.Equal(t, "size", gjson.Get(rec.Body.String(), "error.param").String())
|
||||
require.Contains(t, gjson.Get(rec.Body.String(), "error.message").String(), "Invalid value for 'size'")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturnsClientError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw blocked image","response_format":"b64_json"}`)
|
||||
|
||||
Reference in New Issue
Block a user