ccpoad / internal /util /classifier.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
26.3 kB
package util
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"ccLoad/internal/protocol"
)
// HTTP状态码错误分类器
// 设计原则:区分Key级错误和渠道级错误,避免误判导致多Key功能失效
// ErrUpstreamFirstByteTimeout 是上游首字节超时的统一错误标识,避免依赖具体报错文案。
var ErrUpstreamFirstByteTimeout = errors.New("upstream first byte timeout")
// ErrUpstreamEmptyResponse 是上游 200 但无响应体的统一错误标识。
var ErrUpstreamEmptyResponse = errors.New("upstream returned empty response")
// resetTime1308Regex 匹配1308错误 message 中的重置时间(不依赖具体语言文案)
// 格式示例: 2025-12-09 18:08:11
var resetTime1308Regex = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
// beijingTomorrowResetRegex 匹配类似“明天凌晨3点13分(北京时间)恢复”的相对重置时间。
var beijingTomorrowResetRegex = regexp.MustCompile(`明天\s*(?:凌晨|早上|上午)?\s*(\d{1,2})\s*点\s*(?:(\d{1,2})\s*分)?`)
// HTTP 状态码常量(统一定义,避免魔法数字)
const (
// StatusClientClosedRequest 客户端取消请求(Nginx扩展状态码)
// 来源:(1) context.Canceled → 不重试 (2) 上游返回499 → 重试其他渠道
StatusClientClosedRequest = 499
// StatusQuotaExceeded 1308配额超限(自定义状态码)
// 即使HTTP状态码为200,但响应体为1308错误。需从成功率计算中排除
StatusQuotaExceeded = 596
// StatusSSEError SSE流中检测到error事件(自定义状态码)
// HTTP状态码200但流中包含错误,如其他类型的API错误
StatusSSEError = 597
// StatusFirstByteTimeout 上游首字节超时(自定义状态码,触发渠道级冷却)
StatusFirstByteTimeout = 598
// StatusStreamIncomplete 流式响应不完整(自定义状态码)
// 触发条件:流正常结束但没有usage数据,或流传输中断
StatusStreamIncomplete = 599
)
// Rate Limit 相关常量
const (
// RetryAfterThresholdSeconds Retry-After超过此值视为渠道级限流
RetryAfterThresholdSeconds = 60
// RateLimitScope 常量
RateLimitScopeGlobal = "global"
RateLimitScopeIP = "ip"
RateLimitScopeAccount = "account"
)
// ErrorLevel 表示错误的严重级别。
type ErrorLevel int
const (
// ErrorLevelNone 无错误(2xx成功)
ErrorLevelNone ErrorLevel = iota
// ErrorLevelKey Key级错误:应该冷却当前Key,重试其他Key
ErrorLevelKey
// ErrorLevelChannel 渠道级错误:应该冷却整个渠道,切换到其他渠道
ErrorLevelChannel
// ErrorLevelClient 客户端错误:不应该冷却,直接返回给客户端
ErrorLevelClient
)
// StatusCodeMeta 状态码元数据(统一定义错误级别)
// 设计原则:单一数据源,消除 proxy_handler.go / classifier.go 分散的状态码分类逻辑。
//
// 注意:对外状态码映射不应该掺进这个表里,否则很快就会变成另一份“半套规则”。
type StatusCodeMeta struct {
Level ErrorLevel // 错误级别(Key/Channel/Client)
}
// HTTPResponseClassification 包含 HTTP 响应分类的结果。
type HTTPResponseClassification struct {
Level ErrorLevel
KeyCooldownUntil time.Time
HasKeyCooldownUntil bool
KeyCooldownReason string
}
// sseErrorResponse SSE error事件的JSON结构(Anthropic API / 88code API)
// [FIX] 提取为公共结构体,消除 classifySSEError 和 ParseResetTimeFrom1308Error 的重复定义
type sseErrorResponse struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"` // Anthropic使用
Code string `json:"code"` // 其他渠道使用
Message string `json:"message"`
} `json:"error"`
}
type structuredQuotaErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Error json.RawMessage `json:"error"`
}
type structuredQuotaErrorObject struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
}
// ErrorType 返回错误类型(优先使用type字段,如果为空则使用code字段)
// [FIX] 消除重复的errorType判断逻辑
func (r *sseErrorResponse) ErrorType() string {
if r.Error.Type != "" {
return r.Error.Type
}
return r.Error.Code
}
// statusCodeMetaMap 状态码元数据映射表
// 设计原则:表驱动替代分散的 switch/map,提高可维护性
var statusCodeMetaMap = map[int]StatusCodeMeta{
// === 客户端取消 ===
// 499: 上游返回的客户端关闭请求,应切换渠道重试
// 注意:context.Canceled 在 ClassifyError 中单独处理
499: {ErrorLevelChannel},
// === Key级错误:API Key相关问题 ===
// 这些错误在本系统中属于"后端Key/渠道配置问题",不应甩锅给客户端
401: {ErrorLevelKey}, // Unauthorized - Key invalid
402: {ErrorLevelKey}, // Payment Required - quota/balance
403: {ErrorLevelKey}, // Forbidden - Key permission
429: {ErrorLevelKey}, // Too Many Requests - rate limited
// === 渠道级错误:服务器端问题 ===
444: {ErrorLevelChannel}, // nginx: No Response (服务器主动关闭连接)
500: {ErrorLevelChannel}, // Internal Server Error
502: {ErrorLevelChannel}, // Bad Gateway
503: {ErrorLevelChannel}, // Service Unavailable
504: {ErrorLevelChannel}, // Gateway Timeout
520: {ErrorLevelChannel}, // Cloudflare: Unknown Error
521: {ErrorLevelChannel}, // Cloudflare: Web Server Is Down
524: {ErrorLevelChannel}, // Cloudflare: A Timeout Occurred
// === 自定义内部状态码 ===
StatusQuotaExceeded: {ErrorLevelKey}, // 1308 quota exceeded
StatusSSEError: {ErrorLevelKey}, // SSE error event
StatusFirstByteTimeout: {ErrorLevelChannel}, // First byte timeout
StatusStreamIncomplete: {ErrorLevelChannel}, // Stream incomplete
// === 客户端错误:不冷却,直接返回 ===
// 408 Request Timeout: RFC 7231 定义为"服务器等待客户端发送完整请求超时"(客户端慢)
408: {ErrorLevelClient}, // Request Timeout - client slow
// 405 Method Not Allowed: 在代理场景下,这更可能意味着上游 endpoint/路由配置错误(方法不被支持)
// 作为渠道级故障处理:触发渠道冷却。
405: {ErrorLevelChannel}, // Method Not Allowed
406: {ErrorLevelClient}, // Not Acceptable
410: {ErrorLevelClient}, // Gone
413: {ErrorLevelClient}, // Payload Too Large
414: {ErrorLevelClient}, // URI Too Long
415: {ErrorLevelClient}, // Unsupported Media Type
416: {ErrorLevelClient}, // Range Not Satisfiable
417: {ErrorLevelClient}, // Expectation Failed
}
// GetStatusCodeMeta 获取状态码元数据(统一入口)
func GetStatusCodeMeta(status int) StatusCodeMeta {
if meta, ok := statusCodeMetaMap[status]; ok {
return meta
}
// 默认行为(兜底策略)
if status >= 500 {
return StatusCodeMeta{ErrorLevelChannel}
}
if status >= 400 {
// [FIX] 未知 4xx 状态码默认 Key 级冷却(保守策略)
// 设计理念:未知错误应保守处理,避免持续请求故障 Key
// 如果所有 Key 都冷却了,会自动升级为渠道级冷却
return StatusCodeMeta{ErrorLevelKey}
}
return StatusCodeMeta{ErrorLevelClient}
}
// ClientStatusFor 将 status 映射为对外暴露的状态码。
//
// 设计目标:
// - 对外语义一致:不把后端 Key/渠道故障伪装成“客户端错误”
// - 单一映射入口:避免在 app 层再堆一份 if/switch(那就是第二套规则)
func ClientStatusFor(status int) int {
if status <= 0 {
return http.StatusBadGateway
}
// 内部状态码:无条件映射为标准 HTTP 语义值
switch status {
case StatusQuotaExceeded:
return http.StatusTooManyRequests
case StatusSSEError:
return http.StatusBadGateway
case StatusFirstByteTimeout:
return http.StatusGatewayTimeout
case StatusStreamIncomplete:
return http.StatusBadGateway
}
// 透明代理原则:透传所有上游状态码,不篡改HTTP语义
return status
}
// ClassifyHTTPStatus 分类HTTP状态码,返回错误级别
// 注意:401/403/429 需要结合响应体/headers进一步判断(通过ClassifyHTTPResponse)
func ClassifyHTTPStatus(statusCode int) ErrorLevel {
if statusCode >= 200 && statusCode < 300 {
return ErrorLevelNone
}
return GetStatusCodeMeta(statusCode).Level
}
// ClassifyHTTPResponseWithMeta 基于状态码 + headers + 响应体智能分类错误级别
// 返回 HTTPResponseClassification,包含错误级别和固定Key冷却截止时间(如果存在)
//
// 分类策略:
// - 401/403 做语义分析:默认 Key 级,只在明确账户级不可逆错误时升级为 Channel 级
// - 429 做限流范围分析:默认 Key 级,只有明确长时间/全局限流特征才升级为 Channel 级
// - 1308 错误优先:无论 HTTP 状态码,检测到就按 Key 级处理(用于精确冷却时间)
// - 其他状态码:走表驱动分类(statusCodeMetaMap)
func ClassifyHTTPResponseWithMeta(statusCode int, headers map[string][]string, responseBody []byte) HTTPResponseClassification {
// [INFO] 特殊处理:检测1308错误(可能以SSE error事件形式出现,HTTP状态码是200)
// 1308错误表示达到使用上限,应该触发Key级冷却
if resetTime, has1308 := ParseResetTimeFrom1308Error(responseBody); has1308 {
return HTTPResponseClassification{
Level: ErrorLevelKey,
KeyCooldownUntil: resetTime,
HasKeyCooldownUntil: true,
KeyCooldownReason: "1308",
}
}
if cooldownUntil, reason, ok := parseStructuredQuotaCooldown(responseBody, time.Now()); ok {
return HTTPResponseClassification{
Level: ErrorLevelKey,
KeyCooldownUntil: cooldownUntil,
HasKeyCooldownUntil: true,
KeyCooldownReason: reason,
}
}
// [INFO] 597 SSE error事件:解析实际错误类型动态判断级别
// SSE error JSON格式: {"type":"error","error":{"type":"api_error","message":"上游API返回错误: 500"}}
// 根据error.type判断:api_error/overloaded_error → 渠道级,其他 → Key级
if statusCode == StatusSSEError {
return HTTPResponseClassification{Level: classifySSEError(responseBody)}
}
// 429错误:需要结合 headers 判断限流范围
if statusCode == 429 {
if headers != nil {
return HTTPResponseClassification{Level: classifyRateLimitError(headers, responseBody)}
}
return HTTPResponseClassification{Level: ErrorLevelKey}
}
// 400错误:根据响应体智能分类
if statusCode == 400 {
return HTTPResponseClassification{Level: classify400Error(responseBody)}
}
// 404错误:根据响应体智能分类
if statusCode == 404 {
return HTTPResponseClassification{Level: classify404Error(responseBody)}
}
// 仅分析401和403错误,其他状态码使用标准分类器
if statusCode != 401 && statusCode != 403 {
return HTTPResponseClassification{Level: ClassifyHTTPStatus(statusCode)}
}
// 401/403错误:分析响应体内容
if len(responseBody) == 0 {
return HTTPResponseClassification{Level: ErrorLevelKey} // 无响应体,默认Key级错误
}
bodyLower := strings.ToLower(string(responseBody))
// 渠道级错误特征:**仅限账户级不可逆错误**
// 设计原则:保守策略,只有明确是渠道级错误时才返回ErrorLevelChannel
channelErrorPatterns := []string{
// 账户状态(不可逆)
"account suspended", // 账户暂停
"account disabled", // 账户禁用
"account banned", // 账户封禁
"service disabled", // 服务禁用
// 注意:以下错误已移除(改为Key级,让系统先尝试其他Key):
// - "额度已用尽", "quota_exceeded" → 可能只是单个Key额度用尽
// - "余额不足", "balance" → 可能只是单个Key余额不足
// - "limit reached" → 可能只是单个Key限额到达
}
for _, pattern := range channelErrorPatterns {
if strings.Contains(bodyLower, pattern) {
return HTTPResponseClassification{Level: ErrorLevelChannel} // 明确的渠道级错误
}
}
// 默认:Key级错误
// 包括:认证失败、权限不足、额度用尽、余额不足等
// 让handleProxyError根据渠道Key数量决定是否升级为渠道级
return HTTPResponseClassification{Level: ErrorLevelKey}
}
// classifyRateLimitError 分析429 Rate Limit错误的具体类型
// 增强429错误处理,区分Key级和渠道级限流
//
// 判断逻辑:
// 1. 检查Retry-After头: 如果>60秒,可能是IP/账户级限流 → 渠道级
// 2. 检查X-RateLimit-Scope: 如果是"global"或"ip" → 渠道级
// 3. 检查响应体中的错误描述
// 4. 默认: Key级(保守策略)
//
// 参数:
// - headers: HTTP响应头
// - responseBody: 响应体内容
func classifyRateLimitError(headers map[string][]string, responseBody []byte) ErrorLevel {
// 1. 解析Retry-After头
if retryAfterValues, ok := headers["Retry-After"]; ok && len(retryAfterValues) > 0 {
retryAfter := retryAfterValues[0]
// Retry-After可能是秒数或HTTP日期
// 尝试解析为秒数
if seconds, err := strconv.Atoi(retryAfter); err == nil {
// [INFO] 如果Retry-After > 阈值,可能是账户级或IP级限流
// 这种长时间限流通常影响整个渠道
if seconds > RetryAfterThresholdSeconds {
return ErrorLevelChannel
}
}
// 如果是HTTP日期格式,通常表示长时间限流,也视为渠道级
if _, err := time.Parse(time.RFC1123, retryAfter); err == nil {
return ErrorLevelChannel
}
}
// 2. 检查X-RateLimit-Scope头(某些API使用)
if scopeValues, ok := headers["X-Ratelimit-Scope"]; ok && len(scopeValues) > 0 {
scope := strings.ToLower(scopeValues[0])
// global/ip级别的限流影响整个渠道
if scope == RateLimitScopeGlobal || scope == RateLimitScopeIP || scope == RateLimitScopeAccount {
return ErrorLevelChannel
}
}
// 3. 分析响应体中的错误描述
if len(responseBody) > 0 {
bodyLower := strings.ToLower(string(responseBody))
// 渠道级限流特征
channelPatterns := []string{
"ip rate limit", // IP级别限流
"account rate limit", // 账户级别限流
"global rate limit", // 全局限流
"organization limit", // 组织级别限流
}
for _, pattern := range channelPatterns {
if strings.Contains(bodyLower, pattern) {
return ErrorLevelChannel
}
}
}
// 4. 默认: Key级别限流(保守策略)
// 让系统先尝试其他Key,如果所有Key都限流了,会自动升级为渠道级
return ErrorLevelKey
}
// classifySSEError 分析SSE error事件的具体类型
// SSE error JSON格式: {"type":"error","error":{"type":"api_error","message":"上游API返回错误: 500"}}
//
// 判断逻辑:
// - api_error: 上游服务错误(通常是5xx)→ 渠道级
// - overloaded_error: 上游过载 → 渠道级
// - rate_limit_error: 限流错误 → Key级(可能只是单个Key限流)
// - authentication_error: 认证错误 → Key级
// - invalid_request_error: 请求错误 → Key级
// - 其他/解析失败: 默认Key级(保守策略)
func classifySSEError(responseBody []byte) ErrorLevel {
if len(responseBody) == 0 {
return ErrorLevelKey
}
// 解析SSE error JSON
// [FIX] 支持两种格式:
// 1. Anthropic格式: {"type":"error", "error":{"type":"1308", ...}}
// 2. 其他渠道格式: {"error":{"code":"1308", ...}}
var errResp sseErrorResponse
if err := json.Unmarshal(responseBody, &errResp); err != nil {
return ErrorLevelKey // 解析失败,保守处理
}
// 根据error.type/code判断错误级别
switch errResp.ErrorType() {
case "api_error", "overloaded_error", "service_unavailable_error", "server_is_overloaded", "1305":
// 上游服务错误或过载 → 渠道级冷却
return ErrorLevelChannel
case "rate_limit_error", "authentication_error", "invalid_request_error", "1308", "1310":
// 限流/认证/请求错误 → Key级冷却
return ErrorLevelKey
default:
// 未知错误类型,保守处理为Key级
return ErrorLevelKey
}
}
func parseStructuredQuotaCooldown(responseBody []byte, now time.Time) (time.Time, string, bool) {
if len(responseBody) == 0 {
return time.Time{}, "", false
}
code, message, ok := parseStructuredQuotaError(responseBody)
if !ok {
return time.Time{}, "", false
}
messageUpper := strings.ToUpper(message)
switch {
case code == "API_KEY_QUOTA_EXHAUSTED":
return now.Add(30 * time.Minute), "API_KEY_QUOTA_EXHAUSTED", true
case code == "FREE_TIER_BUDGET_EXCEEDED" || strings.Contains(messageUpper, "FREE_TIER_BUDGET_EXCEEDED"):
return now.Add(30 * time.Minute), "FREE_TIER_BUDGET_EXCEEDED", true
case code == "DAILY_LIMIT_EXCEEDED" ||
(code == "USAGE_LIMIT_EXCEEDED" && strings.Contains(messageUpper, "DAILY_LIMIT_EXCEEDED")):
return nextLocalMidnight(now), "DAILY_LIMIT_EXCEEDED", true
case strings.Contains(message, "用量上限"):
if until, ok := parseBeijingTomorrowResetTime(message, now); ok {
return until, "BEIJING_RELATIVE_QUOTA_RESET", true
}
return time.Time{}, "", false
default:
return time.Time{}, "", false
}
}
func parseStructuredQuotaError(responseBody []byte) (string, string, bool) {
var errResp structuredQuotaErrorResponse
if err := json.Unmarshal(responseBody, &errResp); err != nil {
return "", "", false
}
code := strings.ToUpper(strings.TrimSpace(errResp.Code))
message := errResp.Message
if len(errResp.Error) > 0 {
var errorText string
if err := json.Unmarshal(errResp.Error, &errorText); err == nil {
if message == "" {
message = errorText
}
} else {
var errorObj structuredQuotaErrorObject
if err := json.Unmarshal(errResp.Error, &errorObj); err == nil {
if code == "" {
code = strings.ToUpper(strings.TrimSpace(errorObj.Code))
}
if code == "" {
code = strings.ToUpper(strings.TrimSpace(errorObj.Type))
}
if message == "" {
message = errorObj.Message
}
}
}
}
return code, message, code != "" || message != ""
}
func nextLocalMidnight(now time.Time) time.Time {
local := now.In(time.Local)
y, m, d := local.Date()
return time.Date(y, m, d+1, 0, 0, 0, 0, time.Local)
}
func parseBeijingTomorrowResetTime(message string, now time.Time) (time.Time, bool) {
if !strings.Contains(message, "北京时间") {
return time.Time{}, false
}
matches := beijingTomorrowResetRegex.FindStringSubmatch(message)
if matches == nil {
return time.Time{}, false
}
hour, err := strconv.Atoi(matches[1])
if err != nil || hour < 0 || hour > 23 {
return time.Time{}, false
}
minute := 0
if len(matches) > 2 && matches[2] != "" {
minute, err = strconv.Atoi(matches[2])
if err != nil || minute < 0 || minute > 59 {
return time.Time{}, false
}
}
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
local := now.In(loc)
y, mon, d := local.Date()
return time.Date(y, mon, d+1, hour, minute, 0, 0, loc), true
}
// classify400Error 根据响应体内容智能分类 400 错误
// 设计原则:代理场景下 400 通常是上游服务异常,应触发渠道冷却并切换
func classify400Error(responseBody []byte) ErrorLevel {
if len(responseBody) == 0 {
return ErrorLevelChannel // 空响应体 = 上游异常
}
bodyLower := strings.ToLower(string(responseBody))
// Key 级特征(罕见)
if strings.Contains(bodyLower, "invalid_api_key") ||
strings.Contains(bodyLower, "api key") {
return ErrorLevelKey
}
// 默认:渠道级(上游服务异常,触发冷却并切换渠道)
return ErrorLevelChannel
}
// classify404Error 根据响应体内容智能分类 404 错误
// 设计原则:404 本身是异常情况,只有明确的客户端错误才不切换
// - 模型不存在(客户端级):明确的 model_not_found 或 does not exist
// - 其他情况(渠道级):空响应、HTML、异常 JSON 等都应切换渠道
func classify404Error(responseBody []byte) ErrorLevel {
if len(responseBody) == 0 {
return ErrorLevelChannel // 空响应 = 路径错误,渠道配置问题
}
bodyLower := strings.ToLower(string(responseBody))
// 仅当明确是"模型不存在"时才视为客户端错误
if strings.Contains(bodyLower, "model_not_found") ||
strings.Contains(bodyLower, "does not exist") {
return ErrorLevelClient
}
// 其他 404 一律视为渠道问题(HTML/JSON/其他)
// 例如:BaseURL 配错、上游服务异常、路由不存在等
return ErrorLevelChannel
}
// ParseResetTimeFrom1308Error 从1308错误响应中提取重置时间
// 错误格式: {"type":"error","error":{"type":"1308","message":"已达到 5 小时的使用上限。您的限额将在 2025-12-09 18:08:11 重置。"},"request_id":"..."}
//
// [FIX] 使用正则匹配时间格式,不再依赖中文文案(如"将在"/"重置")
// 这样即使上游修改错误消息措辞或切换语言,只要包含 YYYY-MM-DD HH:MM:SS 格式的时间就能正确解析
//
// 参数:
// - responseBody: JSON格式的错误响应体
//
// 返回:
// - time.Time: 解析出的重置时间(如果成功)
// - bool: 是否成功解析(true表示是1308错误且成功提取时间)
func ParseResetTimeFrom1308Error(responseBody []byte) (time.Time, bool) {
// 1. 解析JSON结构
// [FIX] 支持两种格式:
// 1. Anthropic格式: {"type":"error", "error":{"type":"1308", ...}}
// 2. 其他渠道格式: {"error":{"code":"1308", ...}}
var errResp sseErrorResponse
if err := json.Unmarshal(responseBody, &errResp); err != nil {
return time.Time{}, false
}
// 2. 检查是否为1308或1310错误(优先使用type,如果为空则使用code)
errorType := errResp.ErrorType()
if errorType != "1308" && errorType != "1310" {
return time.Time{}, false
}
// 3. 使用正则从message中提取时间字符串(不依赖具体语言文案)
// 匹配格式: YYYY-MM-DD HH:MM:SS
timeStr := resetTime1308Regex.FindString(errResp.Error.Message)
if timeStr == "" {
return time.Time{}, false
}
// 4. 解析时间字符串
resetTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeStr, time.Local)
if err != nil {
return time.Time{}, false
}
return resetTime, true
}
// ClassifyError 统一错误分类器(网络错误+HTTP错误)
// 将proxy_util.go中的classifyError和classifyErrorByString整合到此处
//
// 参数:
// - err: 错误对象(可能是context错误、网络错误、或其他错误)
//
// 返回:
// - statusCode: HTTP状态码(或内部错误码)
// - errorLevel: 错误级别(Key级/渠道级/客户端级)
// - shouldRetry: 是否应该重试
//
// 设计原则(DRY+SRP):
// - 统一入口处理所有错误分类
// - 消除proxy_util.go中的重复逻辑
// - 分层设计:快速路径(context错误)→ 网络错误 → 字符串匹配
func ClassifyError(err error) (statusCode int, errorLevel ErrorLevel, shouldRetry bool) {
if err == nil {
return 200, ErrorLevelNone, false
}
// 快速路径1:专门识别上游首字节超时,优先切换渠道
if errors.Is(err, ErrUpstreamFirstByteTimeout) {
return StatusFirstByteTimeout, ErrorLevelChannel, true
}
// 快速路径1.2:上游 200 空体是坏网关,不是成功响应。
if errors.Is(err, ErrUpstreamEmptyResponse) {
return http.StatusBadGateway, ErrorLevelChannel, true
}
// 快速路径1.5:协议转换明确声明为客户端请求结构不支持
if errors.Is(err, protocol.ErrUnsupportedRequestShape) {
return http.StatusBadRequest, ErrorLevelClient, false
}
// 快速路径2:处理客户端主动取消
if errors.Is(err, context.Canceled) {
return 499, ErrorLevelClient, false // StatusClientClosedRequest
}
// 快速路径3:统一处理其它 DeadlineExceeded,默认视为上游超时
if errors.Is(err, context.DeadlineExceeded) {
return 504, ErrorLevelChannel, true // Gateway Timeout,触发渠道切换
}
// 快速路径4:检测net.Error的超时场景
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return 504, ErrorLevelChannel, true // Gateway Timeout,可重试
}
}
// 慢速路径:回退到字符串匹配
return classifyErrorByString(err.Error())
}
// classifyErrorByString 通过字符串匹配分类网络错误
// 从proxy_util.go迁移,作为ClassifyError的私有辅助函数
func classifyErrorByString(errStr string) (int, ErrorLevel, bool) {
errLower := strings.ToLower(errStr)
// broken pipe - 客户端主动断开连接,完全不重试
if strings.Contains(errLower, "broken pipe") {
return 499, ErrorLevelClient, false
}
// connection reset by peer - 通常是对端(上游)突然断开连接
// 这不是“客户端取消”的语义,内部统一按 502 处理以进入健康度统计,并允许切换渠道重试。
if strings.Contains(errLower, "connection reset by peer") {
return 502, ErrorLevelChannel, true
}
// [INFO] 空响应检测:上游返回200但Content-Length=0
// 常见于CDN/代理错误、认证失败等异常场景,应触发渠道级重试
if strings.Contains(errLower, "empty response") &&
strings.Contains(errLower, "content-length: 0") {
return 502, ErrorLevelChannel, true // 归类为Bad Gateway(上游异常)
}
// Connection refused - 应该重试其他渠道
if strings.Contains(errLower, "connection refused") {
return 502, ErrorLevelChannel, true
}
// HTTP/2 流级错误 - 上游服务器主动关闭流或内部错误
// 常见原因:上游负载过高、服务崩溃、网络中间件超时、CDN断开
// 应触发渠道级重试(切换到其他渠道)
if strings.Contains(errLower, "http2: response body closed") ||
strings.Contains(errLower, "stream error:") {
return 502, ErrorLevelChannel, true // Bad Gateway - 上游服务异常
}
// 其他常见的网络连接错误也应该重试
if strings.Contains(errLower, "no such host") ||
strings.Contains(errLower, "host unreachable") ||
strings.Contains(errLower, "network unreachable") ||
strings.Contains(errLower, "connection timeout") ||
strings.Contains(errLower, "no route to host") {
return 502, ErrorLevelChannel, true
}
// 使用负值错误码,避免与HTTP状态码混淆
// 其他网络错误 - 可以重试
// 对外/日志统一使用标准HTTP语义:502 Bad Gateway
return 502, ErrorLevelChannel, true
}