多 agent 并行批次的协调提交(消除全部共享文件冲突面): - 迁移 0004:id_session + ob_event 两表(T3/T4 用,任务不再写迁移) - ORM:UserSession / ObEvent;错误码:九个新码(40xxx/60006,含 meta) - 配置:四档 tiers / refresh TTL / 任务奖励注册表 / 埋点上限 / COS 直传全套 - 骨架:packages/analytics + packages/storage + events/uploads 空路由(已注册) - deps:AnalyticsServiceDep / StorageServiceDep;DailyBudgetGuard 接收 tiers - uv add cos-python-sdk-v5;import-linter/CI/Makefile 纳入两个新包
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
"""Unified error codes (架构文档 §9.2, FR-SYS-02).
|
|
|
|
Segments:
|
|
``4xxxx`` client error, ``5xxxx`` server error, ``6xxxx`` external dependency.
|
|
|
|
Splitting 5xxxx from 6xxxx lets monitoring separate "our bug" from "vendor
|
|
outage" without parsing messages.
|
|
|
|
Every code carries a user-readable ``message`` and an optional ``hint`` telling
|
|
the user what to do next — required by FR-SYS-02 and NFR-UX-02.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import IntEnum
|
|
from typing import Any
|
|
|
|
|
|
class ErrorCode(IntEnum):
|
|
OK = 0
|
|
|
|
# --- 4xxxx client -----------------------------------------------------
|
|
BAD_REQUEST = 40000
|
|
VALIDATION_FAILED = 40001
|
|
IDEMPOTENCY_CONFLICT = 40002
|
|
UPLOAD_REJECTED = 40010
|
|
EVENTS_BATCH_REJECTED = 40020
|
|
UNAUTHENTICATED = 40100
|
|
TOKEN_EXPIRED = 40101
|
|
TOKEN_INVALID = 40102
|
|
REFRESH_TOKEN_INVALID = 40103
|
|
REFRESH_TOKEN_EXPIRED = 40104
|
|
SESSION_REVOKED = 40105
|
|
FORBIDDEN = 40300
|
|
QUOTA_EXHAUSTED = 40301
|
|
CONSENT_REQUIRED = 40302
|
|
MINOR_RESTRICTED = 40303
|
|
TASK_BONUS_CAP_REACHED = 40310
|
|
NOT_FOUND = 40400
|
|
TASK_BONUS_UNKNOWN = 40410
|
|
STATE_CONFLICT = 40900
|
|
TASK_BONUS_ALREADY_CLAIMED = 40910
|
|
RATE_LIMITED = 42900
|
|
|
|
# --- 5xxxx server -----------------------------------------------------
|
|
INTERNAL_ERROR = 50000
|
|
DB_ERROR = 50001
|
|
TASK_STATE_INVALID = 50002
|
|
CONFIG_ERROR = 50003
|
|
NOT_IMPLEMENTED = 50100
|
|
|
|
# --- 6xxxx external dependency ---------------------------------------
|
|
UPSTREAM_ERROR = 60000
|
|
LLM_ALL_PROVIDERS_FAILED = 60001
|
|
LLM_STRUCTURED_OUTPUT_FAILED = 60002
|
|
LLM_DAILY_BUDGET_EXCEEDED = 60003
|
|
LLM_PROMPT_CACHE_VIOLATION = 60004
|
|
MODERATION_UNAVAILABLE = 60005
|
|
LLM_SCENE_DEGRADED = 60006
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ErrorMeta:
|
|
http_status: int
|
|
message: str
|
|
hint: str | None = None
|
|
|
|
|
|
ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
|
ErrorCode.OK: ErrorMeta(200, "ok"),
|
|
ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"),
|
|
ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"),
|
|
ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta(
|
|
409, "相同请求正在处理中", "请稍后查看任务中心,不要重复提交"
|
|
),
|
|
ErrorCode.UPLOAD_REJECTED: ErrorMeta(
|
|
400, "不支持的图片类型或超出大小限制", "请上传 20MB 以内的 JPG/PNG/WebP 图片"
|
|
),
|
|
ErrorCode.EVENTS_BATCH_REJECTED: ErrorMeta(400, "埋点数据格式有误"),
|
|
ErrorCode.UNAUTHENTICATED: ErrorMeta(401, "请先登录", "登录后即可继续"),
|
|
ErrorCode.TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
|
|
ErrorCode.TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"),
|
|
ErrorCode.REFRESH_TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"),
|
|
ErrorCode.REFRESH_TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
|
|
ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"),
|
|
ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
|
|
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
|
|
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
|
|
ErrorCode.MINOR_RESTRICTED: ErrorMeta(403, "该功能未向未成年人开放"),
|
|
ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta(
|
|
403, "任务奖励已达上限", "明天再来,或等额度自然恢复"
|
|
),
|
|
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
|
|
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
|
|
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
|
|
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
|
|
409, "这个周期的任务奖励已领取", "下个周期再来"
|
|
),
|
|
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
|
|
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
|
|
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
|
|
ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请到任务中心重试"),
|
|
ErrorCode.CONFIG_ERROR: ErrorMeta(500, "服务配置异常", "请联系客服"),
|
|
ErrorCode.NOT_IMPLEMENTED: ErrorMeta(501, "该能力尚未开放"),
|
|
ErrorCode.UPSTREAM_ERROR: ErrorMeta(502, "外部服务暂时不可用", "请稍后重试"),
|
|
ErrorCode.LLM_ALL_PROVIDERS_FAILED: ErrorMeta(
|
|
502, "AI 服务暂时不可用", "请稍后重试,额度不会被扣除"
|
|
),
|
|
ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED: ErrorMeta(
|
|
502, "AI 返回结果格式异常", "请稍后重试,额度不会被扣除"
|
|
),
|
|
ErrorCode.LLM_DAILY_BUDGET_EXCEEDED: ErrorMeta(
|
|
503, "AI 能力今日已达服务上限", "请明天再试,核心功能不受影响"
|
|
),
|
|
ErrorCode.LLM_PROMPT_CACHE_VIOLATION: ErrorMeta(500, "服务配置异常", "请联系客服"),
|
|
ErrorCode.MODERATION_UNAVAILABLE: ErrorMeta(
|
|
503, "内容安全服务不可用", "为保证安全本次请求已中止,请稍后重试"
|
|
),
|
|
ErrorCode.LLM_SCENE_DEGRADED: ErrorMeta(
|
|
503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复"
|
|
),
|
|
}
|
|
|
|
|
|
class AppError(Exception):
|
|
"""Base application error carrying a unified error code.
|
|
|
|
Never let a raw traceback reach the client (FR-SYS-02).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
code: ErrorCode,
|
|
*,
|
|
message: str | None = None,
|
|
hint: str | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
) -> None:
|
|
meta = ERROR_META.get(code, ERROR_META[ErrorCode.INTERNAL_ERROR])
|
|
self.code = code
|
|
self.message = message or meta.message
|
|
self.hint = hint if hint is not None else meta.hint
|
|
self.http_status = meta.http_status
|
|
self.data = data or {}
|
|
super().__init__(f"[{int(code)}] {self.message}")
|
|
|
|
|
|
class QuotaExhaustedError(AppError):
|
|
def __init__(self, *, required: int, remaining: int, reset_at: str | None = None) -> None:
|
|
super().__init__(
|
|
ErrorCode.QUOTA_EXHAUSTED,
|
|
data={"required": required, "remaining": remaining, "reset_at": reset_at},
|
|
)
|
|
|
|
|
|
class NotFoundError(AppError):
|
|
def __init__(self, message: str | None = None) -> None:
|
|
super().__init__(ErrorCode.NOT_FOUND, message=message)
|
|
|
|
|
|
class ForbiddenError(AppError):
|
|
def __init__(self, message: str | None = None) -> None:
|
|
super().__init__(ErrorCode.FORBIDDEN, message=message)
|