Skip to content

错误处理

HTTPC 将所有错误统一封装为 ClientError,提供类型分类、可重试判断和丰富的上下文信息。配合 Go 标准库的 errors.Is/errors.As,可以精确匹配哨兵错误,也可以按分类灵活处理。

ErrorType 完整参考

HTTPC 定义了 12 种错误类型,涵盖从网络层到应用层的所有失败场景:

ErrorTypeCode()含义典型场景可重试
ErrorTypeNetworkNETWORK_ERROR网络层错误连接拒绝、连接重置、断管视原因而定
ErrorTypeTimeoutTIMEOUT超时拨号超时、请求超时、context 截止
ErrorTypeContextCanceledCONTEXT_CANCELED上下文取消ctx.Cancel() 被调用
ErrorTypeDNSDNS_ERRORDNS 解析失败域名不存在、DNS 服务器故障临时/超时可重试
ErrorTypeTLSTLS_ERRORTLS 握手错误协议版本不支持、算法协商失败
ErrorTypeCertificateCERTIFICATE_ERROR证书验证失败证书过期、签名无效、CA 不受信
ErrorTypeTransportTRANSPORT_ERRORHTTP 传输层错误协议错误、传输异常中断
ErrorTypeResponseReadRESPONSE_READ_ERROR响应体读取错误连接中断导致 EOF、读取超时视原因而定
ErrorTypeRetryExhaustedRETRY_EXHAUSTED重试耗尽达到 MaxRetries 上限后仍失败
ErrorTypeValidationVALIDATION_ERROR请求验证失败URL 格式非法、HTTP 头含控制字符
ErrorTypeHTTPHTTP_ERRORHTTP 状态码错误4xx/5xx 响应按状态码判断
ErrorTypeUnknownUNKNOWN_ERROR未分类错误其他未匹配的异常

可重试判断的完整规则

IsRetryable() 的判定逻辑比表中更细粒度:ErrorTypeDNS 仅在 net.DNSError 标记为临时或超时时可重试;ErrorTypeNetwork 通过检查 syscall.ErrnoECONNREFUSED/ECONNRESET/EPIPE/ETIMEDOUT/ENETUNREACH/EHOSTUNREACH)和错误消息模式来判断;ErrorTypeResponseRead 仅在读取操作(read/readfrom)的网络错误时重试。详见下方「可重试判断」。

ClientError 字段详解

ClientError 结构体携带请求失败的完整上下文:

字段类型用途
TypeErrorType错误分类,用于 switch 分支处理
Messagestring人类可读的错误描述
Causeerror底层原始错误,支持 errors.Unwrap
URLstring请求的 URL(已脱敏,见下方)
MethodstringHTTP 方法(GET/POST/...)
Attemptsint已尝试次数(含首次),重试耗尽时 > 1
StatusCodeintHTTP 状态码(仅 ErrorTypeHTTP 有值)
Hoststring目标主机名(用于断路器等)

错误类型判断

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            switch clientErr.Type {
            case httpc.ErrorTypeTimeout:
                log.Printf("请求超时(已尝试 %d 次): %v", clientErr.Attempts, err)
            case httpc.ErrorTypeNetwork:
                log.Printf("网络错误: %v", err)
            case httpc.ErrorTypeDNS:
                log.Printf("DNS 解析失败: %v", err)
            case httpc.ErrorTypeTLS:
                log.Printf("TLS 握手失败: %v", err)
            case httpc.ErrorTypeCertificate:
                log.Printf("证书验证失败: %v", err)
            case httpc.ErrorTypeRetryExhausted:
                log.Printf("重试 %d 次后仍失败: %v", clientErr.Attempts, err)
            case httpc.ErrorTypeValidation:
                log.Printf("请求验证失败: %v", err)
            case httpc.ErrorTypeContextCanceled:
                log.Printf("请求已取消: %v", err)
            default:
                log.Printf("其他错误 [%s]: %v", clientErr.Code(), err)
            }
        }
        return
    }
    fmt.Printf("成功: %d\n", result.StatusCode())
}

