Go AI Agent 实战 12|HTTP 状态码、API 错误处理与日志

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段二 Go 调用大模型 · 第 5 课(12)

上一篇:请求结构、响应结构与 JSON 解析

前面几课已经完成了请求、响应和业务校验。但在真实环境里,调用失败不是例外,而是必须设计的正常分支:认证会失效、配额会耗尽、网络会中断、服务端也会暂时不可用。

这一课把"请求失败"拆成可分类、可记录、可处理的错误,并整理成可复用的 Go 客户端。

一、本课学习目标

学完本课,你应该能够:

  1. 区分请求构造错误、传输错误、HTTP 错误和业务错误。
  2. 根据状态码判断哪些问题可以重试。
  3. 解析服务端返回的 JSON 错误信息。
  4. 使用自定义 error 保存状态码和错误类型。
  5. 使用 %w 保留错误链。
  6. 设计不泄露密钥和敏感内容的诊断日志。

二、先建立错误分类

一条模型调用链可以分为:

text 复制代码
构造请求
   ↓
发送 HTTP 请求
   ↓
检查 HTTP 状态码
   ├─ 非 2xx:读取有限长度的错误响应
   └─ 2xx:解析正常响应
   ↓
校验 choices 与回答内容

对应错误类型如下:

错误类型 例子 处理方向
请求构造错误 JSON 编码失败 修正代码或输入
网络传输错误 DNS、TLS、连接超时 视错误类型决定是否重试
HTTP 错误 401、429、503 结合状态码和错误体处理
解析错误 响应不是预期 JSON 保留安全诊断信息
业务校验错误 choices 为空 视为本次调用未成功

client.Do 返回的 err 和 resp.StatusCode 是两个不同层次:

  • err != nil:没有正常完成一次 HTTP 交互。
  • err == nil:拿到了 HTTP 响应,但状态码仍可能是 401 或 429。

例如,服务端完全正确地返回了"认证失败"的 HTTP 401,因此传输层成功,业务调用失败。

三、常见状态码

状态码 常见含义 是否优先重试
200 请求成功 继续检查响应内容
400 请求格式或参数错误 否,先修请求
401 认证失败 否,先修密钥或权限
402 账户或余额相关问题 通常否
422 参数语义不合法 否,先修参数
429 频率或配额受限 视错误详情有限重试
500 服务端内部错误 可以有限重试
503 服务暂时不可用 可以有限重试

状态码是分类入口,不是唯一依据。429 可能来自短期限流,也可能意味着配额已经耗尽;具体还要看响应体和供应商文档。

四、解析 API 错误响应

很多 API 在非成功状态下仍会返回 JSON:

json 复制代码
{
  "error": {
    "message": "Invalid API key",
    "type": "authentication_error",
    "param": null,
    "code": "invalid_api_key"
  }
}

这是用于说明结构的示例,字段和值应以实际响应为准。

可以设计如下结构:

go 复制代码
type APIErrorResponse struct {
	Error APIErrorDetail `json:"error"`
}

type APIErrorDetail struct {
	Message string  `json:"message"`
	Type    string  `json:"type"`
	Param   *string `json:"param"`
	Code    string  `json:"code"`
}

Param 使用指针,因为错误发生时这个字段可能为 null,也可能缺失。

再定义一个能保留状态码的业务错误:

go 复制代码
type APIError struct {
	StatusCode int
	Type       string
	Code       string
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf(
		"API 请求失败:HTTP %d, code=%s, message=%s",
		e.StatusCode,
		e.Code,
		e.Message,
	)
}

上层可以用 errors.As 取出它:

go 复制代码
var apiErr *APIError
if errors.As(err, &apiErr) {
	switch apiErr.StatusCode {
	case http.StatusUnauthorized:
		// 认证配置错误
	case http.StatusTooManyRequests:
		// 限流或配额问题
	}
}

五、完整客户端示例

下面的 main.go 将模型调用封装成一个返回 error 的函数。它不负责重试,但已经能把错误分类交给上层。

go 复制代码
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

const (
	apiURL       = "https://api.deepseek.com/chat/completions"
	defaultModel = "deepseek-flash"
)

