GitHub API rate limits, and how to stop wasting them
In short: unauthenticated GitHub API calls get 60 requests per
hour per IP; authenticated calls get 5,000. Conditional requests with
If-None-Match return 304 and do not count against the
limit, so caching an ETag turns a polling loop into almost nothing.
Rate limits are the most common reason a GitHub integration breaks in production. The request that fails is rarely the expensive one — it is the one that should not have been made at all.
The two limits
| Caller | Limit | Window |
|---|---|---|
| Unauthenticated (by IP) | 60 requests | 1 hour |
| Authenticated (any token) | 5,000 requests | 1 hour |
| GitHub Enterprise Cloud | 15,000 / 25,000+ | 1 hour |
Read your remaining quota before you hit it
curl -sI https://api.github.com/rate_limit | grep -i x-ratelimit
The headers tell you the limit, what is left, and when the window resets, so a poller can back off intelligently instead of guessing.
ETags: the single biggest win
Every GitHub response carries an ETag. Send it back on the next request and
GitHub answers 304 Not Modified with an empty body if nothing changed — and
crucially, that request is not counted against your hourly limit.
etag=$(curl -sI https://api.github.com/repos/cli/cli | grep -i '^etag:' | tr -d '\r' | cut -d' ' -f2)
curl -sI -H "If-None-Match: $etag" https://api.github.com/repos/cli/cli | head -1
# HTTP/2 304
This is why a tool that caches ETags can poll every few minutes indefinitely without ever approaching the limit, while one that refetches blind will exhaust 5,000 requests in minutes.
Cache what rarely changes
- Repository metadata — default branch, size, description. Changes rarely.
- Stargazer history — queried once every few hours at most.
- Language stats — recompute on a schedule, not per request.
FreeTools caches repository results for six hours and refreshes them in the background, serving a stale answer while the refresh runs. That is the stale-while-revalidate pattern, and it is why repeat repository calls return quickly and do not touch GitHub again.
Handle 403 and 429 properly
if response.status in (403, 429):
reset = int(response.headers.get("X-RateLimit-Reset", 0))
sleep_until(reset) # primary: wait for the window
# secondary: exponential backoff + jitter
A 403 from GitHub usually means the rate limit, not a permissions problem, so retrying
immediately makes things worse. Read X-RateLimit-Reset and wait for the window to
roll over.
Secondary limits
Beyond the hourly cap, GitHub throttles abuse patterns: creating repositories in a loop or hitting the search endpoint repeatedly. Search is limited to 30 requests per minute even for authenticated users, and that limit is the one that catches people out.
Which token to use
A classic token with no scopes at all is enough for 5,000 requests an hour, and a fine-grained token with public-repository read access is the better choice for anything new. Do not grant write scopes to a tool that only reads.
Frequently asked questions
What is the GitHub API rate limit?
Unauthenticated requests are limited to 60 per hour per IP address. Authenticated requests with any token get 5,000 per hour. The search API has a separate, much lower limit of 30 requests per minute even when authenticated.
Do conditional requests count against the GitHub rate limit?
No. A request that includes If-None-Match with a previously stored ETag and receives 304 Not Modified does not count against your hourly quota, which makes ETag caching the most effective way to poll without exhausting the limit.
How do I check my remaining GitHub API quota?
Request https://api.github.com/rate_limit and read the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset response headers. They report the window total, what is left and when it resets.
Why do I get a 403 from GitHub when the limit says I have requests left?
A 403 can also be a secondary abuse limit on rapid repository creation or repeated search calls. Back off, read the X-RateLimit-Reset header to wait for the primary window, and add exponential backoff with jitter.