BodySense 错误处理策略
BodySense 项目的错误处理架构:Go 后端统一错误模型、LLM 错误降级、前端错误处理、用户友好错误信息。
#type / concept
#status / growing
#tech / dev / backend
#tech / ai
[!info] related notes
- 知识地图: BodySense 项目 MOC
- 分层: Handler-Service-Repository 分层
- AI 服务: LLM Provider 抽象层
BodySense 错误处理策略
错误处理架构
┌─────────────────────────────────────────────────────────┐
│ 前端 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ API 错误 │ │ SSE 错误 │ │ 网络错误 │ │
│ │ 拦截器 │ │ 处理器 │ │ 重试 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Go 后端 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Handler │ │ Service │ │ Repository │ │
│ │ 错误转换 │ │ 业务错误 │ │ 数据错误 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 统一错误处理中间件 │ │
│ │ - 错误分类 - 日志记录 - 用户友好信息 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ AI 服务 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ LLM 错误 │ │ RAG 错误 │ │ 超时错误 │ │
│ │ 降级处理 │ │ 降级处理 │ │ 重试 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
1. 统一错误模型
Go 后端错误类型
// errors/types.go
type AppError struct {
Code string `json:"code"` // 业务错误码
Message string `json:"message"` // 用户友好信息
Detail string `json:"detail,omitempty"` // 开发者详情
HTTPStatus int `json:"-"` // HTTP 状态码
Err error `json:"-"` // 原始错误
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Code, e.Err)
}
return e.Code
}
func (e *AppError) Unwrap() error {
return e.Err
}
错误码定义
// errors/codes.go
var (
// 认证相关 (1xxx)
ErrInvalidCredentials = &AppError{
Code: "AUTH_001", Message: "invalid email or password",
HTTPStatus: 401,
}
ErrTokenExpired = &AppError{
Code: "AUTH_002", Message: "token expired",
HTTPStatus: 401,
}
ErrTokenRevoked = &AppError{
Code: "AUTH_003", Message: "token revoked",
HTTPStatus: 401,
}
ErrAccountLocked = &AppError{
Code: "AUTH_004", Message: "account locked",
HTTPStatus: 423,
}
// 业务相关 (2xxx)
ErrSessionNotFound = &AppError{
Code: "SESSION_001", Message: "session not found",
HTTPStatus: 404,
}
ErrSessionCompleted = &AppError{
Code: "SESSION_002", Message: "session already completed",
HTTPStatus: 409,
}
ErrInsufficientInfo = &AppError{
Code: "SESSION_003", Message: "insufficient information for analysis",
HTTPStatus: 400,
}
// AI 服务相关 (3xxx)
ErrLLMUnavailable = &AppError{
Code: "AI_001", Message: "AI service temporarily unavailable",
HTTPStatus: 503,
}
ErrLLMTimeout = &AppError{
Code: "AI_002", Message: "AI service timeout",
HTTPStatus: 504,
}
ErrLLMRateLimit = &AppError{
Code: "AI_003", Message: "too many requests, please try again later",
HTTPStatus: 429,
}
ErrRAGNoResults = &AppError{
Code: "AI_004", Message: "no relevant knowledge found",
HTTPStatus: 200, // 不是错误,返回空结果
}
// 通用错误 (9xxx)
ErrInternal = &AppError{
Code: "SYSTEM_001", Message: "internal server error",
HTTPStatus: 500,
}
ErrValidation = &AppError{
Code: "SYSTEM_002", Message: "validation failed",
HTTPStatus: 400,
}
ErrRateLimit = &AppError{
Code: "SYSTEM_003", Message: "too many requests",
HTTPStatus: 429,
}
)
错误构造函数
// errors/errors.go
func NewValidationError(detail string) *AppError {
return &AppError{
Code: ErrValidation.Code,
Message: ErrValidation.Message,
Detail: detail,
HTTPStatus: 400,
}
}
func WrapError(err error, appErr *AppError) *AppError {
return &AppError{
Code: appErr.Code,
Message: appErr.Message,
HTTPStatus: appErr.HTTPStatus,
Err: err,
}
}
func WrapInternal(err error) *AppError {
return &AppError{
Code: ErrInternal.Code,
Message: ErrInternal.Message,
HTTPStatus: 500,
Err: err,
}
}
2. Handler 层错误处理
// handler/errors.go
func handleServiceError(c *gin.Context, err error) {
var appErr *errors.AppError
if errors.As(err, &appErr) {
// 业务错误,返回给用户
c.JSON(appErr.HTTPStatus, gin.H{
"error": gin.H{
"code": appErr.Code,
"message": appErr.Message,
},
})
return
}
// 未知错误,返回通用错误
c.JSON(500, gin.H{
"error": gin.H{
"code": "SYSTEM_001",
"message": "internal server error",
},
})
}
Handler 使用示例
func (h *Handler) CreateSession(c *gin.Context) {
userID := c.GetString("user_id")
uid, _ := uuid.Parse(userID)
session, err := h.sessionService.CreateSession(c.Request.Context(), uid)
if err != nil {
handleServiceError(c, err)
return
}
c.JSON(201, session)
}
3. Service 层错误处理
// service/session.go
func (s *SessionService) CreateSession(ctx context.Context, userID uuid.UUID) (*Session, error) {
// 检查是否有进行中的会话
existing, err := s.repo.GetLastInProgress(ctx, userID)
if err != nil {
return nil, errors.WrapInternal(err)
}
if existing != nil {
return existing, nil // 复用现有会话
}
session := &Session{
ID: uuid.New(),
UserID: userID,
Status: "in_progress",
Phase: "collecting",
}
if err := s.repo.Create(ctx, session); err != nil {
return nil, errors.WrapInternal(err)
}
return session, nil
}
func (s *SessionService) GenerateDiagnosis(ctx context.Context, sessionID uuid.UUID) (*Session, error) {
session, err := s.repo.GetByID(ctx, sessionID)
if err != nil {
return nil, errors.WrapInternal(err)
}
if session == nil {
return nil, errors.ErrSessionNotFound
}
if session.Phase != "analysis_ready" {
return nil, errors.NewValidationError("session not ready for diagnosis")
}
// 调用 AI 服务
diagnosis, err := s.aiService.GenerateDiagnosis(ctx, session)
if err != nil {
return nil, err // AI 错误向上传播
}
session.Diagnosis = diagnosis
session.Phase = "diagnosis_confirmed"
if err := s.repo.Update(ctx, session); err != nil {
return nil, errors.WrapInternal(err)
}
return session, nil
}
4. AI 服务错误处理
LLM 错误分类
// ai/errors.go
type LLMError struct {
Type string // 'timeout' | 'rate_limit' | 'invalid_response' | 'api_error'
Message string
Err error
Retryable bool // 是否可重试
}
func classifyLLMError(err error) *LLMError {
errStr := err.Error()
switch {
case strings.Contains(errStr, "timeout"):
return &LLMError{Type: "timeout", Message: "LLM request timeout", Err: err, Retryable: true}
case strings.Contains(errStr, "rate_limit"):
return &LLMError{Type: "rate_limit", Message: "LLM rate limit exceeded", Err: err, Retryable: true}
case strings.Contains(errStr, "invalid JSON"):
return &LLMError{Type: "invalid_response", Message: "LLM returned invalid JSON", Err: err, Retryable: false}
default:
return &LLMError{Type: "api_error", Message: "LLM API error", Err: err, Retryable: false}
}
}
LLM 重试机制
// ai/retry.go
func WithRetry(ctx context.Context, maxRetries int, fn func() error) error {
var lastErr error
for i := 0; i <= maxRetries; i++ {
err := fn()
if err == nil {
return nil
}
lastErr = err
llmErr := classifyLLMError(err)
if !llmErr.Retryable {
return err // 不可重试,直接返回
}
// 指数退避
if i < maxRetries {
backoff := time.Duration(1<<uint(i)) * time.Second
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
// 继续重试
}
}
}
return lastErr
}
LLM 降级策略
// ai/provider.go
func (p *Provider) GenerateDiagnosis(ctx context.Context, session *Session) (string, error) {
var diagnosis string
var err error
// 尝试主模型
err = WithRetry(ctx, 2, func() error {
diagnosis, err = p.callLLM(ctx, "gpt-4o", session)
return err
})
if err != nil {
// 主模型失败,尝试备用模型
err = WithRetry(ctx, 1, func() error {
diagnosis, err = p.callLLM(ctx, "gpt-4o-mini", session)
return err
})
}
if err != nil {
// 所有模型都失败,返回降级响应
return "", errors.WrapError(err, errors.ErrLLMUnavailable)
}
return diagnosis, nil
}
RAG 错误处理
// ai/rag.go
func (r *RAGService) Search(ctx context.Context, query string) ([]SearchResult, error) {
// 1. 生成 query embedding
embedding, err := r.embeddingService.Generate(ctx, query)
if err != nil {
// embedding 失败,降级到关键词搜索
return r.keywordSearch(ctx, query)
}
// 2. 向量搜索
results, err := r.vectorStore.Search(ctx, embedding, 10)
if err != nil {
return nil, errors.WrapError(err, errors.ErrInternal)
}
// 3. 如果没有结果,降级到关键词搜索
if len(results) == 0 {
return r.keywordSearch(ctx, query)
}
return results, nil
}
5. SSE 错误处理
Go 后端 SSE 错误
// handler/consultation.go
func (h *ConsultationHandler) StreamMessage(c *gin.Context) {
// 设置 SSE 头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
// 创建 SSE writer
writer := sse.NewWriter(c.Writer)
// 处理 panic
defer func() {
if r := recover(); r != nil {
writer.WriteEvent("error", gin.H{
"code": "SYSTEM_001",
"message": "internal server error",
})
writer.WriteEvent("done", nil)
}
}()
// 处理客户端断开
ctx := c.Request.Context()
go func() {
<-ctx.Done()
// 客户端断开,清理资源
}()
// 处理消息
err := h.consultationService.ProcessMessage(ctx, sessionID, message, writer)
if err != nil {
var appErr *errors.AppError
if errors.As(err, &appErr) {
writer.WriteEvent("error", gin.H{
"code": appErr.Code,
"message": appErr.Message,
})
} else {
writer.WriteEvent("error", gin.H{
"code": "SYSTEM_001",
"message": "internal server error",
})
}
}
writer.WriteEvent("done", nil)
}
SSE 事件类型
// sse/events.go
type SSEEvent struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
// 事件类型
const (
EventTypeText = "text"
EventTypeExtractedInfo = "extracted_info"
EventTypePhaseChange = "phase_changed"
EventTypeRedFlag = "red_flag"
EventTypeCitation = "citation"
EventTypeError = "error"
EventTypeDone = "done"
)
6. 前端错误处理
API 错误拦截器
// api/errors.ts
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.code = code;
this.status = status;
}
}
api.interceptors.response.use(
(res) => res,
(error) => {
if (error.response) {
const { data, status } = error.response;
throw new ApiError(
data.error?.code || 'UNKNOWN',
data.error?.message || 'An error occurred',
status,
);
}
if (error.request) {
throw new ApiError('NETWORK', 'Network error', 0);
}
throw error;
},
);
SSE 错误处理
// hooks/useChatSSE.ts
function useChatSSE(callbacks: SSECallbacks) {
const [error, setError] = useState<string | null>(null);
const processEvent = (event: SSEEvent) => {
switch (event.type) {
case 'error':
setError(event.data.message);
callbacks.onError?.(event.data);
break;
case 'done':
callbacks.onDone?.(event.data);
break;
// ... 其他事件
}
};
const sendMessage = async (sessionId: string, content: string) => {
setError(null);
try {
const response = await fetch(`/api/v1/consultation/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!response.ok) {
const error = await response.json();
throw new ApiError(error.code, error.message, response.status);
}
// 处理 SSE 流
const reader = response.body?.getReader();
// ...
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unknown error');
}
};
return { sendMessage, error };
}
用户友好错误信息
// utils/errorMessages.ts
const ERROR_MESSAGES: Record<string, string> = {
AUTH_001: '邮箱或密码错误',
AUTH_002: '登录已过期,请重新登录',
AUTH_003: '登录已失效,请重新登录',
AUTH_004: '账户已被锁定,请稍后再试',
SESSION_001: '会话不存在',
SESSION_002: '会话已结束',
SESSION_003: '信息不足,请补充更多症状描述',
AI_001: 'AI 服务暂时不可用,请稍后再试',
AI_002: '请求超时,请重试',
AI_003: '请求过于频繁,请稍后再试',
SYSTEM_001: '系统错误,请稍后再试',
SYSTEM_002: '输入格式错误',
SYSTEM_003: '请求过于频繁,请稍后再试',
NETWORK: '网络连接失败,请检查网络',
};
export function getErrorMessage(code: string): string {
return ERROR_MESSAGES[code] || '未知错误';
}
错误展示组件
// components/ErrorAlert.tsx
interface ErrorAlertProps {
error: ApiError | null;
onRetry?: () => void;
onDismiss?: () => void;
}
export function ErrorAlert({ error, onRetry, onDismiss }: ErrorAlertProps) {
if (!error) return null;
const message = getErrorMessage(error.code);
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3">
<p className="text-sm text-red-800">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-sm text-red-600 underline"
>
重试
</button>
)}
</div>
{onDismiss && (
<button onClick={onDismiss} className="ml-auto">
<XMarkIcon className="h-5 w-5 text-red-400" />
</button>
)}
</div>
</div>
);
}
7. 日志记录
结构化日志
// logger/logger.go
type Logger struct {
*slog.Logger
}
func (l *Logger) Error(msg string, err error, attrs ...slog.Attr) {
attrs = append(attrs, slog.String("error", err.Error()))
var appErr *errors.AppError
if errors.As(err, &appErr) {
attrs = append(attrs,
slog.String("error_code", appErr.Code),
slog.Int("http_status", appErr.HTTPStatus),
)
}
l.Logger.Error(msg, attrs...)
}
错误日志示例
// 在 Handler 中记录错误
func (h *Handler) CreateSession(c *gin.Context) {
session, err := h.sessionService.CreateSession(ctx, userID)
if err != nil {
h.logger.Error("failed to create session", err,
slog.String("user_id", userID),
)
handleServiceError(c, err)
return
}
// ...
}
8. 监控与告警
错误率指标
// metrics/errors.go
var (
errorCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "app_errors_total",
Help: "Total number of errors",
},
[]string{"code", "method", "path"},
)
)
func RecordError(code, method, path string) {
errorCounter.WithLabelValues(code, method, path).Inc()
}
告警规则
# prometheus/alerts.yml
groups:
- name: app_errors
rules:
- alert: HighErrorRate
expr: rate(app_errors_total[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate detected"
- alert: AIServiceDown
expr: rate(app_errors_total{code="AI_001"}[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "AI service is down"
常见面试问题
Q: 你的错误处理策略是什么?
A:
- 分层处理:Handler 转换、Service 业务逻辑、Repository 数据访问
- 统一模型:所有错误都用 AppError,包含错误码、用户信息、HTTP 状态
- 错误分类:业务错误返回给用户,系统错误记录日志
- 降级策略:LLM 失败重试,RAG 失败降级到关键词搜索
Q: LLM 返回错误怎么办?
A:
- 分类错误:timeout/rate_limit 可重试,invalid_response 不可重试
- 指数退避:重试间隔 1s, 2s, 4s…
- 备用模型:主模型失败尝试备用模型
- 降级响应:所有模型失败返回友好错误信息
Q: 怎么保证用户看到友好的错误信息?
A:
- 错误码映射:后端定义错误码,前端映射到中文
- 不暴露内部细节:用户看到”系统错误”,开发者看到堆栈
- 国际化支持:错误信息支持多语言
常见错误
吞掉错误
// ❌ 吞掉错误,不记录
result, err := doSomething()
if err != nil {
return nil
}
// ✅ 记录错误
result, err := doSomething()
if err != nil {
h.logger.Error("doSomething failed", err)
return nil, errors.WrapInternal(err)
}
暴露内部错误
// ❌ 把数据库错误返回给用户
c.JSON(500, gin.H{"error": err.Error()})
// ✅ 返回友好信息
c.JSON(500, gin.H{"error": gin.H{
"code": "SYSTEM_001",
"message": "internal server error",
}})
不处理 SSE 错误
// ❌ SSE 出错不通知前端
err := processMessage(ctx, sessionID, message, writer)
if err != nil {
// 只记录日志,前端不知道出错了
log.Error(err)
}
// ✅ 通知前端
err := processMessage(ctx, sessionID, message, writer)
if err != nil {
writer.WriteEvent("error", gin.H{"message": "processing failed"})
writer.WriteEvent("done", nil)
}