从一次深夜告警说起

凌晨 2:47,你的交易监控系统突然亮起红灯。

你从床上爬起来,打开日志,看到一连串的错误:429 Too Many Requests。你揉了揉眼睛,心想:429?不是限速吗?等一下再试不就完了?

于是你在代码里加了个 time.sleep(5),第二天早上满怀信心地部署了。结果?告警更频繁了——因为你把所有请求都变成了排队,而队列越来越长。

这不是你一个人的故事。这是每个和 API 打交道的工程师都踩过的坑。

问题不在于你不会处理 429,而在于 429 本身就是一个“语义模糊”的错误码:它告诉你超速了,但没告诉你超了多少、该等多久、该找谁。整个行业都在用 429,但 429 从来不是为 API 限速设计的。

TickDB 选择了另一条路:自定义错误码体系。本文拆解这个选择的背后逻辑,以及它如何重构你的错误处理体验。


一、HTTP 429 的历史包袱

要理解 TickDB 为什么这样做,先得理解 429 到底从哪里来。

1.1 429 的真实身份

HTTP 状态码不是凭空发明的,它们属于 HTTP/1.1 规范(RFC 2616,后被 RFC 7231 取代)。429 的全称是 Too Many Requests,最初用于描述用户在给定时间内发送了太多请求

但请注意这个措辞:"用户"。在 HTTP 的语境里,"用户"指的是浏览器背后的那个人,而不是调用你 API 的代码

维度 HTTP 429 的设计初衷 实际 API 场景
触发主体 人类用户的浏览器行为 程序化的高频 API 调用
适用规模 单用户单会话 多租户、多端、多进程并发
响应策略 建议用户“喝杯咖啡等一会儿” 需要精确的重试时间
标准化程度 浏览器有约定俗成的处理方式 各家 API 实现各异

这不是 429 的错——它在自己的场景里是合理的。但把它套用到 API 限速上,就像用锤子拧螺丝:能用,但总觉得哪里不对。

1.2 429 的三大原罪

第一罪:没有告诉客户端该等多久。

RFC 6585(2012年补充)给 429 增加了一个 Retry-After 头,但它是可选的。现实是,大量主流 API 返回 429 时根本不带这个头:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{"error": "Rate limit exceeded"}

客户端只能靠猜:等 1 秒?5 秒?30 秒?指数退避?没有一个标准答案。

第二罪:状态码和业务语义绑定。

HTTP 状态码的设计哲学是“网络层结果”,而不是“业务层诊断”。当你看到 429,你知道的是“请求被拒绝了”,但你不知道:

  • 哪个维度超了?(单接口?全局?月度配额?)
  • 硬限制还是软限制
  • 下次重试会不会直接被封 IP?

这些信息对于构建健壮的 API 客户端至关重要,但 429 无法携带。

第三罪:429 和其他 4xx 混在一起,难以分类。

在你的错误处理逻辑里,429 和 400(参数错误)、401(认证失败)、404(资源不存在)用的是同一套处理框架。但它们的性质完全不同:

# 你现在的错误处理可能是这样的
if response.status_code == 400:
    raise ValidationError(response.json())
elif response.status_code == 401:
    raise AuthError(response.json())
elif response.status_code == 429:
    # 等等,这个该抛异常还是等待重试?
    time.sleep(5)  # 又是猜
elif response.status_code == 500:
    raise ServerError(response.json())

429 打破了“所有 4xx 都是客户端错误,都应该抛异常”的直觉。它需要的是特殊对待,但 HTTP 语义没有给它这个能力。


二、TickDB 的错误码设计哲学

面对 429 的局限性,TickDB 选择了自定义错误码体系。这不是一个轻率的决定——它背后有一套完整的设计哲学。

2.1 分层架构:从大类到细粒度

TickDB 的错误码不是一串随机数字,而是一个结构化的诊断系统

错误码前缀 大类 含义 处理策略
1xxx 认证与鉴权 API Key 相关问题 检查配置
2xxx 资源不存在 交易品种、标的代码错误 修正参数
3xxx 限速相关 请求频率超限 按 Retry-After 等待
4xxx 服务端问题 内部错误 指数退避重试
5xxx 参数错误 请求格式、必填字段缺失 检查请求体

