自己写了个 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 的签名不一样,这是第一个坑。提交列表:
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 游标翻页:
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”(因为列表是按时间倒序,它就是全局最新)记下来当回退:
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——和”打得太快”高度相关,不像数据本身的问题。
验证假设最直接的办法:把失败的题单独拉一遍。
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:
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。 200 配 null、或者 200 配空数组,都是很常见的软限流姿势。只盯着 status code 的重试会被它直接绕过,必须在解析出业务数据之后、判断”这个空到底是真没有还是被限流了”。
重试要分层。 传输层的 fetchRetry(看 status)和应用层的”空响应重试”(看 body 语义)是两件事,各管各的,缺一不可。我一开始以为有了 fetchRetry 就够了,结果它对这种软限流完全免疫。
诊断 bursty 失败先做重放。 失败是一片一片的,第一反应不该是逐个分析报错,而是把失败样本单独重跑一遍——一跑就过,基本就能锁定到”瞬时/负载”而不是”数据/逻辑”,省掉一大圈弯路。
批量操作要幂等。 pull --all 按 {id}.{slug} 跳过已存在文件,意味着失败之后直接重跑就能补缺,不会重复劳动,也不会覆盖手写的解答。这一条让”先跑一遍看哪里炸,修了再跑”成了安全的迭代方式,而不是一锤子买卖。