在行情接口的排障里,403 和 429 经常被混为一谈,但它们的性质完全不同:429 是「用量超限」,等一等可能就恢复;403 是「权限不足」,等多久都不会恢复。
把 403 当成 429 处理,最典型的后果是写了一个自动重试逻辑,然后眼睁睁看着服务连续失败一整天。这篇文章把 403 的六种成因逐个拆开。
403 的六种成因
行情接口的 403 至少对应六种不同的权限问题,每一种的解法都不同:
| 错误码 | 含义 | 解法 |
|---|---|---|
NO_SUBSCRIPTION | 没有生效中的订阅 | 订阅对应套餐 |
SUBSCRIPTION_EXPIRED | 订阅已过期 | 续订或检查账单状态 |
MARKET_NOT_ENABLED | 当前套餐未开通该市场 | 升级套餐或改请求已开通市场 |
API_KEY_SCOPE_DENIED | 密钥权限范围不含该接口 | 用范围正确的密钥 |
IP_DENIED | 来源 IP 被策略拒绝 | 调整 IP 白名单或改用服务端出口 |
GEO_DENIED | 来源地区被策略拒绝 | 调整部署区域或联系支持 |
和 429 一样,判断依据是响应体里的 code 字段,而不是 HTTP 状态码本身。六个错误码都是 403,但处理路径完全不同。
NO_SUBSCRIPTION 与 SUBSCRIPTION_EXPIRED:订阅状态问题
这两个错误码说明账号层面没有可用的订阅。区别在于:前者是「从未订阅或已取消」,后者是「曾经有效但已过期」。
值得注意的是,免费套餐本身也是一种订阅。如果你在免费套餐下请求了套餐范围外的市场,得到的应该是 MARKET_NOT_ENABLED 而不是 NO_SUBSCRIPTION。所以看到 NO_SUBSCRIPTION 时,优先检查账号的订阅状态与账单,而不是请求参数。
MARKET_NOT_ENABLED:最常见的 403
这是实际排障中遇到最多的一类。它的含义很明确:你的套餐没有覆盖请求的市场。
不同套餐的市场权限差异很大,而且要注意「实时」与「延迟」是分开授权的。典型情况是:某个市场在你的套餐里是延迟数据,如果你按实时接口去请求,就会收到 MARKET_NOT_ENABLED。这种情况下升级套餐确实能解决,但先确认你真正需要的是实时还是延迟数据,往往能省下一笔开支。
排查方法很简单:先确认目标市场在当前套餐下的权限等级,再对照请求的接口类型。如果只是要延迟数据,用对应的接口即可。
API_KEY_SCOPE_DENIED:密钥范围问题
密钥可以按用途限定范围。当你用一把「只读行情」的密钥去访问别的接口,或者用一把限定给某个服务的密钥去访问另一个服务时,就会命中这个错误。
这类问题常见于两种场景:
- 多环境密钥混用:开发环境的密钥被误部署到生产,而它没有生产所需的权限范围。
- 密钥轮换未同步:新密钥创建时范围配置错了,但旧密钥已撤销。
建议按环境(开发 / 预发 / 生产)分别创建密钥,并遵循最小权限原则。密钥一旦泄漏应立即在控制台撤销并轮换,不要只改代码里的字符串。
IP_DENIED 与 GEO_DENIED:网络策略问题
这两类错误来自访问策略,而不是套餐权限。常见成因:
- 出口 IP 变了:服务从固定 IP 的服务器迁移到弹性 IP 或容器环境,出口地址不再在白名单里。
- 本地开发误伤:在办公室或家宽环境下用生产密钥调试,本机 IP 未在白名单。
- 部署区域变更:服务迁移到新的云区域,触发了地区策略限制。
排查时先确认请求的实际出口 IP(注意容器、NAT、代理都可能改变它),再对照白名单与地区策略。
为什么对 403 重试永远无效
权限问题不会因为时间流逝而改变。更麻烦的是,盲目重试会带来三个副作用:
- 掩盖故障:重试逻辑把明确的权限错误变成「偶发失败」,延误定位。
- 浪费额度:被拒绝的请求如果计入了速率计数,会挤压正常请求的配额。
- 污染监控:错误率指标被稀释,告警阈值失去意义。
正确做法是:403 一律不重试,立即告警,并把 code 与 X-Request-Id 一起记录下来。
排查流程
- 读
code,确认属于六类中的哪一类。 - 记录
X-Request-Id,联系支持时提供。 - 如果是订阅类(NO_SUBSCRIPTION / SUBSCRIPTION_EXPIRED):检查账号订阅状态与账单。
- 如果是市场类(MARKET_NOT_ENABLED):确认该市场在当前套餐下的权限等级,以及你请求的是实时还是延迟接口。
- 如果是密钥类(API_KEY_SCOPE_DENIED):核对密钥范围,检查是否多环境混用。
- 如果是策略类(IP_DENIED / GEO_DENIED):确认实际出口 IP 与部署区域。
- 在客户端把 403 与 429 分开处理:403 直接失败,429 中的速率类才允许重试。
Python 实现:区分 403 与 429 的错误处理
import httpx
API_KEY = "your_api_key"
BASE = "https://api.tiqex.io/api/v1/market"
# 403 全部不重试;只有速率超限类 429 才重试
FATAL_403 = {
"NO_SUBSCRIPTION", "SUBSCRIPTION_EXPIRED", "MARKET_NOT_ENABLED",
"API_KEY_SCOPE_DENIED", "IP_DENIED", "GEO_DENIED",
}
RETRYABLE_429 = {"RATE_LIMIT_EXCEEDED"}
class FatalAccessError(Exception):
"""权限类错误:重试无用,需要人工介入。"""
def get_quote(codes: str):
resp = httpx.get(
f"{BASE}/quote",
params={"codes": codes},
headers={"X-API-Key": API_KEY},
timeout=10,
)
if resp.status_code == 200:
return resp.json()
body = resp.json() if resp.content else {}
code = body.get("code")
request_id = resp.headers.get("X-Request-Id")
if resp.status_code == 403:
hint = {
"NO_SUBSCRIPTION": "账号无生效订阅,请检查订阅状态与账单",
"SUBSCRIPTION_EXPIRED": "订阅已过期,请续订",
"MARKET_NOT_ENABLED": "当前套餐未开通该市场,或请求了错误的实时/延迟接口",
"API_KEY_SCOPE_DENIED": "密钥权限范围不含该接口,请核对密钥配置",
"IP_DENIED": "来源 IP 不在白名单,请确认实际出口 IP",
"GEO_DENIED": "来源地区被策略拒绝,请检查部署区域",
}.get(code, "未知权限错误")
# 直接抛出,绝不重试
raise FatalAccessError(f"{code}: {hint} (requestId={request_id})")
raise RuntimeError(f"请求失败 code={code} requestId={request_id}")
try:
print(get_quote("BTCUSDT,AAPL"))
except FatalAccessError as e:
print("需人工处理:", e)
# 在这里上报告警,而不是进入重试队列
核心是让 403 走一条完全独立的异常路径——它不进重试队列,而是直接触发告警。这能避免权限故障被重试逻辑掩盖成「偶发问题」。
常见问题
403 和 429 怎么快速区分?
看响应体的 code 字段。如果错误在等待一段时间后自动消失,基本是 429;如果持续存在且与请求量无关,基本是 403。
免费套餐也会返回 403 吗?
会。免费套餐只覆盖部分市场的延迟数据,请求套餐范围外的市场会得到 MARKET_NOT_ENABLED。
为什么同一个密钥在本地能用、在服务器上不行?
大概率是 IP_DENIED。密钥本身有效,但服务器的出口 IP 不在白名单里。注意容器平台、NAT 网关和代理都会改变实际出口 IP。
升级套餐能解决所有 403 吗?
不能。只有 MARKET_NOT_ENABLED 与订阅类错误可能通过升级解决。API_KEY_SCOPE_DENIED、IP_DENIED、GEO_DENIED 属于配置与策略问题,升级套餐无效。
被 403 拒绝的请求会消耗额度吗?
业务额度通常不会因此消耗,但请求仍会触发速率计数。更重要的是,大量 403 会让错误率指标失真,建议在监控里把 403 单独分组。
下一步
把 403 从重试逻辑里彻底剥离,是行情接入里性价比最高的一次改动。错误码的完整定义见 错误与限流文档;密钥的创建、分环境管理与轮换规则见 鉴权与密钥文档 与 REST 鉴权指南。如果确认是套餐权限问题,可对照 套餐定价 查看各市场的实时与延迟权限,或直接查阅 加密货币行情 等具体市场页面。