这个架构的好处是:错误码本身就是分类器。看到前缀,你立刻知道该用什么策略应对。

2.2 为什么是 3001?

3001 是 TickDB 限速错误码体系的核心:

子码 含义 标准响应头
3001 请求频率超限 Retry-After(必带)
3002 月度配额超限 无(需升级方案)
3003 并发连接数超限 Retry-After(必带)

3001 不是一个孤立的数字,它是限速诊断协议的一部分:

{
  "code": 3001,
  "message": "请求频率超限",
  "detail": "当前请求频率为每秒 12 次,限制为每秒 10 次",
  "retry_after": 1
}

关键字段:

  • code: 3001:明确的限速诊断
  • detail:超了多少、限制是多少,一目了然
  • retry_after:精确的等待时间(秒),不再靠猜

2.3 Retry-After:被 429 遗忘的标准

这里要单独说说 Retry-After。这是 HTTP 规范里早已存在但长期被忽视的头部字段。

RFC 7231 Section 7.1.3 定义了它的两种格式:

Retry-After: <seconds>
Retry-After: <http-date>

第一种是整数秒数(最常用),第二种是 HTTP 日期格式(用于未来的某个时间点)。

问题是:HTTP 规范只说它是"给 429 用的",但没有强制要求所有 429 都必须返回它。这导致实践中大量 429 响应没有 Retry-After,让客户端无所适从。

TickDB 的选择是:Retry-After 成为 3001 响应的必带字段。这不是发明新标准,而是严格执行已有标准

# 伪代码:TickDB 3001 响应的标准格式
{
    "code": 3001,
    "retry_after": 1,  # 同时出现在 body 中
    "headers": {
        "Retry-After": "1"  # 同时出现在 HTTP 头中
    }
}

双通道设计确保:无论你用的是 REST 客户端还是手写解析,都能拿到这个关键信息。


三、统一错误码体系的工程价值

理论说完了,来点实际的。这个设计对你有什么用?

3.1 可预测的错误处理

有了结构化的错误码,你的代码可以写得非常优雅:

import time
import os
import requests
from typing import Optional

class TickDBError(Exception):
    """TickDB 错误基类"""
    def __init__(self, code: int, message: str, detail: str = None):
        self.code = code
        self.message = message
        self.detail = detail
        super().__init__(f"[{code}] {message}: {detail}")

class RateLimitError(TickDBError):
    """限速错误,需要等待后重试"""
    def __init__(self, code: int, message: str, retry_after: int = 1, **kwargs):
        self.retry_after = retry_after
        super().__init__(code, message, **kwargs)

def handle_response(response: requests.Response) -> dict:
    """
    TickDB 标准响应处理
    返回 data 字段内容,错误时抛出对应的异常类型
    """
    try:
        data = response.json()
    except ValueError:
        # 非 JSON 响应通常是网络层问题
        raise ConnectionError(f"非预期响应格式: {response.status_code}")
    
    code = data.get("code", 0)
    
    if code == 0:
        return data.get("data")
    
    # 认证错误(1xxx):配置问题,抛出异常
    if 1000 <= code < 2000:
        raise TickDBError(
            code=code,
            message=data.get("message", "认证失败"),
            detail=data.get("detail")
        )
    
    # 资源不存在(2xxx):参数问题,抛出异常
    if 2000 <= code < 3000:
        raise TickDBError(
            code=code,
            message=data.get("message", "资源不存在"),
            detail=data.get("detail")
        )
    
    # 限速错误(3001/3003):按 Retry-After 等待
    if code in (3001, 3003):
        retry_after = int(data.get("retry_after", 1))
        raise RateLimitError(
            code=code,
            message=data.get("message", "请求频率超限"),
            retry_after=retry_after,
            detail=data.get("detail")
        )
    
    # 月度配额超限(3002):需要升级,不重试
    if code == 3002:
        raise TickDBError(
            code=code,
            message=data.get("message", "配额超限"),
            detail="请访问 tickdb.ai 了解升级方案"
        )
    
    # 服务端错误(4xxx):指数退避重试
    if 4000 <= code < 5000:
        raise TickDBError(
            code=code,
            message=data.get("message", "服务端错误"),
            detail=data.get("detail")
        )
    
    # 参数错误(5xxx):修正请求后重试
    if 5000 <= code < 6000:
        raise TickDBError(
            code=code,
            message=data.get("message", "参数错误"),
            detail=data.get("detail")
        )
    
    # 未知错误
    raise TickDBError(
        code=code,
        message=data.get("message", "未知错误"),
        detail=data.get("detail")
    )

