摘要 :给飞书机器人接文件上传,10MB 出头的小文件全过,一旦奔着 30MB 去,
im/v1/files接口就开始偶发抽风------一会儿错误码 2200,一会儿 9499,一会儿干脆返回空响应体把 SDK 干出JSONDecodeError。本文记录这次排查的完整链条:从错误日志分层、稳定复现边界,到三次重试 + 退避的修复方案。适合所有接飞书 API 做文件投递的后端/自动化开发者。
1. 背景:一个"平时都好好的"的文件投递管道
我有一套自动化管道,每天定时生成分析报告(PDF/压缩包),通过飞书开放平台的 im/v1/files 接口上传后,以文件消息的形式发到指定会话。跑了几个月,稳得像老黄牛。
直到那天,一份 26MB 的报告把整条管道干趴下了。
诡异的地方在于:不是必现。同一个文件,重试一次可能就成功了;而 10~25MB 区间的文件,从来一次过。
2. 事故现场:三种死法
先把日志翻出来,失败集中在 im/v1/files 上传这一步,死法有三种:
# 死法一:错误码 2200
{"code":2200,"msg":"upload file fail"}
# 死法二:错误码 9499
{"code":9499,"msg":"..."} # msg 时常为空或语焉不详
# 死法三:空响应体
# SDK 侧直接抛 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
前两种好歹给了错误码,第三种最阴------HTTP 层看像是成功了,body 是空的,lark-oapi SDK 拿到空字符串去 json.loads,直接炸在解析层。如果你的异常处理只 catch 了业务错误码,这种会漏网并向上传播成一坨看不懂的堆栈。
3. 排查:先画边界,再定位
3.1 第一步:确认不是代码 bug
同一个函数、同一份代码,小文件稳定成功。变量只有一个:文件大小。代码 bug 排除。
3.2 第二步:画出失败边界
拿不同体积的文件各传 20 次,统计成功率:
| 文件体积 | 上传次数 | 成功 | 失败 | 主要错误 |
|---|---|---|---|---|
| 5 MB | 20 | 20 | 0 | 无 |
| 10 MB | 20 | 20 | 0 | 无 |
| 15 MB | 20 | 20 | 0 | 无 |
| 25 MB | 20 | 20 | 0 | 无 |
| 26~30 MB | 20 | 9 | 11 | 2200 / 9499 / 空响应体 |
边界非常清晰:~26MB 是分水岭,过了这条线,失败率直接飙到 50% 以上,且三种错误随机出现。
3.3 第三步:排除鉴权与网络
- Token 刷新正常,失败请求的鉴权头与成功请求无差异
- 同一时段小文件持续成功,说明不是本地网络抖动
- 服务端限流一般返回 429 或明确的限流码,与 2200/9499 表现不符
结论:这是服务端对大体积 multipart 上传的瞬态不稳,不是客户端问题。错误码 9499 在飞书开放平台的语义里属于服务内部错误一类,官方文档对这类码的建议就是"可重试"。
4. 修复方案:三次重试 + 退避 + 重开文件句柄
4.1 为什么不能只加个简单重试
有个细节容易踩:很多人重试时复用同一个文件对象。lark-oapi 的上传接口会读文件流,第一次失败后文件指针可能停在半路,第二次重试读到的就是半个文件------重试越多,错得越离谱。所以每次重试必须重新打开文件句柄。
4.2 核心代码
import time
MAX_RETRIES = 3
BACKOFF = [2, 4, 8] # 秒,指数退避
def upload_with_retry(client, file_path, file_type="pdf"):
"""飞书 im/v1/files 上传,带三次重试。
关键点:每次重试重开文件句柄,避免读半个文件。"""
last_err = None
for attempt in range(MAX_RETRIES):
try:
with open(file_path, "rb") as f: # 每轮重开句柄
resp = client.im.v1.file.create(
file_type=file_type, file=f
)
if not resp.success():
raise RuntimeError(
f"lark err {resp.code}: {resp.msg}"
)
return resp.data.file_key
except Exception as e:
last_err = e
if attempt < MAX_RETRIES - 1:
time.sleep(BACKOFF[attempt])
raise last_err
对接到消息发送层:上传成功后拿 file_key 调 im/v1/messages 发文件消息,这一步体积小,从未失败,不需要重试。
4.3 整体链路
flowchart TD
A[生成报告文件] --> B{体积 >= 26MB?}
| B -->|是| C[上传 im/v1/files<br/>三次重试 + 2s/4s退避] |
| B -->|否| D[直接上传] |
C --> E{重试3次全失败?}
| E -->|否| F[拿到 file_key] |
| E -->|是| G[降级: 裸 requests multipart 直传] |
G --> F
D --> F
F --> H[发送文件消息 im/v1/messages]
4.4 备用通道:裸 requests 直传
有个有意思的发现:同样参数,绕开 SDK、用裸 requests 手搓 multipart 表单,一次就过。原因是 requests 对 multipart 的边界处理(boundary 生成、分块编码)与 SDK 内置 HTTP 客户端不同,某些服务端路径下表现更稳。不值得深究谁对谁错------工程上把它当备用通道就够了:
import requests
def upload_raw(token, file_path, file_type="pdf"):
url = "https://open.feishu.cn/open-apis/im/v1/files"
with open(file_path, "rb") as f:
r = requests.post(
url,
headers={"Authorization": f"Bearer {token}"},
data={"file_type": file_type, "file_name": file_path},
files={"file": f},
timeout=300,
)
r.raise_for_status()
return r.json()["data"]["file_key"]
4.5 修复后验证
| 指标 | 修复前 | 修复后(连跑一周) |
|---|---|---|
| 26~30MB 上传成功率 | ~45% | 100%(含重试命中) |
| 平均重试次数 | 无重试机制 | 1.2 次 |
| 管道中断次数 | 每天必炸 | 0 |
5. 踩坑记录
- 空响应体不是"无错误" 。HTTP 200 + 空 body 是最容易被忽略的失败形态,异常处理要单独兜
JSONDecodeError,别让它伪装成成功。 - 重试必须重开文件句柄。文件指针被上一轮失败读歪了,复用句柄的重试等于重试上传半个文件。
- 偶发故障先画边界再动手。拿体积做变量跑 20 次统计成功率,5 分钟就能把"玄学"变成清晰的 26MB 分界线,比盯着单次报错猜原因高效得多。
- SDK 传不动就换手搓。不要在"SDK 为什么不行"上死磕,工程师的时间应该花在让链路有兜底,而不是给第三方库写论文。
- 错误码分层要记录。2200、9499、空响应体三种形态各自记进日志标签,后续再出问题可以直接按形态路由到不同处理策略。
6. 总结
这次排坑的教训一句话:对第三方 API 的偶发失败,正确姿势不是找"根因"(你永远等不到官方修),而是把"重试 + 句柄重置 + 降级通道"三件套做扎实。修复上线一周,26MB 以上文件零中断。
你们接飞书或其他 IM 开放平台时,上传体积最大到过多少?有没有遇到过这种"小文件全过、大文件抽奖"的接口?评论区聊聊,我把大家的阈值数据汇总一下,看看 26MB 这个边界是不是普遍现象。