1873 字
9 分钟
给 leetcode-cli 加 pull 命令,撞上 LeetCode 不报 429 的软限流

自己写了个 leetcode-cli,平时用 pick / test / submit 在终端刷题,解答落在一个单独的仓库里,布局是扁平的 problems/{id}.{slug}.{ext}。但它一直缺一块:把账号里已经 AC 过的题,反向拉到本地。手头攒了 694 道 AC,想一次性同步进仓库。于是加了 pull 命令。

功能本身不难。难的是批量拉的时候,撞上了 LeetCode 一种不报 429的限流。记录一下,因为根因和报错信息完全是两回事。

LeetCode 没有公开 API,先把提交接口逆向清楚#

LeetCode 没有官方 API,所有数据都走 web 端那套未公开的 GraphQL,字段全靠抓包和翻开源客户端。拉提交代码需要两个原语:

  • submissionList —— 列出我对某题(或全站)的提交记录,每条带 id / statusDisplay / lang,但不含代码
  • submissionDetails —— 用提交 id 换取那一次提交的源码。

而且 .com.cn 的签名不一样,这是第一个坑。提交列表:

.com — offset 分页,questionSlug 可省略
query ($offset: Int!, $limit: Int!, $slug: String) {
submissionList(offset: $offset, limit: $limit, questionSlug: $slug) {
hasNext
submissions { id statusDisplay lang timestamp title titleSlug }
}
}

.cn 这边 questionSlug 是必填,并且还用一个 lastKey 游标翻页:

.cn — lastKey 游标,questionSlug 必填
query ($offset: Int!, $limit: Int!, $lastKey: String, $questionSlug: String!) {
submissionList(offset: $offset, limit: $limit, lastKey: $lastKey, questionSlug: $questionSlug) {
lastKey hasNext
submissions { id statusDisplay lang timestamp }
}
}

取代码的接口差异更刁钻:.com复数 submissionDetails(submissionId: Int!)lang 是个对象 { name }.cn单数 submissionDetail(submissionId: ID!)lang 直接是字符串。一个 Int! 一个 ID!,一个对象一个字符串,写客户端时必须按站点分支。这些字段我没凭记忆,是对着 JacobLinCool/LeetCode-Query.graphql 源文件逐条核过的。另外提交列表每页上限大约 20 行,想拉多必须翻页。

pull 的设计:每题一个文件,优先 C++#

命令分两种用法:pull <id> 拉单题,pull --all 拉全部已解。

选哪个语言是个真问题。我的仓库以 C++ 为主,但有些题我只有 Python 或 SQL 的 AC。需求很明确:有 C++ 就拉 C++,没有就回退到最新的任意 AC,每题只留一个文件

直接两次请求(先查 cpp 再查 any)太浪费。所以在一次分页遍历里同时记住两者——命中优先语言就立刻返回,否则把”第一次见到的 AC”(因为列表是按时间倒序,它就是全局最新)记下来当回退:

src/api/client.ts
async bestAcSubmission(slug: string, preferLang?: string): Promise<SubmissionRow | null> {
let fallback: SubmissionRow | null = null;
for await (const submissions of this.eachSubmissionPage(slug)) {
const preferred = pickAcSubmission(submissions, preferLang);
if (preferred) return preferred;
// 倒序,所以第一个见到的 AC 就是任意语言里最新的那条
if (!fallback) fallback = pickAcSubmission(submissions);
}
return fallback;
}

文件写入直接复用了 pick 已有的 writeSolution——同一个 problems/{id}.{slug}.{ext} 路径、同一行 // @leetcode id=... questionId=... 头。这样拉下来的文件 test / submit 能原样识别,不用为 pull 单独造一套格式。

第一次 pull —all:460 成功,229 失败#

配置里 workdir 已经指向解题仓库,lang=cpp,直接跑。694 道,后台跑。结果:

Done. 460 pulled, 5 skipped, 229 failed.

5 个跳过是仓库里本来就有的题(按 {id}.{slug} 去重,原有手写解答原样保留)。但 229 个失败,而且报错出奇地一致:

✖ fail [78/694] 292.nim-game: Submission 1994979392 was not found on leetcode.com.
✖ fail [79/694] 304.range-sum-query-2d-immutable: Submission 2045164923 was not found on leetcode.com.
✖ fail [80/694] 319.bulb-switcher: Submission 2045163415 was not found on leetcode.com.

“Submission X was not found”——可 id 明明是 submissionList 刚返回给我的。列表里有,详情里查不到,自相矛盾。

