---
sidebar_label: "错误处理"
title: "错误处理 - CyberGo HTTPC | 分类与哨兵匹配"
description: "HTTPC 错误处理指南：ErrorType 十二种错误分类、ClientError 字段及 IsRetryable 判断、errors.Is/As 哨兵错误匹配、重试耗尽处理、context 超时与取消、中间件统一错误处理与超时分层最佳实践。"
sidebar_position: 5
---

# 错误处理

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

## ErrorType 完整参考

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

| ErrorType | Code() | 含义 | 典型场景 | 可重试 |
|-----------|--------|------|----------|--------|
| `ErrorTypeNetwork` | `NETWORK_ERROR` | 网络层错误 | 连接拒绝、连接重置、断管 | 视原因而定 |
| `ErrorTypeTimeout` | `TIMEOUT` | 超时 | 拨号超时、请求超时、context 截止 | 是 |
| `ErrorTypeContextCanceled` | `CONTEXT_CANCELED` | 上下文取消 | `ctx.Cancel()` 被调用 | 否 |
| `ErrorTypeDNS` | `DNS_ERROR` | DNS 解析失败 | 域名不存在、DNS 服务器故障 | 临时/超时可重试 |
| `ErrorTypeTLS` | `TLS_ERROR` | TLS 握手错误 | 协议版本不支持、算法协商失败 | 否 |
| `ErrorTypeCertificate` | `CERTIFICATE_ERROR` | 证书验证失败 | 证书过期、签名无效、CA 不受信 | 否 |
| `ErrorTypeTransport` | `TRANSPORT_ERROR` | HTTP 传输层错误 | 协议错误、传输异常中断 | 是 |
| `ErrorTypeResponseRead` | `RESPONSE_READ_ERROR` | 响应体读取错误 | 连接中断导致 EOF、读取超时 | 视原因而定 |
| `ErrorTypeRetryExhausted` | `RETRY_EXHAUSTED` | 重试耗尽 | 达到 `MaxRetries` 上限后仍失败 | 否 |
| `ErrorTypeValidation` | `VALIDATION_ERROR` | 请求验证失败 | URL 格式非法、HTTP 头含控制字符 | 否 |
| `ErrorTypeHTTP` | `HTTP_ERROR` | HTTP 状态码错误 | 4xx/5xx 响应 | 按状态码判断 |
| `ErrorTypeUnknown` | `UNKNOWN_ERROR` | 未分类错误 | 其他未匹配的异常 | 否 |

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

## ClientError 字段详解

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

| 字段 | 类型 | 用途 |
|------|------|------|
| Type | `ErrorType` | 错误分类，用于 `switch` 分支处理 |
| Message | `string` | 人类可读的错误描述 |
| Cause | `error` | 底层原始错误，支持 `errors.Unwrap` 链 |
| `URL` | `string` | 请求的 URL（已脱敏，见下方） |
| `Method` | `string` | HTTP 方法（GET/POST/...） |
| `Attempts` | `int` | 已尝试次数（含首次），重试耗尽时 > 1 |
| `StatusCode` | `int` | HTTP 状态码（仅 `ErrorTypeHTTP` 有值） |
| Host | `string` | 目标主机名（用于断路器等） |

### 错误类型判断

```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())
            }
        }
    }
}
```

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

## 哨兵错误完整参考

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

| 哨兵变量 | 触发条件 | 推荐处理 |
|----------|----------|----------|
| `ErrClientClosed` | 在 `client.Close()` 之后继续使用该客户端 | 初始化新 Client 或修复生命周期管理 |
| `ErrNilConfig` | 传给 `New()` 的 Config 指针为 nil | 使用 `DefaultConfig()` 获取默认值 |
| `ErrInvalidHeader` | HTTP 头校验失败（含控制字符或格式非法） | 修正 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=false`、`ResumeDownload=false` | 设置覆盖或续传，或换路径 |
| `ErrResponseBodyEmpty` | 响应体为空时调用 `Unmarshal()` 等解析方法 | 先检查 `RawBody` 再解析 |
| `ErrResponseBodyTooLarge` | 响应体超过 `MaxResponseBodySize` 限制 | 增大限制或换接口分页获取 |

:::tip 配置类错误 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:s3cret@api.example.com/data")
    if err != nil {
        // 错误消息中的凭证被自动脱敏：
        // "GET https://***:***@api.example.com/data: network error occurred"
        fmt.Println(err)
    }
    if result != nil {
        fmt.Println(result.StatusCode())
    }
}
```

:::tip 脱敏的覆盖范围
脱敏不仅移除 `user:pass@host` 格式的凭证，也处理敏感查询参数（如 `token`、`key`、`secret` 等）。对于不含凭证或敏感参数的 URL，会走快速路径跳过解析，避免不必要的 `url.Parse` 开销。
:::

## panic 恢复安全网

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

<!-- check-code: skip -->
```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
        }
    }()
    // ... 正常请求逻辑
}
```

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

<!-- check-code: skip -->
```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 |

:::tip context 取消是快速路径
`isRetryableError` 在判断前先检查 `context.Canceled` 和 `context.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. 断路器模式

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

<!-- check-code: skip -->
```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

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

<!-- check-code: skip -->
```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 提供多个层级的超时控制，从粗到细：

<!-- check-code: skip -->
```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)
```

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

## 下一步

- [重试与容错](./retry-fault-tolerance) — 退避算法详解与自定义重试策略
- [错误类型 API](../api-reference/types/errors) — 错误类型和哨兵变量完整参考
- [中间件链](./middleware-chain) — 使用中间件统一处理错误
- [配置 API](../api-reference/client-config/config) — 超时与安全配置参考