type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatRequest struct {
	Model    string        `json:"model"`
	Messages []ChatMessage `json:"messages"`
}

type ChatResponse struct {
	ID      string `json:"id"`
	Model   string `json:"model"`
	Choices []struct {
		Message      ChatMessage `json:"message"`
		FinishReason string      `json:"finish_reason"`
	} `json:"choices"`
}

type APIErrorResponse struct {
	Error APIErrorDetail `json:"error"`
}

type APIErrorDetail struct {
	Message string  `json:"message"`
	Type    string  `json:"type"`
	Param   *string `json:"param"`
	Code    string  `json:"code"`
}

type APIError struct {
	StatusCode int
	Type       string
	Code       string
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf(
		"API 请求失败:HTTP %d, code=%s, message=%s",
		e.StatusCode,
		e.Code,
		e.Message,
	)
}

func callModel(apiKey, model, prompt string) (string, error) {
	if strings.TrimSpace(apiKey) == "" {
		return "", errors.New("DEEPSEEK_API_KEY 未设置")
	}

	requestData := ChatRequest{
		Model: model,
		Messages: []ChatMessage{
			{Role: "user", Content: prompt},
		},
	}

	body, err := json.Marshal(requestData)
	if err != nil {
		return "", fmt.Errorf("序列化请求失败: %w", err)
	}

	req, err := http.NewRequest(
		http.MethodPost,
		apiURL,
		bytes.NewReader(body),
	)
	if err != nil {
		return "", fmt.Errorf("创建 HTTP 请求失败: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 90 * time.Second}

	start := time.Now()
	resp, err := client.Do(req)
	if err != nil {
		// 不记录 API Key 或完整请求头。
		log.Printf(
			"模型请求传输失败,耗时=%s,错误=%v",
			time.Since(start),
			err,
		)
		return "", fmt.Errorf("发送模型请求失败: %w", err)
	}
	defer resp.Body.Close()

	log.Printf(
		"模型请求收到响应,status=%d,耗时=%s",
		resp.StatusCode,
		time.Since(start),
	)

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		errorBody, readErr := io.ReadAll(
			io.LimitReader(resp.Body, 1<<20),
		)
		if readErr != nil {
			return "", fmt.Errorf(
				"HTTP %d,读取错误响应失败: %w",
				resp.StatusCode,
				readErr,
			)
		}

		var apiErrResp APIErrorResponse
		if err := json.Unmarshal(errorBody, &apiErrResp); err == nil &&
			apiErrResp.Error.Message != "" {
			return "", &APIError{
				StatusCode: resp.StatusCode,
				Type:       apiErrResp.Error.Type,
				Code:       apiErrResp.Error.Code,
				Message:    apiErrResp.Error.Message,
			}
		}

		message := strings.TrimSpace(string(errorBody))
		if message == "" {
			message = "服务器未提供可读取的错误信息"
		}

		return "", &APIError{
			StatusCode: resp.StatusCode,
			Message:    message,
		}
	}

	var result ChatResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("解析模型响应失败: %w", err)
	}

	if len(result.Choices) == 0 {
		return "", errors.New("模型响应异常:choices 为空")
	}

	answer := strings.TrimSpace(result.Choices[0].Message.Content)
	if answer == "" {
		return "", errors.New("模型响应异常:回答内容为空")
	}

	return answer, nil
}

func run() error {
	apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))

	model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
	if model == "" {
		model = defaultModel
	}

	answer, err := callModel(
		apiKey,
		model,
		"请用 Go 代码举例解释什么是 goroutine。",
	)
	if err != nil {
		return err
	}

	fmt.Println("模型回答:")
	fmt.Println(answer)
	return nil
}

func main() {
	log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)

	if err := run(); err != nil {
		log.Printf("调用失败:%v", err)
		os.Exit(1)
	}
}

运行:

powershell 复制代码
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .

成功时,日志中能看到状态码和耗时;失败时,错误会说明它发生在序列化、传输、HTTP 处理还是响应校验阶段。

六、几个关键设计

1. 用 %w 保留错误链

go 复制代码
return "", fmt.Errorf("发送模型请求失败: %w", err)

