股票接口返回 404 与空数组的排查笔记:证书状态与级别参数
一、问题:HTTP 200 不等于拿到了数据
写数据管道时,最常见的错误处理是「看一眼状态码,200 就继续」。这套逻辑在股票数据接口上会漏掉两类情况:一类是 HTTP 层直接报错,客户端抛异常,容易发现;另一类是状态码 200、返回体却是空数组,程序一路跑到底,最后写出一个空文件或一张空表,等到下游发现时已经过了好几个环节。
还有一种更隐蔽的情况:同样是 404,有的返回带错误码的 JSON 对象,有的返回一段纯文本。若客户端无条件 json.loads,第二种会在解析那一行直接抛 ValueError,连错误信息都留不下来。
本文把四种真实返回放在一起对照------404 JSON 错误体、404 纯文本、200 空数组、200 正常数组------给出一段能区分它们的处理代码。全部结论来自实际请求,不涉及行情判断。
二、先看结果:四条可以在线验证的返回
第一条,一枚已失效的演示证书:
https://api.mairuiapi.com/hsrl/ssjy/000001/b997d4403688d5e66a
紧跟一句说明它是什么:hsrl/ssjy 是实时交易路径,000001 是六位股票代码,最后一段是证书串。点开返回的是 HTTP 404 与一个错误对象:
json
{"code":404,"message":"Licence证书不存在","licence":"b997d4403688d5e66a"}
第二条 ,把分时级别从 y 换成无效写法 m5:
点开返回的是 HTTP 404,但响应体不是 JSON,而是一段纯文本:
text
404 page not found
第三条 ,分时级别用 y,在本次区间内没有记录:
点开返回的是 HTTP 200,但响应体只有:
json
[]
第四条,正确的日线级别:
点开返回的是 HTTP 200 与一个 3 条记录的数组,首条是:
json
{"t":"2025-11-27","o":11.66,"h":11.71,"l":11.58,"c":11.71,"v":1011847.0,"a":1178308953.0,"pc":11.69,"sf":0.0}
四条地址只有路径段不同,结果却分属四种完全不同的处理分支:抛异常并读错误体、抛异常但只有纯文本、成功但无数据、成功且有数据。把它们并排看一次,比读十行错误处理说明更直观。
三、返回字段逐项说明
404 JSON 错误体只有三个字段,全部是诊断信息:
| 字段 | 类型 | 实测值 | 说明 |
|---|---|---|---|
code |
int |
404 |
与 HTTP 状态码一致 |
message |
str |
"Licence证书不存在" |
明确指向证书段 |
licence |
str |
"b997d4403688d5e66a" |
回显请求中的证书,便于定位是哪一枚 |
这个结构很有用:message 已经把问题定位到「证书」,所以排查时不需要去猜是不是代码、周期或者网络的问题。
404 纯文本 没有任何字段,长度 19 字节,内容就是 404 page not found。它不携带错误码,也不能被 JSON 解析器读取,只能由客户端按状态码先分流。
空数组长度为 0,同样是 HTTP 200。它不携带错误码,也不携带提示信息,因此只能由客户端自己判定「成功但无数据」。
正常数组 的 9 个字段分别是 t、o、h、l、c、v、a、pc、sf。其中 t 在日线级别是 2025-11-27 这种纯日期,分钟级别会带时分秒;sf 是停牌标记,实测以 0.0 返回。字段含义以接口文档定义为准。
四、接口结构与参数
两条路径的结构分别是:
text
https://api.mairuiapi.com/hsrl/ssjy/{股票代码}/{licence}
https://api.mairuiapi.com/hsstock/history/{股票代码.市场}/{分时级别}/{除权方式}/{licence}?st=&et=<=
由此可以把四种结果对应到不同的路径段:
| 现象 | 出问题的段 | 客户端表现 |
|---|---|---|
404 + Licence证书不存在 |
证书段 | urlopen 抛 HTTPError,捕获后可读到 JSON 错误体 |
| 404 + 纯文本 | 分时级别段 | urlopen 抛 HTTPError,响应体不是 JSON |
200 + [] |
分时级别与区间组合 | 无异常,返回体为空数组 |
| 200 + 数组 | 全部正确 | 正常解析 |
分时级别按接口文档支持 5、15、30、60、d、w、m、y;本次 y 在 20250101 到 20251231 区间内没有可返回的记录,m5 不在取值表里。除权方式日线以上支持 n、f、b、fr、br,分钟级别按文档只用 n。
一个实际结论:404 是请求本身的问题,重试同一个请求不会改变结果;而 200 空数组是「本次条件下没有记录」,也不是瞬时故障。把两种情况的处置方式写反,要么产生大量无效重试,要么把空结果当成一次正常采集写进库。
五、代码实现:把四种结果分开处理
下面这段代码只依赖标准库,重点在 HTTPError 的捕获、「非 JSON 响应体」的显式判断,以及「空数组」的单独归类。
python
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError
LICENCE = "LICENCE-8003-6DCF-E0C78035A455"
DEAD_LICENCE = "b997d4403688d5e66a"
Q = "?st=20250101&et=20251231<=3"
HIS = "https://api.mairuiapi.com/hsstock/history/000001.SZ/"
def fetch(url):
"""统一返回 (status, text),把 HTTPError 也转成可判断的结构。"""
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
try:
with urlopen(req, timeout=20) as resp:
return resp.status, resp.read().decode("utf-8")
except HTTPError as e:
return e.code, e.read().decode("utf-8")
def classify(status, text):
try:
payload = json.loads(text)
except ValueError:
payload = None
if status >= 400 and payload is None:
return "ERROR_TEXT", "404 非 JSON 纯文本"
if status >= 400:
return "ERROR_JSON", "HTTP %d / %s" % (
status, payload.get("message", ""))
if isinstance(payload, list) and len(payload) == 0:
return "EMPTY", "200 但返回空数组"
if isinstance(payload, list):
return "ROWS", "%d 条" % len(payload)
if isinstance(payload, dict):
return "ONE", "单对象,%d 字段" % len(payload)
return "UNKNOWN", type(payload).__name__
cases = (
("失效演示证", "https://api.mairuiapi.com/hsrl/ssjy/000001/" + DEAD_LICENCE),
("年线区间无数据", HIS + "y/n/" + LICENCE + Q),
("无效级别 m5", HIS + "m5/n/" + LICENCE + Q),
("正常日线", HIS + "d/n/" + LICENCE + Q),
)
for label, url in cases:
status, text = fetch(url)
kind, note = classify(status, text)
print("%-16s %-10s %s" % (label, kind, note))
实测运行输出(原样):
text
失效演示证 ERROR_JSON HTTP 404 / Licence证书不存在
年线区间无数据 EMPTY 200 但返回空数组
无效级别 m5 ERROR_TEXT 404 非 JSON 纯文本
正常日线 ROWS 3 条
关键在 classify() 的两个分支:status >= 400 先分流,并且区分「响应体能不能解析成 JSON」;200 + 空数组 再单独归为一类,与「200 + 有数据」区分开。这样上游可以决定是告警、跳过还是继续解析,而不会把空结果当成一次正常采集写进库。
还有一点容易忽略:fetch() 里 HTTPError 必须调用 e.read() 才能拿到响应体。若不读取,日志里只剩一个状态码,错误体里的 message 就丢了。
六、演示证书的行为说明
用当前有效的演示证书做一组对照,只把股票代码从 000001.SZ 换成 600519.SH:
- https://api.mairuiapi.com/hsstock/history/000001.SZ/d/n/LICENCE-8003-6DCF-E0C78035A455?st=20250101&et=20251231<=3
- https://api.mairuiapi.com/hsstock/history/600519.SH/d/n/LICENCE-8003-6DCF-E0C78035A455?st=20250101&et=20251231<=3
实测两次返回逐字节相同 ;把级别换成 w、m、5 重复对照,结果同样逐字节相同。
示例使用的是演示证书,服务端固定返回 000001 的样例数据,因此替换请求中的股票代码不会改变返回结果。演示证书的作用只有两个:确认接口连通、看清返回字段结构。若需要验证其他标的的真实历史数据,需要在请求中使用自己的 licence 参数。
顺带说明本文第一条地址为什么会 404:那是一枚早期公开过的演示证书,实测已返回「Licence证书不存在」。公开演示证书会轮换,写在文章或示例代码里的串需要定期复测,不能假定长期有效;本文第一条地址是刻意保留的反例,用于展示错误体结构,不是可用的体验入口。
七、常见问题与踩坑
只判断状态码。 if resp.status == 200 之后直接 for row in payload,遇到 [] 会静默执行零次循环。行数校验必须独立于状态码。
无条件解析 JSON。 无效级别返回的是纯文本 404,json.loads 会抛 ValueError。先按状态码分流,再用 try/except ValueError 兜住非 JSON 响应体。
不捕获 HTTPError。 用 urllib 时 404 会抛异常,若不捕获,错误体里的 message、licence 就读不到。用 requests 时则相反------404 不抛异常,需要主动判断 status_code,两套客户端行为不同。
对 404 做重试。 证书状态与无效路径段都不是瞬时故障,重试不会改变结果,只会放大请求量。正确处置是修正请求参数并告警。
把「无数据」和「失败」混成同一个结局。 采集任务通常要区分三种结局:成功且有数据、成功但无数据、请求失败。第二种不一定需要重试;若把前两者都记为失败,会产生大量无效重试;都记为成功,又会让空表静默通过。建议在任务结果里保留独立的 row_count 字段,让调度层据此决定下一步动作。
沿用旧文章里的证书串。 公开演示证书会轮换,复制旧代码前应当把 URL 逐条实测一次,确认返回 200 且结构符合预期。
把错误体里的证书串原样写进日志。 licence 字段回显的是请求里的证书值,用于定位是哪一枚出了问题;日志里保留错误码与 message 就足够排查,没必要把整串完整落盘。
把空数组写成空表还提交。 落库前加一道「本次采集行数大于 0」的检查,并保留上一次成功结果,避免一次空返回把已有数据覆盖掉。
只在开发时验证过一次就上线。 演示证书会轮换,级别参数也可能调整。上线前把文中每条 URL 逐条实测一遍;错误分支也不要只靠模拟数据测试,用一条确实会 404 的地址跑通一次,才能确认 HTTPError 捕获与错误体解析真的生效。
八、小结与数据来源说明
四类结果对应四套处置:404 JSON 错误体要读 message 定位到证书段并停止重试;404 纯文本要先按状态码分流、不做 JSON 解析;200 空数组要独立归类为「无数据」;200 正常数组才进入解析链。把「状态码正确」与「确实拿到数据」拆成两个判断,是这一层最值得补的一行代码。
本文所有返回样例、错误体、状态码与代码输出均来自对上述公开接口地址的实际请求,采集时间为 2026-11-12。第一条 404 地址是本文刻意保留的反例,用于展示错误体结构,不是可用的体验入口;分时级别与除权方式的取值以当期接口文档为准。
本文只讨论接口返回结构与错误处理,不涉及任何行情判断或投资建议。