注意这个设计的精妙之处:错误码前缀本身就是处理逻辑的分类键。你不需要记住每个具体的错误码,只需要记住几个大类。

3.2 优雅的重试循环

基于这个错误体系,你可以写出极其清晰的重试逻辑:

import random
import time
from requests.exceptions import RequestException

def fetch_with_retry(
    url: str,
    params: dict = None,
    max_retries: int = 5,
    base_delay: float = 1.0,
    max_delay: float = 32.0
) -> dict:
    """
    带智能重试的 TickDB 请求
    
    策略:
    - 限速错误(3001/3003):等待 Retry-After,不计入重试次数
    - 服务端错误(4xxx):指数退避 + 抖动,最大重试 max_retries 次
    - 其他错误:立即抛出
    """
    api_key = os.environ.get("TICKDB_API_KEY")
    headers = {
        "X-API-Key": api_key,
        "Content-Type": "application/json"
    }
    
    for attempt in range(max_retries):
        try:
            response = requests.get(
                url,
                headers=headers,
                params=params,
                timeout=(3.05, 10)  # 连接超时 3.05s,读取超时 10s
            )
            return handle_response(response)
            
        except RateLimitError as e:
            # 限速错误:使用服务指定的等待时间,不计入重试次数
            print(f"[TickDB] 限速触发,等待 {e.retry_after}s")
            time.sleep(e.retry_after)
            # 重试,但不增加 attempt 计数
            
        except TickDBError as e:
            # 其他业务错误:直接抛出,不重试
            if e.code in (1001, 1002):  # 认证错误
                raise  # 不重试,直接暴露
            if 2000 <= e.code < 3000:  # 参数错误
                raise  # 不重试
            if e.code == 3002:  # 配额超限
                raise  # 不重试
            
            # 服务端错误:走下面的指数退避
            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = random.uniform(0, delay * 0.1)
            wait_time = delay + jitter
            print(f"[TickDB] 服务端错误 {e.code},{wait_time:.2f}s 后重试 ({attempt + 1}/{max_retries})")
            time.sleep(wait_time)
            
        except (ConnectionError, RequestException) as e:
            # 网络层错误:指数退避
            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = random.uniform(0, delay * 0.1)
            wait_time = delay + jitter
            print(f"[TickDB] 网络错误 {e},{wait_time:.2f}s 后重试 ({attempt + 1}/{max_retries})")
            time.sleep(wait_time)
    
    raise RuntimeError(f"超过最大重试次数 {max_retries}")

这段代码的优雅之处在于每种错误都有明确的处理语义

  • 3001 → 等待 retry_after不计入重试次数(这是合理重试,不是失败)
  • 4xxx → 指数退避 + 抖动,计入重试次数(可能是临时故障)
  • 其他 → 直接抛出(重试也没用)

3.3 调试与监控

结构化错误码还有一个被忽视的价值:监控和调试

import logging
from collections import defaultdict

class ErrorTracker:
    """错误码频率追踪(用于监控告警)"""
    def __init__(self):
        self.error_counts = defaultdict(int)
        self.logger = logging.getLogger(__name__)
    
    def record(self, code: int):
        self.error_counts[code] += 1
        self.logger.warning(f"[TickDB Error] code={code}")
    
    def get_top_errors(self, n: int = 5):
        """获取 Top N 错误码"""
        return sorted(
            self.error_counts.items(),
            key=lambda x: x[1],
            reverse=True
        )[:n]
    
    def check_anomaly(self):
        """异常检测:限速错误占比过高可能意味着限速配置不合理"""
        total = sum(self.error_counts.values())
        if total == 0:
            return
        
        rate_limit_ratio = sum(
            count for code, count in self.error_counts.items()
            if 3000 <= code < 4000
        ) / total
        
        if rate_limit_ratio > 0.5:
            self.logger.error(
                f"限速错误占比 {rate_limit_ratio:.1%},"
                "建议检查请求频率或升级套餐"
            )

你可以把这个 tracker 集成到你的监控系统里,当某类错误码的占比异常时自动告警。