排查:失败是一阵一阵的#

第一件事是看失败的分布。如果是从某一题开始全错,那多半是某个边界数据;如果是断断续续,那更像负载相关。

实际是后者:78–115 连续错一片,然后恢复正常,到 177 又错一批。典型的 bursty——和”打得太快”高度相关,不像数据本身的问题。

验证假设最直接的办法:把失败的题单独拉一遍。

Terminal window
for id in 292 304 401; do node dist/index.js pull "$id"; done
✔ Pulled .../problems/292.nim-game.cpp
✔ Pulled .../problems/304.range-sum-query-2d-immutable.cpp
✔ Pulled .../problems/401.binary-watch.cpp

三个全部立刻成功。同一个提交 id,夹在批量里 not found,单独拉就 found。这就把”id 错了 / 提交不可访问”这类永久性原因排除了——问题在频率上:submissionDetails 在高频请求下会临时返回空。

根因:HTTP 200 配 null 的软限流#

我的 HTTP 层本来就有重试 fetchRetry,会对 429 / 503 退避重试,也认 Retry-After。但它一次都没拦下这些失败。为什么?

因为 submissionDetails 被限流的时候,根本不返回 429。它返回的是一个完全正常的 200 OK,body 是:

{ "data": { "submissionDetails": null } }

fetchRetry 只看 HTTP status code,200 在它眼里就是成功,直接放行。响应流到上层,zod 解析出 submissionDetails === null,我的代码就当作”这条提交不存在”,抛了 NotFoundError

这就是比 429 隐蔽得多的软限流:传输层一切正常,只有 body 的语义是空的。社区里能查到 LeetCode 对 GraphQL 接口确实有限流(429 的反馈帖、第三方抓取库内置 Rate Limiter 都佐证了这一点),只是它在这个接口上选择用空响应而不是错误码来限速。

修复:在语义层重试#

既然传输层那套抓不到,就得在应用层补一道。把取码逻辑改成:detail 为 null 时按退避重试,最多 4 次,间隔 800ms 起翻倍封顶 5s:

src/api/client.ts
async submissionCode(id: string): Promise<SubmissionCode> {
const maxRetries = 4;
for (let attempt = 0; ; attempt++) {
const detail = await this.fetchSubmissionDetail(id);
if (detail) return detail;
if (attempt >= maxRetries) {
throw new NotFoundError(
`Submission ${id} was not found on ${this.cfg.site}.`,
'LeetCode may be throttling — retry in a moment.',
);
}
await sleep(Math.min(800 * 2 ** attempt, 5000));
}
}

顺手加了回归测试:mock fetch 连续返回两次 { submissionDetails: null } 再给真数据,断言最终拿到代码、且确实重试了 3 次。

重跑 pull --all——它会按 {id}.{slug} 跳过已经在盘上的 460 多个文件,只补缺口:

Done. 224 pulled, 468 skipped, 2 failed.

229 个失败降到 2 个(只是这两个不巧把 4 次重试也耗光了),单独再拉一次就齐了。最终 694/694,按语言分布是 663 个 cpp、15 个 sql、10 个 py、3 个 js、1 个 ts——回退逻辑确实在干活,没有 C++ AC 的题落成了它实际 AC 的语言。

几点经验#

限流不一定是 429。 200null、或者 200 配空数组,都是很常见的软限流姿势。只盯着 status code 的重试会被它直接绕过,必须在解析出业务数据之后、判断”这个空到底是真没有还是被限流了”。

重试要分层。 传输层的 fetchRetry(看 status)和应用层的”空响应重试”(看 body 语义)是两件事,各管各的,缺一不可。我一开始以为有了 fetchRetry 就够了,结果它对这种软限流完全免疫。

诊断 bursty 失败先做重放。 失败是一片一片的,第一反应不该是逐个分析报错,而是把失败样本单独重跑一遍——一跑就过,基本就能锁定到”瞬时/负载”而不是”数据/逻辑”,省掉一大圈弯路。

批量操作要幂等。 pull --all{id}.{slug} 跳过已存在文件,意味着失败之后直接重跑就能补缺,不会重复劳动,也不会覆盖手写的解答。这一条让”先跑一遍看哪里炸,修了再跑”成了安全的迭代方式,而不是一锤子买卖。

给 leetcode-cli 加 pull 命令,撞上 LeetCode 不报 429 的软限流
https://blog.lishuyu.app/posts/leetcode-pull-软限流排查/
作者
猫猫魔女
发布于
2026-06-25
许可协议
CC BY-NC-SA 4.0