%w 会包装原始错误,调用方能用 errors.Is 或 errors.As 判断底层原因。%v 主要只是把错误转成文本。

2. 底层函数返回错误,不直接退出进程

模型客户端未来可能运行在 Gin Handler、后台任务或消息消费者中。某次调用失败不应该让整个服务退出。

因此 callModel 只返回 error,由程序入口决定本次命令是否失败。

3. 限制错误体大小

go 复制代码
io.LimitReader(resp.Body, 1<<20)

异常 HTML、代理页面或超长响应不应该无限制地进入内存。读取上限并不能解决所有安全问题,但它能提供基础资源保护。

4. 日志只记录必要字段

适合记录:

  • 请求 ID。
  • HTTP 状态码。
  • 耗时。
  • 模型标识。
  • 错误类型和经过脱敏、截断的消息。

不应记录:

  • Authorization 请求头。
  • API Key。
  • 完整用户隐私数据。
  • 未经审查的全部错误响应。

七、哪些错误适合重试

场景 建议
400、401、402、422 通常不重试,先修正配置或输入
429 遵守 Retry-After 或退避提示,限制次数
500、502、503、504 可以有限重试
连接失败 视具体网络错误决定
读取超时 谨慎,服务端可能已经执行

重试不是免费的。第一次请求可能已经生成内容,只是响应在返回途中丢失;再次调用可能产生重复费用。后续课程会实现有限重试和退避。

八、本课总结

  1. 模型调用错误要按构造、传输、HTTP、解析和业务校验分层处理。
  2. err == nil 不代表状态码成功,2xx 也不代表回答有效。
  3. 自定义 APIError 能保留状态码、错误类型和错误代码。
  4. %w 用于保留错误链,便于上层分类。
  5. 底层客户端返回错误,入口统一决定退出。
  6. 日志要有用、有限、脱敏,不能以调试为理由泄露凭证。

九、课后练习

练习 1:网络错误还是 HTTP 错误

client.Do 返回 err == nil,但 resp.StatusCode == 401。这是哪类问题?

参考答案:这是 HTTP 层错误。客户端已经成功获得服务端响应,只是认证被拒绝。应检查密钥、认证头和账号状态,不应把它当作网络故障反复重试。

练习 2:%v 与 %w

两种写法有什么区别?

go 复制代码
fmt.Errorf("请求失败: %v", err)
fmt.Errorf("请求失败: %w", err)

参考答案 :%v 主要生成文本;%w 会包装原始错误,使上层可以使用 errors.Is 和 errors.As 检查错误链。

练习 3:如何设计日志

模型调用失败时,你会记录哪些字段?哪些字段不能记录?

参考思路:可以记录请求 ID、状态码、耗时、模型标识和脱敏后的错误类型。不能记录 API Key、Authorization 请求头或未经处理的隐私内容;错误体也要限制长度并评估是否包含敏感回显。


下一篇:Go AI Agent 实战 13|大模型流式输出 Streaming 的实现

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
坤坤子吖1 小时前
C++继承:继承的基本用法与访问规则
开发语言·c++·笔记·学习
星马梦缘1 小时前
JetAuto VLN语义目标探索
人工智能·数码相机
好焊孙辉1 小时前
从六代螺柱焊机到AI质检:鸿栢科技的螺柱焊国产化路径
人工智能·python·科技·fpga开发
美狐美颜sdk1 小时前
直播APP开发中的视频帧处理:美颜SDK接入需要注意哪些细节?
大数据·前端·人工智能·音视频·美颜sdk
miofly1 小时前
Kimi K3.1 Pro 跻身智能榜第四,开源权重模型逼近头部闭源水平
人工智能
行走的小派1 小时前
Harmony输入法框架解析:IME架构、多模式输入引擎与语音识别链路
人工智能·架构·语音识别
LMT流氓兔1 小时前
第五单元——第10课:用自动化测试验证工具逻辑
数据库·ai·oracle·langchain
Demon--hx1 小时前
C++ 文件流:理解 ifstream/ofstream 与 RAII 机制
开发语言·c++
UMI赋能企业1 小时前
AI实战交流的提问模板:从业务描述到可分析的问题
人工智能·工作流·ai应用