四、横向对比:为什么这套体系比 429 更优?

4.1 语义精确度对比

维度 HTTP 429 TickDB 3001
错误类型 模糊(只知道超速了) 精确(频率/月度/并发)
超限详情 包含当前值和限制值
重试时间 可选 Retry-After 必带 retry_after
错误分类 属于 4xx,和参数错误混在一起 独立的 3xxx 体系
客户端处理 需要大量 if-else 判断 错误码前缀即可分类

4.2 主流 API 的 429 实况

让我们看看几家主流数据 API 对限速错误的处理:

API 错误码 Retry-After 额外信息
Polygon 429 ✅ 有 只支持 HTTP 头
Alpaca 403 (forbidden) ❌ 无 需查文档才知道是限速
Binance -529 ❌ 无 自定义码,文档位置偏僻
Interactive Brokers 100 ❌ 无 错误码和中文消息混用
TickDB 3001 ✅ 有(body + header) 当前值/限制值/精确时间

可以看到,429 本身不是问题,429 + 没有 Retry-After 才是问题。TickDB 的做法是把所有必要信息都放进响应里,让客户端不需要查文档就能正确处理。


五、最佳实践:让你的代码更健壮

5.1 SDK 层面的错误处理

如果你在构建 SDK,建议把错误码翻译成更语义化的异常类型:

# sdk/exceptions.py
class TickDBException(Exception):
    """SDK 异常基类"""
    pass

class AuthenticationError(TickDBException):
    """认证失败(API Key 无效或缺失)"""
    pass

class ResourceNotFoundError(TickDBException):
    """资源不存在(交易品种代码错误)"""
    pass

class RateLimitError(TickDBException):
    """限速错误"""
    def __init__(self, retry_after: int, current_rate: int = None, limit: int = None):
        self.retry_after = retry_after
        self.current_rate = current_rate
        self.limit = limit
        msg = f"限速触发,等待 {retry_after}s"
        if current_rate and limit:
            msg += f"(当前 {current_rate}/s,限制 {limit}/s)"
        super().__init__(msg)

class QuotaExceededError(TickDBException):
    """月度配额超限"""
    pass

class ServerError(TickDBException):
    """服务端错误(5xx)"""
    pass

# sdk/errors.py
ERROR_CODE_MAP = {
    (1001, 1002): AuthenticationError,
    (2002,): ResourceNotFoundError,
    (3001, 3003): RateLimitError,
    (3002,): QuotaExceededError,
    (4000,): ServerError,
}

def build_exception(code: int, data: dict) -> TickDBException:
    """根据错误码构建对应的异常类型"""
    for code_range, exc_class in ERROR_CODE_MAP.items():
        if code in code_range:
            if issubclass(exc_class, RateLimitError):
                return exc_class(
                    retry_after=data.get("retry_after", 1),
                    current_rate=_parse_rate(data.get("detail")),
                    limit=_parse_limit(data.get("detail"))
                )
            return exc_class(data.get("message", "Unknown error"))
    
    return TickDBException(f"[{code}] {data.get('message', 'Unknown error')}")

def _parse_rate(detail: str) -> int:
    """从 detail 字段提取当前频率"""
    # 伪代码:解析 "当前请求频率为每秒 12 次"
    import re
    match = re.search(r"每秒\s*(\d+)\s*次", detail or "")
    return int(match.group(1)) if match else None

def _parse_limit(detail: str) -> int:
    """从 detail 字段提取限制频率"""
    import re
    match = re.search(r"限制为每秒\s*(\d+)\s*次", detail or "")
    return int(match.group(1)) if match else None

5.2 WebSocket 连接的限速处理

实时数据场景下的限速处理稍有不同:

import asyncio
import json
import os
import websockets
from websockets.exceptions import ConnectionClosed

