mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-22 06:40:21 +08:00
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:
co-authored by
Claude Opus 4.8
parent
aa69e3947d
commit
381d1d6d6c
@@ -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