| package util |
|
|
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "testing" |
| ) |
|
|
| func assertClassifyError(t *testing.T, err error, wantStatus int, wantLevel ErrorLevel, wantRetry bool, reason string) { |
| t.Helper() |
|
|
| statusCode, errorLevel, shouldRetry := ClassifyError(err) |
| if statusCode != wantStatus { |
| t.Errorf("状态码错误: 期望 %d, 实际 %d (%s)", wantStatus, statusCode, reason) |
| } |
| if errorLevel != wantLevel { |
| t.Errorf("错误级别错误: 期望 %v, 实际 %v (%s)", wantLevel, errorLevel, reason) |
| } |
| if shouldRetry != wantRetry { |
| t.Errorf("重试标志错误: 期望 %v, 实际 %v (%s)", wantRetry, shouldRetry, reason) |
| } |
|
|
| } |
|
|
| func TestClassifyHTTPResponse(t *testing.T) { |
| tests := []struct { |
| name string |
| statusCode int |
| responseBody []byte |
| expected ErrorLevel |
| reason string |
| }{ |
| |
| { |
| name: "401_quota_exhausted_chinese", |
| statusCode: 401, |
| responseBody: []byte(`{"error":{"message":"该令牌额度已用尽"}}`), |
| expected: ErrorLevelKey, |
| reason: "额度用尽可能只是单个Key问题,应先尝试其他Key", |
| }, |
| { |
| name: "401_insufficient_quota", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"insufficient_quota"}`), |
| expected: ErrorLevelKey, |
| reason: "insufficient_quota可能只是单个Key问题,应先尝试其他Key", |
| }, |
| { |
| name: "401_balance_insufficient", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"balance insufficient"}`), |
| expected: ErrorLevelKey, |
| reason: "余额不足可能只是单个Key问题,应先尝试其他Key", |
| }, |
|
|
| |
| { |
| name: "401_account_suspended", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"account suspended"}`), |
| expected: ErrorLevelChannel, |
| reason: "账户暂停是账户级不可逆错误,应冷却整个渠道", |
| }, |
| { |
| name: "401_account_disabled", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"account disabled"}`), |
| expected: ErrorLevelChannel, |
| reason: "账户禁用是账户级不可逆错误,应冷却整个渠道", |
| }, |
|
|
| |
| { |
| name: "401_invalid_authentication", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"invalid_authentication"}`), |
| expected: ErrorLevelKey, |
| reason: "认证失败应该仅冷却当前Key", |
| }, |
| { |
| name: "401_token_expired", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"token expired"}`), |
| expected: ErrorLevelKey, |
| reason: "Token过期应该仅冷却当前Key(与account expired区分)", |
| }, |
| { |
| name: "401_unauthorized", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"unauthorized"}`), |
| expected: ErrorLevelKey, |
| reason: "未授权应该仅冷却当前Key", |
| }, |
| { |
| name: "401_empty_body", |
| statusCode: 401, |
| responseBody: []byte{}, |
| expected: ErrorLevelKey, |
| reason: "无响应体默认为Key级错误", |
| }, |
|
|
| |
| { |
| name: "403_quota_exceeded_openai_style", |
| statusCode: 403, |
| responseBody: []byte(`{"error":{"type":"quota_exceeded","message":"Daily cost limit reached ($1)"},"details":{"dailyLimit":1,"currentUsage":1.09655195,"remaining":0}}`), |
| expected: ErrorLevelKey, |
| reason: "quota_exceeded可能只是单个Key问题,应先尝试其他Key", |
| }, |
| { |
| name: "403_daily_limit_reached", |
| statusCode: 403, |
| responseBody: []byte(`{"error":"Daily cost limit reached ($10)"}`), |
| expected: ErrorLevelKey, |
| reason: "Daily limit可能只是单个Key问题,应先尝试其他Key", |
| }, |
| { |
| name: "403_remaining_zero", |
| statusCode: 403, |
| responseBody: []byte(`{"details":{"remaining":0}}`), |
| expected: ErrorLevelKey, |
| reason: "remaining:0可能只是单个Key问题,应先尝试其他Key", |
| }, |
| { |
| name: "403_quota_chinese", |
| statusCode: 403, |
| responseBody: []byte(`{"error":"余额不足,请充值"}`), |
| expected: ErrorLevelKey, |
| reason: "余额不足可能只是单个Key问题,应先尝试其他Key", |
| }, |
|
|
| |
| { |
| name: "403_service_disabled", |
| statusCode: 403, |
| responseBody: []byte(`{"error":"service disabled"}`), |
| expected: ErrorLevelChannel, |
| reason: "服务禁用是账户级不可逆错误,应冷却整个渠道", |
| }, |
|
|
| |
| { |
| name: "403_permission_denied", |
| statusCode: 403, |
| responseBody: []byte(`{"error":"permission denied"}`), |
| expected: ErrorLevelKey, |
| reason: "403 + permission denied应该仅冷却当前Key", |
| }, |
| { |
| name: "403_forbidden", |
| statusCode: 403, |
| responseBody: []byte(`{"error":"forbidden"}`), |
| expected: ErrorLevelKey, |
| reason: "403 + forbidden应该仅冷却当前Key", |
| }, |
| { |
| name: "403_empty_body", |
| statusCode: 403, |
| responseBody: []byte{}, |
| expected: ErrorLevelKey, |
| reason: "403无响应体默认为Key级错误", |
| }, |
|
|
| |
| { |
| name: "404_not_found", |
| statusCode: 404, |
| responseBody: []byte(`{"error":"not found"}`), |
| expected: ErrorLevelChannel, |
| reason: "404非model_not_found应返回渠道级错误以触发切换", |
| }, |
| { |
| name: "500_internal_error", |
| statusCode: 500, |
| responseBody: []byte(`{"error":"internal server error"}`), |
| expected: ErrorLevelChannel, |
| reason: "500应该冷却渠道", |
| }, |
| { |
| name: "429_rate_limit", |
| statusCode: 429, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelKey, |
| reason: "429应该冷却Key", |
| }, |
|
|
| |
| { |
| name: "401_case_insensitive", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"Account SUSPENDED"}`), |
| expected: ErrorLevelChannel, |
| reason: "大小写不敏感匹配(account suspended)", |
| }, |
| { |
| name: "401_mixed_case", |
| statusCode: 401, |
| responseBody: []byte(`{"error":"Account Disabled"}`), |
| expected: ErrorLevelChannel, |
| reason: "混合大小写应该正确识别(account disabled)", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| result := ClassifyHTTPResponseWithMeta(tt.statusCode, nil, tt.responseBody).Level |
| if result != tt.expected { |
| t.Errorf("%s\n 期望: %v\n 实际: %v\n 原因: %s\n 响应体: %s", |
| tt.name, tt.expected, result, tt.reason, string(tt.responseBody)) |
| } |
| }) |
| } |
| } |
|
|
| func TestClassifyHTTPStatus(t *testing.T) { |
| tests := []struct { |
| statusCode int |
| expected ErrorLevel |
| reason string |
| }{ |
| {200, ErrorLevelNone, "2xx成功"}, |
| {201, ErrorLevelNone, "2xx成功"}, |
| {402, ErrorLevelKey, "402 应视为Key级(额度/余额/配额)"}, |
| {401, ErrorLevelKey, "Key级错误(默认)"}, |
| {403, ErrorLevelKey, "Key级错误"}, |
| {429, ErrorLevelKey, "Key级限流"}, |
| |
| {499, ErrorLevelChannel, "499来自HTTP响应时,说明上游API返回,应重试其他渠道"}, |
| |
| {444, ErrorLevelChannel, "444 nginx No Response - 服务器主动关闭连接,渠道级错误"}, |
| {500, ErrorLevelChannel, "渠道级错误"}, |
| {502, ErrorLevelChannel, "渠道级错误"}, |
| {503, ErrorLevelChannel, "渠道级错误"}, |
| {504, ErrorLevelChannel, "渠道级错误"}, |
| {520, ErrorLevelChannel, "520 Web Server Returned an Unknown Error - 渠道级错误"}, |
| {521, ErrorLevelChannel, "521 Web Server Is Down - 渠道级错误"}, |
| {524, ErrorLevelChannel, "524 A Timeout Occurred - 渠道级错误"}, |
| |
| {418, ErrorLevelKey, "418 未知4xx - 兜底策略应为Key级冷却"}, |
| {451, ErrorLevelKey, "451 未知4xx - 兜底策略应为Key级冷却"}, |
| {599, ErrorLevelChannel, "599 未知5xx - 兜底策略应为Channel级冷却"}, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(string(rune(tt.statusCode)), func(t *testing.T) { |
| result := ClassifyHTTPStatus(tt.statusCode) |
| if result != tt.expected { |
| t.Errorf("状态码 %d: 期望 %v, 实际 %v (%s)", tt.statusCode, tt.expected, result, tt.reason) |
| } |
| }) |
| } |
| } |
|
|
| |
| func TestClassifyError_ContextCanceled(t *testing.T) { |
| tests := []struct { |
| name string |
| err error |
| expectedStatus int |
| expectedLevel ErrorLevel |
| expectedRetry bool |
| reason string |
| }{ |
| { |
| name: "context_canceled_from_client", |
| err: context.Canceled, |
| expectedStatus: 499, |
| expectedLevel: ErrorLevelClient, |
| expectedRetry: false, |
| reason: "下游客户端取消请求(context.Canceled)应返回499+ErrorLevelClient,不重试", |
| }, |
| { |
| name: "context_deadline_exceeded", |
| err: context.DeadlineExceeded, |
| expectedStatus: 504, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "上游超时(context.DeadlineExceeded)应返回504+ErrorLevelChannel,可重试其他渠道", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| assertClassifyError(t, tt.err, tt.expectedStatus, tt.expectedLevel, tt.expectedRetry, tt.reason) |
| }) |
| } |
| } |
|
|
| |
| func TestClassifyError_EmptyResponse(t *testing.T) { |
| tests := []struct { |
| name string |
| err error |
| expectedStatus int |
| expectedLevel ErrorLevel |
| expectedRetry bool |
| reason string |
| }{ |
| { |
| name: "empty_response_200_with_content_length_0", |
| err: errors.New("upstream returned empty response (200 OK with Content-Length: 0)"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "200状态码但Content-Length=0应视为上游故障,触发渠道级重试", |
| }, |
| { |
| name: "empty_response_uppercase", |
| err: errors.New("Upstream Returned Empty Response (200 OK with Content-Length: 0)"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "大小写不敏感匹配", |
| }, |
| { |
| name: "empty_response_sentinel_without_content_length", |
| err: fmt.Errorf("response probe failed: %w", ErrUpstreamEmptyResponse), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "无Content-Length的200空体也应视为上游故障,不能依赖错误文案", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| assertClassifyError(t, tt.err, tt.expectedStatus, tt.expectedLevel, tt.expectedRetry, tt.reason) |
| }) |
| } |
| } |
|
|
| |
| func TestClassifyError_HTTP2StreamErrors(t *testing.T) { |
| tests := []struct { |
| name string |
| err error |
| expectedStatus int |
| expectedLevel ErrorLevel |
| expectedRetry bool |
| reason string |
| }{ |
| { |
| name: "http2_response_body_closed", |
| err: fmt.Errorf("http2: response body closed"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "上游服务器主动关闭HTTP/2流,应触发渠道级重试", |
| }, |
| { |
| name: "http2_stream_error_internal", |
| err: fmt.Errorf("stream error: stream ID 7; INTERNAL_ERROR"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "HTTP/2 RST_STREAM INTERNAL_ERROR,上游服务异常", |
| }, |
| { |
| name: "http2_stream_error_protocol", |
| err: fmt.Errorf("stream error: stream ID 3; PROTOCOL_ERROR"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "HTTP/2协议错误,应切换渠道重试", |
| }, |
| { |
| name: "http2_error_wrapped", |
| err: fmt.Errorf("failed to read response: http2: response body closed"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "包装后的HTTP/2错误也应正确识别", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| assertClassifyError(t, tt.err, tt.expectedStatus, tt.expectedLevel, tt.expectedRetry, tt.reason) |
| }) |
| } |
| } |
|
|
| func TestClassifyError_ConnectionResetAndBrokenPipe(t *testing.T) { |
| tests := []struct { |
| name string |
| err error |
| expectedStatus int |
| expectedLevel ErrorLevel |
| expectedRetry bool |
| reason string |
| }{ |
| { |
| name: "broken_pipe_client_closed", |
| err: errors.New("write: broken pipe"), |
| expectedStatus: 499, |
| expectedLevel: ErrorLevelClient, |
| expectedRetry: false, |
| reason: "broken pipe 基本是客户端断开,不应重试", |
| }, |
| { |
| name: "connection_reset_by_peer_upstream", |
| err: errors.New("read: connection reset by peer"), |
| expectedStatus: 502, |
| expectedLevel: ErrorLevelChannel, |
| expectedRetry: true, |
| reason: "connection reset by peer 通常是上游断开,应按 502 进入健康度统计并允许切换渠道重试", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| assertClassifyError(t, tt.err, tt.expectedStatus, tt.expectedLevel, tt.expectedRetry, tt.reason) |
| }) |
| } |
| } |
|
|
| |
| func TestClassifyRateLimitError(t *testing.T) { |
| tests := []struct { |
| name string |
| headers map[string][]string |
| responseBody []byte |
| expected ErrorLevel |
| reason string |
| }{ |
| |
| { |
| name: "retry_after_120_seconds", |
| headers: map[string][]string{ |
| "Retry-After": {"120"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "Retry-After > 60秒表示账户级或IP级限流,应冷却渠道", |
| }, |
| { |
| name: "retry_after_30_seconds", |
| headers: map[string][]string{ |
| "Retry-After": {"30"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelKey, |
| reason: "Retry-After ≤ 60秒表示Key级限流,应冷却Key", |
| }, |
| { |
| name: "retry_after_60_seconds_boundary", |
| headers: map[string][]string{ |
| "Retry-After": {"60"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelKey, |
| reason: "Retry-After = 60秒边界值,保守策略为Key级", |
| }, |
| { |
| name: "retry_after_http_date", |
| headers: map[string][]string{ |
| "Retry-After": {"Wed, 29 Oct 2025 12:00:00 GMT"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "HTTP日期格式通常表示长时间限流,应冷却渠道", |
| }, |
| { |
| name: "retry_after_invalid_format", |
| headers: map[string][]string{ |
| "Retry-After": {"invalid"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelKey, |
| reason: "无效的Retry-After格式应默认为Key级", |
| }, |
|
|
| |
| { |
| name: "scope_global", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"global"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "global scope表示全局限流,应冷却渠道", |
| }, |
| { |
| name: "scope_ip", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"ip"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "IP scope表示IP级限流,应冷却渠道", |
| }, |
| { |
| name: "scope_account", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"account"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "account scope表示账户级限流,应冷却渠道", |
| }, |
| { |
| name: "scope_user", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"user"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelKey, |
| reason: "user scope(非global/ip/account)应默认为Key级", |
| }, |
| { |
| name: "scope_case_insensitive", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"GLOBAL"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "scope匹配应不区分大小写", |
| }, |
|
|
| |
| { |
| name: "body_ip_rate_limit", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": { |
| "message": "IP rate limit exceeded", |
| "type": "rate_limit_error" |
| } |
| }`), |
| expected: ErrorLevelChannel, |
| reason: "响应体包含'ip rate limit'应冷却渠道", |
| }, |
| { |
| name: "body_account_rate_limit", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": { |
| "message": "Account rate limit exceeded for organization" |
| } |
| }`), |
| expected: ErrorLevelChannel, |
| reason: "响应体包含'account rate limit'应冷却渠道", |
| }, |
| { |
| name: "body_global_rate_limit", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": "Global rate limit has been exceeded" |
| }`), |
| expected: ErrorLevelChannel, |
| reason: "响应体包含'global rate limit'应冷却渠道", |
| }, |
| { |
| name: "body_organization_limit", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": "Organization limit reached" |
| }`), |
| expected: ErrorLevelChannel, |
| reason: "响应体包含'organization limit'应冷却渠道", |
| }, |
| { |
| name: "body_case_insensitive", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": "IP Rate Limit Exceeded" |
| }`), |
| expected: ErrorLevelChannel, |
| reason: "响应体匹配应不区分大小写", |
| }, |
|
|
| |
| { |
| name: "default_key_level_no_special_indicators", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{ |
| "error": "Too many requests" |
| }`), |
| expected: ErrorLevelKey, |
| reason: "无特殊指示器时应默认为Key级", |
| }, |
| { |
| name: "nil_headers", |
| headers: nil, |
| responseBody: []byte(`{"error":"rate limit"}`), |
| expected: ErrorLevelKey, |
| reason: "nil headers应默认为Key级", |
| }, |
| { |
| name: "empty_headers", |
| headers: map[string][]string{}, |
| responseBody: []byte(`{"error":"rate limit"}`), |
| expected: ErrorLevelKey, |
| reason: "空headers应默认为Key级", |
| }, |
| { |
| name: "empty_response_body", |
| headers: map[string][]string{}, |
| responseBody: []byte{}, |
| expected: ErrorLevelKey, |
| reason: "空响应体应默认为Key级", |
| }, |
| { |
| name: "nil_response_body", |
| headers: map[string][]string{}, |
| responseBody: nil, |
| expected: ErrorLevelKey, |
| reason: "nil响应体应默认为Key级", |
| }, |
|
|
| |
| { |
| name: "combined_retry_after_and_scope", |
| headers: map[string][]string{ |
| "Retry-After": {"30"}, |
| "X-Ratelimit-Scope": {"ip"}, |
| }, |
| responseBody: []byte(`{"error":"rate limit"}`), |
| expected: ErrorLevelChannel, |
| reason: "Retry-After检查优先于Scope,但>60秒判断优先", |
| }, |
| { |
| name: "combined_scope_and_body", |
| headers: map[string][]string{ |
| "X-Ratelimit-Scope": {"user"}, |
| }, |
| responseBody: []byte(`{"error":"IP rate limit exceeded"}`), |
| expected: ErrorLevelChannel, |
| reason: "响应体中的渠道级指示器应被识别", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| result := ClassifyHTTPResponseWithMeta(429, tt.headers, tt.responseBody).Level |
| if result != tt.expected { |
| t.Errorf("%s\n 期望: %v\n 实际: %v\n 原因: %s", |
| tt.name, tt.expected, result, tt.reason) |
| } |
| }) |
| } |
| } |
|
|
| |
| func TestClassifySSEError(t *testing.T) { |
| tests := []struct { |
| name string |
| responseBody []byte |
| expected ErrorLevel |
| reason string |
| }{ |
| { |
| name: "api_error_500", |
| responseBody: []byte(`{"type":"error","error":{"type":"api_error","message":"上游API返回错误: 500"}}`), |
| expected: ErrorLevelChannel, |
| reason: "api_error表示上游服务错误,应触发渠道级冷却", |
| }, |
| { |
| name: "overloaded_error", |
| responseBody: []byte(`{"type":"error","error":{"type":"overloaded_error","message":"服务过载"}}`), |
| expected: ErrorLevelChannel, |
| reason: "overloaded_error表示上游过载,应触发渠道级冷却", |
| }, |
| { |
| name: "service_unavailable_error_server_is_overloaded", |
| responseBody: []byte(`{"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later.","param":null},"sequence_number":2}`), |
| expected: ErrorLevelChannel, |
| reason: "service_unavailable_error/server_is_overloaded 表示上游服务过载,应触发渠道级冷却", |
| }, |
| { |
| name: "rate_limit_error", |
| responseBody: []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"请求过于频繁"}}`), |
| expected: ErrorLevelKey, |
| reason: "rate_limit_error可能只是单个Key限流,应触发Key级冷却", |
| }, |
| { |
| name: "authentication_error", |
| responseBody: []byte(`{"type":"error","error":{"type":"authentication_error","message":"认证失败"}}`), |
| expected: ErrorLevelKey, |
| reason: "authentication_error是Key级问题,应触发Key级冷却", |
| }, |
| { |
| name: "invalid_request_error", |
| responseBody: []byte(`{"type":"error","error":{"type":"invalid_request_error","message":"请求无效"}}`), |
| expected: ErrorLevelKey, |
| reason: "invalid_request_error是Key级问题,应触发Key级冷却", |
| }, |
| { |
| name: "1308_error", |
| responseBody: []byte(`{"type":"error","error":{"type":"1308","message":"已达到使用上限"}}`), |
| expected: ErrorLevelKey, |
| reason: "1308错误是Key配额问题,应触发Key级冷却", |
| }, |
| { |
| name: "1308_error_with_code_field", |
| responseBody: []byte(`{"error":{"code":"1308","message":"已达到 5 小时的使用上限。您的限额将在 2025-12-21 15:00:05 重置。"},"request_id":"202512211335142b05cc4f9bbb4e6c"}`), |
| expected: ErrorLevelKey, |
| reason: "使用code字段的1308错误(非Anthropic格式)应触发Key级冷却", |
| }, |
| { |
| name: "1305_overloaded", |
| responseBody: []byte(`{"error":{"code":"1305","message":"The service may be temporarily overloaded"},"request_id":"..."}`), |
| expected: ErrorLevelChannel, |
| reason: "1305错误表示上游过载,应触发渠道级冷却", |
| }, |
| { |
| name: "1310_limit_exhausted", |
| responseBody: []byte(`{"error":{"code":"1310","message":"Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-20 15:24:20"},"request_id":"..."}`), |
| expected: ErrorLevelKey, |
| reason: "1310错误是Key配额问题,应触发Key级冷却", |
| }, |
| { |
| name: "unknown_error_type", |
| responseBody: []byte(`{"type":"error","error":{"type":"unknown_type","message":"未知错误"}}`), |
| expected: ErrorLevelKey, |
| reason: "未知错误类型应保守处理为Key级", |
| }, |
| { |
| name: "empty_body", |
| responseBody: []byte{}, |
| expected: ErrorLevelKey, |
| reason: "空响应体应保守处理为Key级", |
| }, |
| { |
| name: "invalid_json", |
| responseBody: []byte(`not valid json`), |
| expected: ErrorLevelKey, |
| reason: "无效JSON应保守处理为Key级", |
| }, |
| { |
| name: "nil_body", |
| responseBody: nil, |
| expected: ErrorLevelKey, |
| reason: "nil响应体应保守处理为Key级", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| |
| result := ClassifyHTTPResponseWithMeta(StatusSSEError, nil, tt.responseBody).Level |
| if result != tt.expected { |
| t.Errorf("%s\n 期望: %v\n 实际: %v\n 原因: %s", |
| tt.name, tt.expected, result, tt.reason) |
| } |
| }) |
| } |
| } |
|
|
| func TestClassify400Error(t *testing.T) { |
| tests := []struct { |
| name string |
| responseBody []byte |
| expected ErrorLevel |
| reason string |
| }{ |
| { |
| name: "empty_body", |
| responseBody: []byte{}, |
| expected: ErrorLevelChannel, |
| reason: "空响应体应判定为上游异常", |
| }, |
| { |
| name: "nil_body", |
| responseBody: nil, |
| expected: ErrorLevelChannel, |
| reason: "nil响应体应判定为上游异常", |
| }, |
| { |
| name: "invalid_api_key", |
| responseBody: []byte(`{"error": {"message": "Invalid API Key provided"}}`), |
| expected: ErrorLevelKey, |
| reason: "包含 invalid_api_key 特征应判定为 Key 级错误", |
| }, |
| { |
| name: "api_key_error", |
| responseBody: []byte(`{"error": {"message": "The API key you provided is malformed"}}`), |
| expected: ErrorLevelKey, |
| reason: "包含 api key 特征应判定为 Key 级错误", |
| }, |
| { |
| name: "bad_request_params", |
| responseBody: []byte(`{"error": {"message": "Missing required parameter: 'model'"}}`), |
| expected: ErrorLevelChannel, |
| reason: "代理场景下400默认视为上游异常", |
| }, |
| { |
| name: "invalid_json_format", |
| responseBody: []byte(`{"error": {"message": "Invalid JSON format in request body"}}`), |
| expected: ErrorLevelChannel, |
| reason: "代理场景下400默认视为上游异常", |
| }, |
| { |
| name: "validation_error", |
| responseBody: []byte(`{"error": {"message": "Validation failed: max_tokens must be positive"}}`), |
| expected: ErrorLevelChannel, |
| reason: "代理场景下400默认视为上游异常", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| result := classify400Error(tt.responseBody) |
| if result != tt.expected { |
| t.Errorf("%s\n 期望: %v\n 实际: %v\n 原因: %s\n 响应体: %s", |
| tt.name, tt.expected, result, tt.reason, string(tt.responseBody)) |
| } |
| }) |
| } |
| } |
|
|
| func TestClassify404Error(t *testing.T) { |
| tests := []struct { |
| name string |
| responseBody []byte |
| expected ErrorLevel |
| reason string |
| }{ |
| { |
| name: "empty_body", |
| responseBody: []byte{}, |
| expected: ErrorLevelChannel, |
| reason: "空响应体应判定为渠道配置错误(路径不存在)", |
| }, |
| { |
| name: "nil_body", |
| responseBody: nil, |
| expected: ErrorLevelChannel, |
| reason: "nil响应体应判定为渠道配置错误(路径不存在)", |
| }, |
| { |
| name: "model_not_found", |
| responseBody: []byte(`{"error": {"message": "The model 'gpt-5' could not be found", "type": "model_not_found"}}`), |
| expected: ErrorLevelClient, |
| reason: "模型不存在应判定为客户端级错误", |
| }, |
| { |
| name: "resource_not_exist", |
| responseBody: []byte(`{"error": {"message": "The requested resource does not exist"}}`), |
| expected: ErrorLevelClient, |
| reason: "资源不存在应判定为客户端级错误", |
| }, |
| { |
| name: "html_error_page", |
| responseBody: []byte(`<!DOCTYPE html> |
| <html> |
| <head><title>404 Not Found</title></head> |
| <body><h1>404 Not Found</h1></body> |
| </html>`), |
| expected: ErrorLevelChannel, |
| reason: "HTML错误页面应判定为渠道级错误(BaseURL配置错误)", |
| }, |
| { |
| name: "html_lowercase", |
| responseBody: []byte(`<html><head><title>Not Found</title></head><body>404</body></html>`), |
| expected: ErrorLevelChannel, |
| reason: "HTML错误页面(小写)应判定为渠道级错误", |
| }, |
| { |
| name: "endpoint_not_found", |
| responseBody: []byte(`{"error": {"message": "Endpoint /v1/completions not found"}}`), |
| expected: ErrorLevelChannel, |
| reason: "端点不存在应判定为渠道级错误(标准路径返回404更可能是渠道配置问题)", |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| result := classify404Error(tt.responseBody) |
| if result != tt.expected { |
| t.Errorf("%s\n 期望: %v\n 实际: %v\n 原因: %s\n 响应体: %s", |
| tt.name, tt.expected, result, tt.reason, string(tt.responseBody)) |
| } |
| }) |
| } |
| } |
|
|
| func TestGetStatusCodeMeta(t *testing.T) { |
| t.Parallel() |
|
|
| tests := []struct { |
| status int |
| wantLevel ErrorLevel |
| description string |
| }{ |
| |
| {401, ErrorLevelKey, "401 -> Key级"}, |
| {402, ErrorLevelKey, "402 -> Key级"}, |
| {403, ErrorLevelKey, "403 -> Key级"}, |
| {429, ErrorLevelKey, "429 -> Key级"}, |
|
|
| |
| {499, ErrorLevelChannel, "499 -> 渠道级"}, |
| {500, ErrorLevelChannel, "500 -> 渠道级"}, |
| {502, ErrorLevelChannel, "502 -> 渠道级"}, |
| {503, ErrorLevelChannel, "503 -> 渠道级"}, |
| {504, ErrorLevelChannel, "504 -> 渠道级"}, |
| {408, ErrorLevelClient, "408 -> 客户端级 (RFC7231: 客户端发送请求慢)"}, |
| {405, ErrorLevelChannel, "405 -> 渠道级 (上游endpoint/方法不支持)"}, |
|
|
| |
| {StatusQuotaExceeded, ErrorLevelKey, "596 -> Key级"}, |
| {StatusSSEError, ErrorLevelKey, "597 -> Key级"}, |
| {StatusFirstByteTimeout, ErrorLevelChannel, "598 -> 渠道级"}, |
| {StatusStreamIncomplete, ErrorLevelChannel, "599 -> 渠道级"}, |
|
|
| |
| {406, ErrorLevelClient, "406 -> 客户端级"}, |
| {413, ErrorLevelClient, "413 -> 客户端级"}, |
|
|
| |
| {599, ErrorLevelChannel, "599 -> 渠道级(自定义)"}, |
| {418, ErrorLevelKey, "418 -> Key级(兜底策略:未知4xx)"}, |
| {511, ErrorLevelChannel, "511 -> 渠道级(默认5xx)"}, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.description, func(t *testing.T) { |
| meta := GetStatusCodeMeta(tt.status) |
| if meta.Level != tt.wantLevel { |
| t.Errorf("Level: got %v, want %v", meta.Level, tt.wantLevel) |
| } |
| }) |
| } |
| } |
|
|
| func TestClientStatusFor(t *testing.T) { |
| t.Parallel() |
|
|
| tests := []struct { |
| status int |
| expected int |
| desc string |
| }{ |
| {-1, 502, "负值 -> 502"}, |
| {0, 502, "零值 -> 502"}, |
| {StatusQuotaExceeded, 429, "596 -> 429"}, |
| {StatusSSEError, 502, "597 -> 502"}, |
| {StatusFirstByteTimeout, 504, "598 -> 504"}, |
| {StatusStreamIncomplete, 502, "599 -> 502"}, |
| {429, 429, "429 -> 透传"}, |
| {401, 401, "401 -> 401 (透明代理)"}, |
| {405, 405, "405 -> 405 (透明代理)"}, |
| {404, 404, "404 -> 404 (透明代理)"}, |
| {499, 499, "499 -> 499(语义由上层决定)"}, |
| {502, 502, "502 -> 502"}, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.desc, func(t *testing.T) { |
| if got := ClientStatusFor(tt.status); got != tt.expected { |
| t.Fatalf("ClientStatusFor(%d)=%d, expected %d", tt.status, got, tt.expected) |
| } |
| }) |
| } |
| } |
|
|
| |
|
|