class TickDBWebSocket:
    """TickDB WebSocket 客户端(带限速处理)"""
    
    def __init__(self, api_key: str = None):
        self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
        self.ws = None
        self.retry_count = 0
        self.max_retries = 5
        self.base_delay = 1.0
        self.max_delay = 32.0
    
    async def connect(self, url: str):
        """建立 WebSocket 连接"""
        # WebSocket 鉴权通过 URL 参数
        auth_url = f"{url}?api_key={self.api_key}"
        self.ws = await websockets.connect(auth_url)
        self.retry_count = 0
        print(f"[TickDB WS] 连接成功")
    
    async def subscribe(self, channels: list):
        """订阅频道"""
        subscribe_msg = {
            "cmd": "subscribe",
            "channels": channels
        }
        await self.ws.send(json.dumps(subscribe_msg))
        response = await self.ws.recv()
        data = json.loads(response)
        
        # ⚠️ WebSocket 连接层的限速处理
        if data.get("code") == 3001:
            retry_after = data.get("retry_after", 1)
            print(f"[TickDB WS] 订阅被限速,等待 {retry_after}s")
            await asyncio.sleep(retry_after)
            # 重试订阅
            await self.ws.send(json.dumps(subscribe_msg))
            response = await self.ws.recv()
            return json.loads(response)
        
        return data
    
    async def handle_rate_limit(self, retry_after: int):
        """
        WebSocket 限速特殊处理:
        - 连接不会断开,只需等待
        - 无需重新建立连接
        """
        print(f"[TickDB WS] 触发限速,暂停 {retry_after}s")
        await asyncio.sleep(retry_after)
        print(f"[TickDB WS] 限速窗口结束,恢复接收数据")
    
    async def listen(self, on_message):
        """
        持续监听消息
        """
        try:
            async for message in self.ws:
                data = json.loads(message)
                
                # 处理限速错误(订阅或心跳超时)
                if data.get("code") == 3001:
                    await self.handle_rate_limit(data.get("retry_after", 1))
                    continue
                
                # 处理心跳响应
                if data.get("type") == "pong":
                    continue
                
                # 处理正常消息
                on_message(data)
                
        except ConnectionClosed as e:
            print(f"[TickDB WS] 连接断开: {e.code} {e.reason}")
            await self.reconnect()
    
    async def reconnect(self):
        """指数退避重连"""
        if self.retry_count >= self.max_retries:
            raise RuntimeError("超过最大重连次数")
        
        delay = min(self.base_delay * (2 ** self.retry_count), self.max_delay)
        import random
        jitter = random.uniform(0, delay * 0.1)
        wait_time = delay + jitter
        
        print(f"[TickDB WS] {wait_time:.2f}s 后尝试重连 ({self.retry_count + 1}/{self.max_retries})")
        await asyncio.sleep(wait_time)
        
        self.retry_count += 1
        await self.connect(self.ws.url)

六、写在最后:错误码是开发体验的显微镜

你可能觉得讨论错误码有点过于细节了。但正是这些细节,决定了你和 API 之间的关系质量。

用 429 的世界

  • 你需要查文档才知道超的是什么限制
  • 你需要猜测该等多久
  • 你需要在代码里写一堆 if status == 429 的判断
  • 你的监控里,429 和 404 混在一起,没有任何区分度

用 3001 的世界

  • 响应本身告诉你所有需要知道的信息
  • 你的代码可以写出通用的错误处理框架
  • 你的监控可以精确区分限速/月度配额/服务端错误
  • 你可以在凌晨 2:47 安心睡觉,因为你知道重试逻辑是对的

这不是“用哪个更好”的问题,这是设计哲学的差异。429 是 HTTP 时代的设计,它服务于 HTTP 的场景。TickDB 的错误码体系是为 API 时代重新设计的,它服务于程序化调用者的需求。

下次你看到 3001,不要只把它当作一个数字——把它当作一份结构化的诊断报告


下一步行动

如果你在构建 SDK 或数据管道

  1. 访问 TickDB API 文档 查看完整的错误码体系
  2. 复制本文的 handle_response 函数作为错误处理起点
  3. 集成错误追踪器到你的监控系统

如果你在评估数据供应商

  1. 重点检查对方是否在 429 响应中返回 Retry-After
  2. 尝试触发限速错误,看返回的诊断信息是否足够丰富
  3. 评估你需要在代码里写多少 if-else 来处理各种异常

如果你想了解更多 API 设计最佳实践

  1. 阅读 TickDB 开发者博客,定期更新 API 工程实践
  2. 关注公众号,获取最新功能更新和限速策略调整

风险提示:本文内容基于 TickDB 当前 API 设计,不构成任何服务承诺。错误码体系可能随产品迭代调整,请以官方文档为准。