可重试判断

IsRetryable() 综合考虑错误类型和底层原因,返回是否值得重试:

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    _, err = client.Get("https://api.example.com/data")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            if clientErr.IsRetryable() {
                fmt.Println("可重试错误,上层逻辑可稍后重试")
            } else {
                fmt.Printf("不可重试错误 [%s],需人工介入\n", clientErr.Code())
            }
        }
    }
}

IsRetryable 与自动重试的区别

IsRetryable() 判断的是「这个错误是否值得重试」,它同时被 HTTPC 内部的重试引擎使用。如果你已经通过 RetryConfig.MaxRetries 配置了自动重试,那么到你的错误处理代码时,如果收到的是网络/超时类错误,说明重试已经耗尽。IsRetryable() 主要用于上层(如断路器、任务队列)的决策。

哨兵错误完整参考

HTTPC 定义了以下哨兵错误变量,可通过 errors.Is 精确匹配:

哨兵变量触发条件推荐处理
ErrClientClosedclient.Close() 之后继续使用该客户端初始化新 Client 或修复生命周期管理
ErrNilConfig传给 New() 的 Config 指针为 nil使用 DefaultConfig() 获取默认值
ErrInvalidHeaderHTTP 头校验失败(含控制字符或格式非法)修正 Header 值后重试
ErrInvalidTimeout超时值为负数或超过 30 分钟上限调整到合法区间 [0, 30min]
ErrInvalidRetry重试配置非法(MaxRetries 不在 0-10,BackoffFactor 不在 1.0-10.0)修正重试参数
ErrInvalidConnection连接配置非法(连接池大小超出范围、代理 URL 格式错误)修正连接参数
ErrInvalidSecurity安全配置非法(响应体大小限制超出范围)修正安全参数
ErrInvalidMiddleware中间件配置非法(重定向次数超 50、UserAgent 过长或含控制字符)修正中间件参数
ErrEmptyFilePath下载时未指定文件路径设置 DownloadConfig.FilePath
ErrFileExists目标文件已存在且 Overwrite=falseResumeDownload=false设置覆盖或续传,或换路径
ErrResponseBodyEmpty响应体为空时调用 Unmarshal() 等解析方法先检查 RawBody 再解析
ErrResponseBodyTooLarge响应体超过 MaxResponseBodySize 限制增大限制或换接口分页获取

配置类错误 vs 运行时错误

ErrInvalid* 系列(ErrInvalidHeader/ErrInvalidTimeout/ErrInvalidRetry/ErrInvalidConnection/ErrInvalidSecurity/ErrInvalidMiddleware)是配置验证错误,在 New() 调用时就会返回,不应在请求热路径中出现。运行时错误通过 ClientError 分类处理。

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")

    switch {
    case errors.Is(err, httpc.ErrClientClosed):
        fmt.Println("客户端已关闭,需重新创建")
    case errors.Is(err, httpc.ErrResponseBodyTooLarge):
        fmt.Println("响应体过大,考虑增大 MaxResponseBodySize")
    case errors.Is(err, httpc.ErrResponseBodyEmpty):
        fmt.Println("响应体为空,调用解析方法前先检查 RawBody")
    case errors.Is(err, httpc.ErrInvalidHeader):
        fmt.Println("请求头无效,修正后重试")
    }

    if result != nil {
        fmt.Printf("状态码: %d\n", result.StatusCode())
    }
}

URL 自动脱敏

ClientError.Error() 会自动移除 URL 中的敏感信息。包含用户名密码的 URL(如 https://user:pass@host/path)会被脱敏为 https://***:***@host/path,确保日志和错误消息中不会泄露凭证:

go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // URL 中包含凭证信息
    result, err := client.Get("https://admin:[email protected]/data")
    if err != nil {
        // 错误消息中的凭证被自动脱敏:
        // "GET https://***:***@api.example.com/data: network error occurred"
        fmt.Println(err)
    }
    if result != nil {
        fmt.Println(result.StatusCode())
    }
}

