GitHub Rate Limits: What I Learned When CI Started Getting 403s

2026/03/02

BUILDgithub-actions🍞

We had a team discussion about GitHub rate limits this week, and I realized I understood maybe 30% of how they actually work. The other 70% was vibes and wrong assumptions. Here’s what I learned.

The basics nobody reads

GitHub rate-limits its REST API. Everyone knows this. What most people don’t know is that the limits are wildly different depending on how you authenticate.

Auth method Rate limit Scope
Unauthenticated 60 req/hr Per IP
Personal Access Token (PAT) 5,000 req/hr Per user
GitHub App (installation token) 15,000 req/hr Per installation
GITHUB_TOKEN (Actions) 1,000 req/hr Per repo

That’s a 3x difference between a PAT and a GitHub App. In a monorepo with hundreds of workflows, 3x is the difference between “works fine” and “everything is 403ing at 2 PM.”

The GITHUB_TOKEN that GitHub Actions gives you automatically? 1,000 requests per hour per repo. That sounds fine until you realize every actions/checkout, every gh api call, every status check update — they all count. In a busy monorepo, 1,000 evaporates fast.

GitHub Apps: the upgrade path

A GitHub App is essentially a first-class integration identity. Instead of using someone’s personal token (which dies when they leave the company, or when they rotate it and forget to update the secret), you register an App, install it on your org, and generate short-lived installation tokens.

The key insight: the 15,000 req/hr limit is per installation, not per app. An installation is a specific deployment of the app to an organization (or a subset of repositories). If you install the same app on two orgs, each installation gets its own 15,000 bucket.

But here’s where it gets tricky in practice.

How installation tokens scope

When you generate an installation token, you can scope it to:

The rate limit bucket is tied to the installation, not the token. So if you generate 10 different tokens from the same installation, they all share the same 15,000 req/hr pool. Creating more tokens doesn’t give you more capacity.

This is the part that caught me off guard. I’d vaguely assumed each token got its own limit. Nope. One pool per installation.

GitHub App: "CI Bot"
  └── Installation on org "my-org" (15,000 req/hr shared pool)
       ├── Token A (scoped to repo-1)     ─┐
       ├── Token B (scoped to repo-2)      ├── All drawing from same 15,000
       └── Token C (scoped to all repos)  ─┘

The scoping only controls which repos the token can access. It doesn’t partition the rate limit.

Why CI hits rate limits

In a monorepo CI setup, you might have:

If all of these use the same GitHub App installation — which they probably do, because it’s the same org — they’re all drawing from one 15,000 req/hr bucket.

15,000 sounds generous until you do the math. A single actions/checkout with submodules can generate dozens of API calls. A workflow that paginates through PR comments hits the API once per page. Status check updates happen per job, not per workflow.

And the fun part: rate limit errors don’t always look like rate limit errors. Sometimes you get a 403 Forbidden with a message about secondary rate limits. Sometimes the API just responds slower. Sometimes actions/checkout fails with a vague auth error because the token was fine but the rate limit wasn’t.

Primary vs. secondary rate limits

GitHub actually has two rate limiting systems running simultaneously:

Primary rate limits — the 5,000/15,000 req/hr numbers. This is the straightforward counter. You can check your remaining balance with the response headers:

curl -s -I -H "Authorization: Bearer $TOKEN" \
  https://api.github.com/rate_limit

# Look for:
# x-ratelimit-limit: 15000
# x-ratelimit-remaining: 14234
# x-ratelimit-reset: 1709380800

Secondary rate limits — these are concurrency and abuse limits. Even if you have 14,000 requests remaining, GitHub will throttle you if you:

Secondary limits are the sneaky ones. You’ll have plenty of primary quota left, but you start getting 403s with retry-after headers. And GitHub doesn’t document the exact thresholds — they’re adaptive.

The PAT trap

Here’s something I didn’t realize: PATs are rate-limited per user, not per token. If you have 5 PATs under your account, they all share the same 5,000 req/hr. And if your CI also uses a PAT tied to a bot user account, that bot user has the same 5,000 limit across all repos and all workflows using it.

This is why teams graduate from PATs to GitHub Apps. The limits are 3x higher, the tokens are short-lived (better security), and the identity is institutional rather than personal. Nobody’s CI breaks because someone left the company.

Monitoring with Datadog

Knowing your rate limits exist is one thing. Knowing you’re about to hit them before CI breaks is another.

The approach we discussed: instrument your GitHub API calls to emit metrics to Datadog. The rate limit headers in every GitHub API response give you everything you need:

Header What it tells you
x-ratelimit-limit Total budget (5,000 or 15,000)
x-ratelimit-remaining How much is left
x-ratelimit-reset Unix timestamp when it resets
x-ratelimit-used How much you’ve consumed
x-ratelimit-resource Which category (core, search, graphql, etc.)

If you’re already shipping CI pipeline data to Datadog, you can correlate rate limit usage with pipeline execution patterns. “Every Tuesday at 2 PM we spike to 90% usage” becomes visible.

The x-ratelimit-resource header is especially useful because GitHub partitions limits by resource type. The core API, search API, and graphql API each have separate buckets. You might be fine on core but hammering the search limit because some workflow is doing gh search in a loop.

A basic Datadog dashboard would track:

Practical takeaways

Things I’m keeping in my head after this discussion:

The meta-lesson: rate limits are one of those things where the documentation is technically complete but practically useless until you’ve been burned. “15,000 requests per hour” sounds like plenty — until you understand what counts as a request in a monorepo CI with 30 concurrent workflows. Then you start counting.