GitHub API 限流与认证踩坑实录(文档翻译工具项目)

一个翻译任务,凭什么同时踩到 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 订阅;
  • 用 octokit SDK,自动适配新接口;
  • 在测试用例里覆盖迁移字段。

八、跨组织授权

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 比例告警;
  • 平均响应时间超阈值触发排查。

十一、给同类项目的建议

把这次的教训沉淀成四条建议,排在前面的是架构层面的,越早做越省事,等出了问题再回头补,成本至少翻一倍:

  1. 用官方 SDK 而不是手写 HTTP;
  2. 抽象 GitHub 客户端与业务隔离,便于切换;
  3. 所有调用点都带 trace_id,方便回溯;
  4. 重大变更前在 staging 仓库先跑回归。

到这里,GitHub API 相关的坑基本都讲完了,从限流到授权,每一类都有对应的解法,照着排查能少走不少弯路。

常见问题(FAQ)

Q1:调 GitHub API 用什么 SDK?

推荐 @octokit/rest 或 @octokit/graphql,类型安全。

Q2:Webhook 收不到怎么办?

在 GitHub App 的 “Recent Deliveries” 看重试记录,签名校验失败是常见原因。

Q3:GraphQL 复杂度超限怎么处理?

拆分查询或减少 alias。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部