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:
- All repos the app has access to (org-wide installation)
- A subset of repos (you pick which ones at token creation time)
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:
- 20+ workflows triggered per push to main
- Each workflow doing
actions/checkout(1 API call), posting status checks, fetching PR data - Reusable workflows calling the GitHub API for metadata
- Bots commenting on PRs, updating labels, creating issues
- Scheduled workflows polling for changes
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:
- Make too many concurrent requests
- Hit the same endpoint too rapidly (think: polling in a tight loop)
- Generate too much compute on GitHub’s side (large diff requests, etc.)
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:
github.rate_limit.remainingover time, per installationgithub.rate_limit.usedas a percentage of limit- Alert when remaining drops below 20%
- Correlation with CI pipeline execution count
Practical takeaways
Things I’m keeping in my head after this discussion:
- Use GitHub Apps, not PATs, for CI. 3x the rate limit, better security, institutional identity.
- All tokens from one installation share one pool. Creating more tokens doesn’t help.
- The
GITHUB_TOKENin Actions is the lowest tier — 1,000/hr. Fine for simple repos, death for monorepos. - Secondary rate limits exist and will bite you even when primary limits look fine.
- Check the headers. Every GitHub API response tells you exactly where you stand. If you’re not monitoring that, you’re flying blind.
- Rate limits are per-resource. Core, search, and GraphQL each get their own bucket. Know which one your workflows are hitting.
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.