脱敏的覆盖范围

脱敏不仅移除 user:pass@host 格式的凭证,也处理敏感查询参数(如 tokenkeysecret 等)。对于不含凭证或敏感参数的 URL,会走快速路径跳过解析,避免不必要的 url.Parse 开销。

panic 恢复安全网

HTTPC 在 Request()Download() 方法中内置了 panic 安全网。任何意外的 panic(来自引擎、传输层、TLS 库或中间件)都会被捕获并转换为 ClientError,而不是让调用方进程崩溃:

go
// client.go 内部实现(概念示意)
func (c *clientImpl) Request(ctx context.Context, method, url string, ...) (*Result, error) {
    defer func() {
        if r := recover(); r != nil {
            result = nil
            err = panicToError(r) // 转换为 ClientError
        }
    }()
    // ... 正常请求逻辑
}

安全网不替代中间件恢复

内置安全网是最后的防线,将 panic 转换为错误而非崩溃。但如果你在中间件中可能触发 panic,建议额外使用 RecoveryMiddleware()——它能在中间件链中更早捕获 panic,提供更完整的日志上下文:

go
cfg := httpc.DefaultConfig()
cfg.Middleware.Middlewares = []httpc.MiddlewareFunc{
    httpc.RecoveryMiddleware(),       // 中间件层 panic 恢复
    httpc.LoggingMiddleware(nil),     // 日志
    httpc.MetricsMiddleware(nil),     // 指标
}

自动重试与错误的关系

HTTPC 的重试引擎在内部自动处理可重试错误。了解哪些错误会被自动重试,能帮助你避免在应用层重复重试:

自动重试的错误

条件是否重试说明
网络错误(连接拒绝、重置、EOF)isRetryableNetworkMessage 匹配
拨号/请求超时ErrorTypeTimeout
临时/超时类 DNS 失败dnsErr.IsTemporary || dnsErr.IsTimeout
响应体读取网络错误读取操作的 net.OpError
可重试 HTTP 状态码408/429/500/502/503/504
ProxyRotateOnStatus 指定的状态码如 403 触发代理轮换

不重试的错误

条件是否重试说明
context.Canceled快速路径直接返回
context.DeadlineExceeded快速路径直接返回
TLS 握手失败ErrorTypeTLS 不可重试
证书验证失败ErrorTypeCertificate 不可重试
配置验证错误ErrorTypeValidation 不可重试
其他 4xx 客户端错误如 400/401/403/404

context 取消是快速路径

isRetryableError 在判断前先检查 context.Canceledcontext.DeadlineExceeded——如果匹配,直接返回 false,跳过完整的错误分类。这避免了在 context 已取消时浪费资源进行重试判断。

go
package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "time"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    result, err := client.Request(ctx, "GET", "https://api.example.com/slow")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            if clientErr.Type == httpc.ErrorTypeContextCanceled {
                // context 超时或手动取消,不会被自动重试
                fmt.Println("请求被取消(超时或手动取消),不会重试")
            } else if clientErr.Type == httpc.ErrorTypeTimeout {
                fmt.Println("请求超时,已自动重试仍失败")
            }
        }
        return
    }
    fmt.Println(result.StatusCode())
}

错误处理最佳实践

1. 区分客户端错误和服务端错误

go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        // 网络层错误——连接、TLS、DNS 等问题
        log.Printf("网络层错误: %v", err)
        return
    }

    // HTTP 层错误——收到了响应但状态码非 2xx
    if result.IsClientError() {
        // 4xx:客户端请求有误(参数错误、权限不足等)
        log.Printf("客户端错误: %d", result.StatusCode())
    } else if result.IsServerError() {
        // 5xx:服务端故障(重试已耗尽,上游仍不可用)
        log.Printf("服务端错误: %d", result.StatusCode())
    } else {
        fmt.Printf("成功: %d\n", result.StatusCode())
    }
}

2. 断路器模式

当某个服务持续失败时,断路器暂时停止请求,避免级联故障和资源浪费:

