一个翻译任务,凭什么同时踩到 REST、GraphQL、Webhook 三种接口?这套流程让我同时依赖 GitHub 的 REST、GraphQL 和 Webhook 三类 API:REST 负责仓库与文件的基础读写,GraphQL 用来一次拿多层目录结构,Webhook 接收 push 事件触发翻译任务。选这三类是因为各自擅长的场景不同——REST 生态成熟、能覆盖绝大多数操作,GraphQL 适合批量取数据,Webhook 则省掉轮询的开销。起初我以为调 GitHub API 就是拼 URL 加 Token,上线第一周就被 403、401、404 轮番教育,夜间任务更是接二连三失败。我把前后两个月遇到的坑整理成 8 类问题,每一类都记录了现象、根因和最终解法,给做同类文档工具的项目做个参考。
一、速率限制
所有 GitHub API 调用都会计入配额,配额按小时滚动,用满就返回 403。翻译工具挂的是 GitHub App,安装级默认每小时 5000 次请求;单仓库文件一多,逐个拉内容再逐篇翻译,很快就撞限。第一次看到 403 时我以为是 Token 配置错了,看响应头里的 X-RateLimit-Remaining 才发现是配额问题,之前完全没想过要把限流当一等公民来设计。
对比过两条路:REST 逐目录递归拉取,请求次数和目录数量成正比;GraphQL 一条查询拿整个目录树,请求数骤降,但按查询复杂度计费。下表从请求量、配额消耗、实现复杂度三个维度做对比,方便后续选型时直接查。
| 对比项 | REST 逐目录拉取 | GraphQL 单查询取树 |
|---|---|---|
| 请求次数 | 每目录至少一次 | 一到两次 |
| 配额消耗 | 每次请求计 1 点 | 按字段复杂度计费 |
| 实现复杂度 | 需处理递归与翻页 | 需写查询与分页游标 |
| 出错恢复 | 单文件失败可局部重试 | 整条查询失败需整体重试 |
最终三个动作一起做:文件元数据走 GraphQL 减少请求数;内容拉取改成按需拉,跳过未变更文件;任务调度按仓库维度做令牌桶限流。全量同步的请求量降了六成左右,403 基本不再出现。对偶发的 403 和 429,我们又加了一层退避重试,代码大致是这样:
// 对 403/429 做指数退避重试
async function callWithRetry(fn, { retries = 3 } = {}) {
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
if (err.status !== 403 && err.status !== 429) throw err;
await sleep(1000 * Math.pow(2, i));
}
}
}
这层重试把偶发限流的失败率又压下去一截,但要注意重试只对幂等请求安全,写操作要格外谨慎,否则重复提交会让内容翻倍。
现象
调 GitHub Contents API 拉大仓库树时偶发 403。
原因
Installation Token 默认 5000/h,单仓库 1000+ 文件时拉取 + 翻译请求容易撞限。
解决
- 用 GraphQL 一次拿多文件元数据,减少请求数;
- 内容拉取改为按需拉,跳过 unchanged 文件;
- 任务调度按仓库级令牌桶限流。
二、Token 失效
GitHub App 的 Installation Token 有效期只有 1 小时,过期后继续调用会收到 401。这个坑最隐蔽:白天开发调试正常,夜间任务跑着跑着突然一批请求全部 Unauthorized,日志里看不到任何前置告警,只能靠时间戳反推是哪个时段开始失败的。
排查后确定根因在缓存逻辑:Token 缓存被 Redis 驱逐后没有及时重新生成,下一次任务又拿着旧 Token 去请求。解法是把缓存 TTL 压到 50 分钟,留 10 分钟余量,让刷新动作永远发生在过期之前;命中失败时立即重新生成;应用启动时先预热一次,避免冷启动后的第一批请求慢吞吞地等 Token 刷新。
现象
夜间任务突然报 401。
原因
Installation Token 1 小时过期,缓存被驱逐后未及时刷新。
解决
- Redis 缓存 TTL 50 分钟(留 10 分钟余量);
- 命中失败时立即重新生成;
- 启动时预热一次,避免冷启动请求慢。
三、分页数据丢失
REST 列表接口默认每页只返回 30 条,不显式翻页就会静默丢数据。我们拉取用户仓库列表时,有一批仓库始终不出现,用户反馈后排查才发现默认 per_page=30,而仓库数量往往远不止 30 个,后面的数据全被吞了。
对比过两种翻页方式:REST 靠响应头 Link 里的 rel=next 判断是否还有下一页;GraphQL 用游标分页,一次最多取 100 条。REST 场景我们显式设 per_page=100,并循环跟随 Link 头直到没有下一页,核心逻辑如下:
// 跟随 Link 头的 rel=next 拉全部分页
async function listAllRepos(octokit, opts) {
const result = [];
let page = 1;
let hasNext = true;
while (hasNext) {
const { data, headers } = await octokit.request("GET /user/repos", {
per_page: 100, page,
});
result.push(...data);
hasNext = /rel="next"/.test(headers.link || "");
page += 1;
}
return result;
}
列表类数据能走 GraphQL 的就用 GraphQL first: 100 一次拿满,避免 REST 翻页带来的额外请求和失败点。后来我们把所有列表接口的翻页逻辑统一封装,再没出过数据缺失的事故。
现象
拉取仓库列表时只到 30 条就停了。
原因
REST API 默认 per_page=30,且很多列表资源需要翻页。
解决
- 显式设置
per_page=100; - 循环
while直到Link头没有rel="next"; - 列表资源用 GraphQL
first: 100一次拿满。
四、Webhook 重放
Webhook 的问题是同一事件可能被投递多次:GitHub 在请求超时会自动重试,服务端若没有去重,同一个 push 就会触发两次翻译任务。我们第一次发现时还以为是代码并发 bug,排查任务表才发现 delivery 重复入库。
GitHub 每个 Webhook 投递都有唯一 ID,放在 X-GitHub-Delivery 头里。用这个头做幂等键,入库前先查重,命中就直接跳过;任务入队前再做一次校验,双保险。要特别说明的是,重放不挑事件类型,push、pull_request 一视同仁,幂等逻辑必须放在所有事件处理的最前面。
现象
同一次 push 触发了两次任务。
原因
GitHub 在请求超时会重试,重试间缺乏去重。
解决
- 用
X-GitHub-Delivery做幂等键; - 数据库存
delivery_id,命中则跳过; - 任务入队前查重。
五、Contents API 大文件限制
Contents API 对超过 1MB 的文件不返回 raw 内容,而是直接给 404,接口文档不会主动提醒这一点。仓库里一旦出现单文件超限的 Markdown,翻译任务就静默失败,只在日志里留下一行找不到资源的记录,不细看根本发现不了。
对比过三种替代方案,按改动量从小到大排列,选型时可以对照这张表:
| 方案 | 适用场景 | 改动量 |
|---|---|---|
| Contents API | 文件 ≤ 1MB | 无需改动 |
| Git Blob API | 任意大小 | 需要先取 blob SHA |
| clone 仓库后 git 命令读取 | 任意大小 | 依赖 git 环境与磁盘 |
最终我们优先用 Git Blob API 按 SHA 拉内容,它对大小没有硬限制;实在跑不通的仓库才退到 clone 本地读取。这样既覆盖了极端情况,又没有引入太多维护成本。
现象
仓库里某些 Markdown 超过 1MB,Contents API 返回 404。
原因
Contents API 不支持 > 1MB 文件的 raw 内容。
解决
- 改用 Git Blob API:
GET /repos/{owner}/{repo}/git/blobs/{file_sha}; - 或 clone 仓库后用 git 命令拉大文件。
六、GraphQL 复杂度
GraphQL 不是无限随便查的,GitHub 给每个查询设了复杂度上限,超限会返回 “query is too complex”。我们第一次遇到是在嵌套了多层的目录树查询上,层级越深、字段越多,复杂度评分涨得越快,一条自认为很普通的查询直接被打回。
处理方式是从查询本身下手:把大查询拆成多段分页拉取;减少嵌套层级;能用 nodes 就用 nodes,避免同名别名重复计分。GraphQL 很适合批量取数据,但要用它的方式去查询,而不是照抄 REST 的写法。改造之后,同样的数据量,查询数从几十条降到个位数,配额压力小了很多。
现象
GraphQL 查询返回 “query is too complex”。
原因
GitHub GraphQL 限制每个查询复杂度评分 ≤ 50000。
解决
- 拆分查询,分页拉取;
- 减少嵌套层级;
- 用
nodes代替别名,重复字段不重复计分。
七、API 弃用
GitHub 会按版本淘汰老接口和字段,弃用期过后旧字段直接失效。我们曾依赖 /installations/{id}/repositories 的某个字段,某次升级后字段返回空,排查了半天才发现是接口迁移,日志里一点线索都没有。
应对办法是订阅 GitHub Changelog,变更前提前适配;代码里统一用 octokit SDK,让它替我们消化掉大部分接口差异;测试用例里覆盖迁移字段,回归时能第一时间发现。靠人肉盯接口变化不现实,自动化校验更靠谱。
现象
/installations/{id}/repositories 字段被弃用后返回空。
原因
GitHub 会按版本淘汰老接口。
解决
- 跟 GitHub Changelog 订阅;
- 用
octokitSDK,自动适配新接口; - 在测试用例里覆盖迁移字段。
八、跨组织授权
GitHub App 是按安装授权的,用户可能属于多个组织,但 App 只装在了其中一部分。访问未安装组织的仓库时返回 404,这个 404 极具迷惑性,我们一度怀疑是路径写错,对着 URL 反复核对都没有结果。
最终方案分三层:前端只列出已安装组织的仓库,不让用户点进去;调用前再次校验 installation.account.login 是否在用户范围;跨组织场景下为每个安装生成独立任务,避免用错授权上下文。这样把问题在入口处就挡掉了。
现象
用户属于多个组织,App 只在部分组织安装,访问未安装组织的仓库 404。
原因
App 只能访问已安装的组织和仓库。
解决
- 用户前端只列出已安装组织的仓库;
- 调用前再次校验
installation.account.login是否在用户范围; - 跨组织场景下为每个安装生成独立任务。
九、其它值得记住的细节
除了上面 8 类大坑,还有几个容易忽略的小细节,记下来能省不少排查时间,踩到的人基本都在它们身上耗过半天:
- Push 事件的
head_commit可能为 null(删除分支场景); - Fork 仓库默认不可写,需要明确开启;
- 私有仓库 API 路径要带可见性参数;
- Webhook 的
signature头大小写敏感。
十、监控与告警
踩坑到最后我们意识到,很多问题不是不能避免,而是发现得太晚。给 API 调用加监控之后,问题定位时间从小时级降到了分钟级,这一步在整套排查体系里投入产出比很突出:
- 关键 API 调用成功率按端点统计;
- 401/403/404 比例告警;
- 平均响应时间超阈值触发排查。
十一、给同类项目的建议
把这次的教训沉淀成四条建议,排在前面的是架构层面的,越早做越省事,等出了问题再回头补,成本至少翻一倍:
- 用官方 SDK 而不是手写 HTTP;
- 抽象 GitHub 客户端与业务隔离,便于切换;
- 所有调用点都带 trace_id,方便回溯;
- 重大变更前在 staging 仓库先跑回归。
到这里,GitHub API 相关的坑基本都讲完了,从限流到授权,每一类都有对应的解法,照着排查能少走不少弯路。
常见问题(FAQ)
Q1:调 GitHub API 用什么 SDK?
推荐 @octokit/rest 或 @octokit/graphql,类型安全。
Q2:Webhook 收不到怎么办?
在 GitHub App 的 “Recent Deliveries” 看重试记录,签名校验失败是常见原因。
Q3:GraphQL 复杂度超限怎么处理?
拆分查询或减少 alias。