fix(errors): 区分平台限流并保留上游状态码

原因:公开错误层将平台并发限流误标为上游限流,并把多种上游 4xx 统一压成 400,影响定位和客户端处理。

影响:新增公开错误 source,平台限流使用 gateway_rate_limited,上游请求按安全分类返回对应状态;数据库与管理端继续保留原始错误码、消息和状态用于审计。

验证:Go 全量测试、pnpm test、pnpm lint、pnpm build、pnpm openapi、gofmt 和 diff 检查均通过。
This commit is contained in:
2026-08-04 10:46:46 +08:00
parent 0f0998cbcf
commit fe56aa46b9
21 changed files with 289 additions and 22 deletions
@@ -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 {