go
type CircuitBreaker struct {
    mu           sync.Mutex
    failures     int
    threshold    int           // 连续失败阈值
    cooldown     time.Duration // 熔断冷却时间
    trippedAt    time.Time
}

func (cb *CircuitBreaker) Allow() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if cb.failures >= cb.threshold {
        if time.Since(cb.trippedAt) < cb.cooldown {
            return false // 熔断中,拒绝请求
        }
        cb.failures = 0 // 冷却期过,重置
    }
    return true
}

func (cb *CircuitBreaker) Record(err error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if err != nil {
        cb.failures++
        if cb.failures >= cb.threshold {
            cb.trippedAt = time.Now()
        }
    } else {
        cb.failures = 0 // 成功则重置
    }
}

// 使用时结合 IsRetryable 判断
func requestWithBreaker(client httpc.Client, cb *CircuitBreaker, url string) error {
    if !cb.Allow() {
        return fmt.Errorf("circuit breaker open")
    }
    result, err := client.Get(url)
    cb.Record(err)
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) && !clientErr.IsRetryable() {
            cb.Record(nil) // 不可重试错误不算服务故障
        }
        return err
    }
    _ = result
    return nil
}

3. 降级 fallback

当主服务不可用时回退到缓存或默认值:

go
package main

import (
    "errors"
    "log"

    "github.com/cybergodev/httpc"
)

func fetchWithFallback(client httpc.Client, url string, fallback []byte) []byte {
    result, err := client.Get(url)
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            switch clientErr.Type {
            case httpc.ErrorTypeTimeout, httpc.ErrorTypeRetryExhausted:
                log.Printf("主服务不可用,使用降级数据: %v", err)
                return fallback
            case httpc.ErrorTypeValidation:
                // 验证错误是本地 bug,不应降级
                log.Fatalf("请求配置错误: %v", err)
            }
        }
        log.Printf("未知错误,使用降级数据: %v", err)
        return fallback
    }
    return result.RawBody()
}

4. 使用中间件统一处理

go
package main

import (
    "log"
    "time"

    "github.com/cybergodev/httpc"
)

func main() {
    cfg := httpc.DefaultConfig()
    cfg.Middleware.Middlewares = []httpc.MiddlewareFunc{
        httpc.RecoveryMiddleware(),
        httpc.LoggingMiddleware(&httpc.LoggingConfig{
            LogFunc: func(format string, args ...any) {
                log.Printf("[HTTP] "+format, args...)
            },
        }),
        httpc.MetricsMiddleware(&httpc.MetricsConfig{
            OnMetrics: func(method, url string, statusCode int, duration time.Duration, err error) {
                if err != nil {
                    log.Printf("[METRICS] %s %s 失败: %v (耗时 %v)", method, url, err, duration)
                } else {
                    log.Printf("[METRICS] %s %s -> %d (耗时 %v)", method, url, statusCode, duration)
                }
            },
        }),
    }

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("状态码: %d", result.StatusCode())
}

5. 超时分层

HTTPC 提供多个层级的超时控制,从粗到细:

go
// 第一层:客户端默认超时(所有请求的全局上限)
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 30 * time.Second

// 第二层:中间件强制超时(覆盖默认)
timeoutMW := httpc.TimeoutMiddleware(&httpc.TimeoutMiddlewareConfig{
    Duration: 30 * time.Second,
})

// 第三层:单请求覆盖(WithTimeout 覆盖中间件和默认)
result, err := client.Get(url, httpc.WithTimeout(10*time.Second))

// 第四层:context 超时(最精确,推荐用于关键路径)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := client.Request(ctx, "GET", url)

ResponseHeader 超时与 WithTimeout 的交互

Timeouts.ResponseHeader = 0(默认)时,传输层不强制响应头超时,WithTimeout 有完全控制权。但如果设为正值(如 SecureConfig() 的 10s),它会在传输层对所有请求强制执行,WithTimeout 无法延长它——这是防御 slowloris 攻击的设计。AI API 等长响应场景请保持 ResponseHeader = 0

下一步