fix(images): surface real upstream error instead of generic 502

The /v1/images/generations and /v1/images/edits paths routed every
non-failover upstream error through the generic handleErrorResponse,
whose final switch collapses anything that isn't 401/402/403/429 into a
hardcoded 502 "Upstream request failed" — discarding the actual upstream
status code, type, code, message, and param. So a gpt-image-2 400
(invalid_request_error, moderation, unsupported parameter, ...) reached
the client as an opaque 502.

The sibling Chat Completions and Messages compat paths already avoid this
via handleCompatErrorResponse, which preserves the real status and
message. Add the equivalent for images: handleOpenAIImagesErrorResponse
keeps all existing side-effects (ops logging, error-passthrough rules,
ShouldHandleErrorCode, account-disable/secondary-failover) but surfaces
the real upstream status + type/code/message/param by reusing the
existing OpenAIImagesUpstreamError machinery. Both forward paths now call
it instead of handleErrorResponse for non-failover errors.

Failover behavior (5xx / 401 / 403 / 429 / 529) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Liu Linhuai
2026-06-03 17:23:13 +08:00
co-authored by Claude Opus 4.8
parent aa69e3947d
commit 381d1d6d6c
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"}`)