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:
Wesley Liddick
2026-06-06 11:07:53 +08:00
committed by GitHub
3 changed files with 230 additions and 2 deletions
+1 -1
View File
@@ -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"}`)