fix(errors): 区分平台限流并保留上游状态码
原因:公开错误层将平台并发限流误标为上游限流,并把多种上游 4xx 统一压成 400,影响定位和客户端处理。 影响:新增公开错误 source,平台限流使用 gateway_rate_limited,上游请求按安全分类返回对应状态;数据库与管理端继续保留原始错误码、消息和状态用于审计。 验证:Go 全量测试、pnpm test、pnpm lint、pnpm build、pnpm openapi、gofmt 和 diff 检查均通过。
This commit is contained in:
@@ -15,6 +15,7 @@ var observed = struct {
|
||||
upstreamInvalidResponse atomic.Uint64
|
||||
upstreamAuthFailed atomic.Uint64
|
||||
upstreamRequestRejected atomic.Uint64
|
||||
gatewayRateLimited atomic.Uint64
|
||||
storageWriteFailed atomic.Uint64
|
||||
storageReadFailed atomic.Uint64
|
||||
resultExpired atomic.Uint64
|
||||
@@ -39,6 +40,10 @@ func Observe(value Error) {
|
||||
observed.upstreamAuthFailed.Add(1)
|
||||
case "upstream_request_rejected":
|
||||
observed.upstreamRequestRejected.Add(1)
|
||||
case "upstream_invalid_request", "upstream_not_found", "upstream_method_not_allowed", "upstream_conflict", "upstream_payload_too_large", "upstream_unsupported_media_type", "upstream_unprocessable_request":
|
||||
observed.upstreamRequestRejected.Add(1)
|
||||
case "gateway_rate_limited":
|
||||
observed.gatewayRateLimited.Add(1)
|
||||
case "storage_write_failed":
|
||||
observed.storageWriteFailed.Add(1)
|
||||
case "storage_read_failed":
|
||||
@@ -63,6 +68,7 @@ func MetricSnapshot() []MetricCount {
|
||||
{Code: "upstream_invalid_response", Count: observed.upstreamInvalidResponse.Load()},
|
||||
{Code: "upstream_auth_failed", Count: observed.upstreamAuthFailed.Load()},
|
||||
{Code: "upstream_request_rejected", Count: observed.upstreamRequestRejected.Load()},
|
||||
{Code: "gateway_rate_limited", Count: observed.gatewayRateLimited.Load()},
|
||||
{Code: "storage_write_failed", Count: observed.storageWriteFailed.Load()},
|
||||
{Code: "storage_read_failed", Count: observed.storageReadFailed.Load()},
|
||||
{Code: "result_expired", Count: observed.resultExpired.Load()},
|
||||
|
||||
@@ -2,6 +2,7 @@ package publicerror
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,6 +12,7 @@ type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Category string `json:"category"`
|
||||
Source string `json:"source"`
|
||||
HTTPStatus int `json:"httpStatus"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Action string `json:"action"`
|
||||
@@ -79,6 +81,8 @@ func mappedError(code string, message string, status int, retryable bool) (Error
|
||||
return newError("upstream_connection_interrupted", "The upstream connection was interrupted before a complete response was received.", "upstream", http.StatusBadGateway, true, "retry"), true
|
||||
case "upstream_timeout", "timeout", "context_deadline_exceeded":
|
||||
return newError("upstream_timeout", "The upstream service did not respond in time.", "upstream", http.StatusGatewayTimeout, true, "retry"), true
|
||||
case "gateway_rate_limited":
|
||||
return newError("gateway_rate_limited", "The gateway rate limit was reached.", "rate_limit", http.StatusTooManyRequests, true, "retry_after"), true
|
||||
case "rate_limit", "upstream_rate_limited", "too_many_requests":
|
||||
return newError("upstream_rate_limited", "The upstream service rate limit was reached.", "rate_limit", http.StatusTooManyRequests, true, "retry_after"), true
|
||||
case "upstream_unavailable", "upstream_overloaded", "service_unavailable", "bad_gateway", "server_error", "provider_failed":
|
||||
@@ -107,8 +111,11 @@ func mappedError(code string, message string, status int, retryable bool) (Error
|
||||
if (status == http.StatusUnauthorized || status == http.StatusForbidden) && (strings.HasPrefix(code, "http_") || strings.Contains(code, "provider")) {
|
||||
return newError("upstream_auth_failed", "The upstream service rejected the configured credentials.", "upstream", http.StatusBadGateway, false, "contact_support"), true
|
||||
}
|
||||
if strings.HasPrefix(code, "http_") && status >= 500 {
|
||||
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
|
||||
}
|
||||
if strings.HasPrefix(code, "http_") || (code == "provider_failed" && status > 0 && status < 500) {
|
||||
return newError("upstream_request_rejected", "The upstream service rejected the request.", "upstream", http.StatusBadRequest, false, "fix_request"), true
|
||||
return upstreamHTTPError(status), true
|
||||
}
|
||||
if status >= 500 && code != "upstream_submission_unknown" && (strings.Contains(code, "upstream") || strings.Contains(code, "provider")) {
|
||||
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
|
||||
@@ -142,7 +149,45 @@ func retryAction(retryable bool) string {
|
||||
}
|
||||
|
||||
func newError(code string, message string, category string, status int, retryable bool, action string) Error {
|
||||
return Error{Code: code, Message: message, Category: category, HTTPStatus: status, Retryable: retryable, Action: action, Version: Version}
|
||||
return Error{Code: code, Message: message, Category: category, Source: errorSource(code, category), HTTPStatus: status, Retryable: retryable, Action: action, Version: Version}
|
||||
}
|
||||
|
||||
func upstreamHTTPError(status int) Error {
|
||||
if status < 400 || status >= 500 {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return newError("upstream_invalid_request", "The upstream service rejected the request parameters.", "upstream", status, false, "fix_request")
|
||||
case http.StatusNotFound:
|
||||
return newError("upstream_not_found", "The upstream service could not find the requested resource.", "upstream", status, false, "contact_support")
|
||||
case http.StatusMethodNotAllowed:
|
||||
return newError("upstream_method_not_allowed", "The upstream service did not accept the configured request method.", "upstream", status, false, "contact_support")
|
||||
case http.StatusConflict:
|
||||
return newError("upstream_conflict", "The upstream service reported a request conflict.", "upstream", status, false, "fix_request")
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
return newError("upstream_payload_too_large", "The upstream service rejected the request because its payload was too large.", "upstream", status, false, "fix_request")
|
||||
case http.StatusUnsupportedMediaType:
|
||||
return newError("upstream_unsupported_media_type", "The upstream service rejected the request media type.", "upstream", status, false, "fix_request")
|
||||
case http.StatusUnprocessableEntity:
|
||||
return newError("upstream_unprocessable_request", "The upstream service could not process the request parameters.", "upstream", status, false, "fix_request")
|
||||
default:
|
||||
return newError("upstream_request_rejected", "The upstream service rejected the request.", "upstream", status, false, "fix_request")
|
||||
}
|
||||
}
|
||||
|
||||
func errorSource(code string, category string) string {
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
switch {
|
||||
case strings.HasPrefix(code, "upstream_"):
|
||||
return "upstream"
|
||||
case strings.HasPrefix(code, "gateway_"), category == "gateway", category == "storage":
|
||||
return "gateway"
|
||||
case category == "request":
|
||||
return "client"
|
||||
default:
|
||||
return "gateway"
|
||||
}
|
||||
}
|
||||
|
||||
func sensitiveTransportMessage(message string) bool {
|
||||
@@ -156,8 +201,13 @@ func sensitiveTransportMessage(message string) bool {
|
||||
}
|
||||
|
||||
func statusFromCode(code string) int {
|
||||
if strings.HasPrefix(code, "http_") {
|
||||
if status, err := strconv.Atoi(strings.TrimPrefix(code, "http_")); err == nil && status >= 400 && status <= 599 {
|
||||
return status
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case "rate_limit", "upstream_rate_limited", "too_many_requests":
|
||||
case "gateway_rate_limited", "rate_limit", "upstream_rate_limited", "too_many_requests":
|
||||
return http.StatusTooManyRequests
|
||||
case "upstream_timeout", "timeout", "context_deadline_exceeded":
|
||||
return http.StatusGatewayTimeout
|
||||
|
||||
@@ -44,7 +44,11 @@ func TestProviderHTTPErrorDoesNotExposeProviderBody(t *testing.T) {
|
||||
wantCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{code: "http_400", status: http.StatusBadRequest, wantCode: "upstream_request_rejected", wantStatus: http.StatusBadRequest},
|
||||
{code: "http_400", status: http.StatusBadRequest, wantCode: "upstream_invalid_request", wantStatus: http.StatusBadRequest},
|
||||
{code: "http_404", status: http.StatusNotFound, wantCode: "upstream_not_found", wantStatus: http.StatusNotFound},
|
||||
{code: "http_405", status: http.StatusMethodNotAllowed, wantCode: "upstream_method_not_allowed", wantStatus: http.StatusMethodNotAllowed},
|
||||
{code: "http_413", status: http.StatusRequestEntityTooLarge, wantCode: "upstream_payload_too_large", wantStatus: http.StatusRequestEntityTooLarge},
|
||||
{code: "http_422", status: http.StatusUnprocessableEntity, wantCode: "upstream_unprocessable_request", wantStatus: http.StatusUnprocessableEntity},
|
||||
{code: "auth_failed", status: http.StatusUnauthorized, wantCode: "upstream_auth_failed", wantStatus: http.StatusBadGateway},
|
||||
{code: "provider_failed", status: http.StatusForbidden, wantCode: "upstream_auth_failed", wantStatus: http.StatusBadGateway},
|
||||
{code: "provider_failed", status: http.StatusTooManyRequests, wantCode: "upstream_rate_limited", wantStatus: http.StatusTooManyRequests},
|
||||
@@ -55,12 +59,40 @@ func TestProviderHTTPErrorDoesNotExposeProviderBody(t *testing.T) {
|
||||
if got.Code != test.wantCode || got.HTTPStatus != test.wantStatus {
|
||||
t.Fatalf("%s: unexpected public error: %+v", test.code, got)
|
||||
}
|
||||
if got.Source != "upstream" {
|
||||
t.Fatalf("%s: source = %q, want upstream", test.code, got.Source)
|
||||
}
|
||||
if strings.Contains(got.Message, "private-a") || strings.Contains(got.Message, "secret-project") {
|
||||
t.Fatalf("%s: provider body leaked: %+v", test.code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRateLimitIsDistinctFromUpstreamRateLimit(t *testing.T) {
|
||||
gateway := FromFields("gateway_rate_limited", "concurrency limit is saturated and queueing is disabled", http.StatusTooManyRequests, true)
|
||||
if gateway.Code != "gateway_rate_limited" || gateway.Source != "gateway" || gateway.HTTPStatus != http.StatusTooManyRequests {
|
||||
t.Fatalf("unexpected gateway rate limit: %+v", gateway)
|
||||
}
|
||||
if strings.Contains(gateway.Message, "concurrency") {
|
||||
t.Fatalf("gateway rate limit leaked internal details: %+v", gateway)
|
||||
}
|
||||
|
||||
upstream := FromFields("rate_limit", "provider quota exhausted for private account", http.StatusTooManyRequests, true)
|
||||
if upstream.Code != "upstream_rate_limited" || upstream.Source != "upstream" || upstream.HTTPStatus != http.StatusTooManyRequests {
|
||||
t.Fatalf("unexpected upstream rate limit: %+v", upstream)
|
||||
}
|
||||
if upstream.Message == "provider quota exhausted for private account" {
|
||||
t.Fatalf("upstream rate limit leaked provider details: %+v", upstream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPCodeDerivesOriginalUpstreamStatusWhenStatusIsMissing(t *testing.T) {
|
||||
got := FromFields("http_404", "404 page not found", 0, false)
|
||||
if got.Code != "upstream_not_found" || got.Source != "upstream" || got.HTTPStatus != http.StatusNotFound {
|
||||
t.Fatalf("unexpected derived upstream error: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationGateKeepsStablePublicCode(t *testing.T) {
|
||||
got := FromFields("validation_in_progress", "new production tasks are paused while validation is running", http.StatusServiceUnavailable, true)
|
||||
if got.Code != "validation_in_progress" || got.HTTPStatus != http.StatusServiceUnavailable || !got.Retryable {
|
||||
|
||||
Reference in New Issue
Block a user