GitHub Error: 403_rate_limit — REST API Rate Limit Exceeded
HTTP/1.1 403 Forbidden
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714150800
X-RateLimit-Resource: core
{
"message": "API rate limit exceeded for 203.0.113.42. (But here's the good news: Authenticated requests get a higher rate limit.)",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
}
GitHub’s 403 with X-RateLimit-Remaining: 0 is the most common production blocker for any code that talks to the GitHub API. The fix is rarely “wait longer” — it’s almost always one of: authenticate, use conditional requests, switch to GraphQL, or upgrade from PAT to GitHub App. Each move multiplies your effective quota by 5-100x.
The single biggest win is conditional requests with ETags. Most GitHub data (repo metadata, branch SHAs, label lists) changes rarely. A naive integration that re-reads the same resource every minute spends 60 calls/hour per resource; the same code with If-None-Match spends maybe 3-5 because most reads return 304. That alone takes you from constant throttling to a comfortable margin.
Why this happens
- Unauthenticated requests on a CI runner. GitHub's unauthenticated quota is 60/hour per source IP. CI runners (especially shared GitHub Actions runners on shared NAT) consume this collectively. A single noisy job can lock out everyone else on the same exit IP for 59 minutes.
- PAT used for what should be a GitHub App. Personal access tokens get 5,000/hour. GitHub Apps get 15,000/hour per installation, scaling with your installation count. Heavy automation that runs through a PAT will throttle long before an equivalent GitHub App.
- Polling instead of webhooks. Many GitHub integrations poll `GET /repos/.../events` or `GET /notifications` every minute. That alone burns 60 RPH; combined with other reads, you'll hit the cap. Switch to webhooks or the events stream for change-driven updates.
- REST search API on its own tighter limit. `GET /search/...` has a separate, much lower limit (30/min authenticated, 10/min unauthenticated). Hitting search rate limits returns the same 403 with `X-RateLimit-Resource: search`.
- Secondary (abuse) rate limit. If you hammer a single endpoint with high concurrency, GitHub may return a 403 with `Retry-After` and a different message — the secondary rate limit. It's separate from the per-hour budget and triggers on burst patterns.
How to fix it
Fixes are ordered by likelihood. Start with the first one that matches your context.
1. Authenticate every request
Unauthenticated 60/hour is a development convenience, not a production budget. Set the `Authorization: Bearer <token>` header on every call. PATs get 5,000/hour; fine-grained PATs and GitHub Apps get more.
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
// Octokit auto-handles primary rate limit and retries
throttle: {
onRateLimit: (retryAfter, options) => {
console.warn(`Rate limited on ${options.method} ${options.url}, retrying in ${retryAfter}s`);
return options.request.retryCount < 3;
},
onSecondaryRateLimit: (retryAfter, options) => {
console.warn(`Secondary rate limit on ${options.method} ${options.url}`);
return options.request.retryCount < 1;
},
},
});
2. Honour X-RateLimit-Reset before retrying
Don't retry blindly — `X-RateLimit-Reset` is a unix timestamp telling you exactly when the budget refills. Sleep until then (plus a small jitter) before the next call.
import time, requests
def get(url, token, max_attempts=3):
for _ in range(max_attempts):
r = requests.get(url, headers={"Authorization": f"Bearer {token}"})
if r.status_code != 403 or 'X-RateLimit-Remaining' not in r.headers:
return r
if int(r.headers['X-RateLimit-Remaining']) > 0:
return r
reset_at = int(r.headers['X-RateLimit-Reset'])
sleep_for = max(0, reset_at - int(time.time())) + 5
time.sleep(sleep_for)
r.raise_for_status()
3. Use conditional requests to avoid spending quota
Every GitHub REST response has an `ETag`. Send `If-None-Match: <etag>` on subsequent reads — if the data hasn't changed, GitHub returns 304 Not Modified and the call doesn't count against your rate limit.
const cache = new Map();
async function getRepo(owner, repo) {
const url = `https://api.github.com/repos/${owner}/${repo}`;
const headers = {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
};
const cached = cache.get(url);
if (cached) headers['If-None-Match'] = cached.etag;
const r = await fetch(url, { headers });
if (r.status === 304) return cached.body; // free read
const body = await r.json();
cache.set(url, { etag: r.headers.get('etag'), body });
return body;
}
4. Move to GraphQL for batched reads
The GraphQL API uses a points-based budget — fewer round trips for the same data. A single query that fetches a repo, its open PRs, their reviewers, and their checks would be 4-5 REST calls but one GraphQL call costing maybe 20 points out of 5,000.
5. Switch from PAT to GitHub App for sustained automation
A GitHub App installation gets up to 15,000/hour and scales with the number of installations. Bots, syncers, and CI integrations that hit PAT limits regularly should be GitHub Apps. The migration is mostly authentication plumbing — endpoints stay the same.
Detection and monitoring in production
Log `X-RateLimit-Remaining` on every response and emit it as a metric. Alarm when remaining drops below 10% of the limit for more than 5 minutes — that's an incoming 403. For GitHub Apps, expose the metric per installation so you can see which one is the heavy user.
Related errors
- github422_validation_failedGitHub parsed your request but rejected one or more fields — typically a missing required field, a value that breaks a uniqueness constraint, an invalid SHA, or a referenced resource that doesn't exist (e.g., a label name that hasn't been created on the repo).
- awsAccessDeniedExceptionThe IAM principal (user, role, or assumed role) making the request does not have an `Allow` statement for the action and resource being called, or an explicit `Deny` somewhere in the evaluation chain blocks it.
- awsThrottlingExceptionYou exceeded the per-second or per-account API request limit for an AWS service. Most AWS APIs use a token bucket — sustained or bursty traffic above the bucket refill rate gets throttled.
- openairate_limit_exceededYour account has exceeded its per-minute request (RPM) or per-minute token (TPM) limit for the model you're calling. Limits are tier-based and per-model.
Frequently asked questions
Why does GitHub return 403 instead of 429 for rate limiting? +
What''s the difference between primary and secondary rate limits? +
Does the search API share the same 5,000/hour budget? +
Are GraphQL and REST limits separate? +
Why am I rate-limited on a GitHub Actions workflow? +
How do I know my current rate-limit budget without making a call? +
What's the right retry strategy for secondary rate limits? +
Does authenticated traffic from one PAT count against another user''s PAT? +
When to escalate to GitHub support
Open a GitHub support ticket if (a) your GitHub App installation is consistently hitting 15,000/hour and you have a legitimate use case requiring more, (b) secondary rate limits are firing on calls that aren't bursty, or (c) the rate-limit headers contradict each other (e.g., `Remaining: 100` but you still get 403). For routine quota hits, support can't grant manual exceptions — switch to GraphQL, conditional requests, or App auth instead.
Read more: /guide/handling-rate-limits/