GitHub API Rate Limit Errors: Fix 401, 403, 429, 502 & Timeouts
Diagnose and fix GitHub API 401, 403, 429, 502, and timeout errors. Step-by-step guide with real commands, ETag caching, and exponential backoff code.
- GitHub uses 403 for secondary rate limits (too many concurrent or mutation requests) AND for permission errors — always read the response body message field to distinguish them
- Unauthenticated requests are capped at 60/hour per IP; authenticated PAT requests get 5,000/hour; GitHub Apps installations get 15,000/hour; search endpoints have a separate 30 req/min limit
- 401 Bad credentials means your token is missing, expired, revoked, malformed with whitespace, or requires SSO authorization for your organization
- 502 Bad Gateway and connection timeouts are transient GitHub infrastructure events — implement exponential backoff reading the Retry-After and x-ratelimit-reset response headers
- Quick fixes: authenticate with a scoped PAT, use conditional GET requests with ETag/If-None-Match headers to avoid consuming quota on unchanged resources, and serialize write operations with 1-second delays
| Method | When to Use | Time to Implement | Risk |
|---|---|---|---|
| Add or refresh Personal Access Token | Getting 401 or hitting 60 req/hr unauthenticated ceiling | 5 minutes | Low |
| Conditional requests with ETag caching | Polling endpoints that rarely change (repo metadata, user info) | 30 minutes | Low |
| Header-aware retry with Retry-After | Handling 429, 502, and transient 503 errors | 1–2 hours | Low |
| Serialize mutation requests with 1s delay | Triggering secondary 403 rate limits on POST/PATCH/DELETE | 1 hour | Low |
| GitHub Apps installation tokens | Production workloads exceeding 5,000 req/hr on a single token | Half day | Medium |
| ETag response cache in Redis | Multiple services querying the same GitHub endpoints | 1 day | Medium |
| GraphQL API migration | REST calls that over-fetch and waste rate limit budget | Days to weeks | Medium-High |
Understanding GitHub API Rate Limit Errors
GitHub's REST API enforces multiple overlapping rate limits, and the same HTTP status code (403) can mean either a permissions problem or a secondary rate limit violation. Knowing which limit you've hit determines the correct fix. This guide walks through every error code you will encounter in production.
The Four Rate Limit Tiers
Primary rate limit: Unauthenticated requests are capped at 60 requests per hour per originating IP address. Authenticated requests using a Personal Access Token (PAT), OAuth token, or GitHub App installation token receive 5,000 requests per hour. GitHub Apps installations for organizations with 20 or more users receive 15,000 requests per hour.
Search API rate limit: The /search family of endpoints operates under a separate, stricter budget: 10 requests per minute for unauthenticated clients and 30 requests per minute for authenticated clients. Exceeding this returns a 429 with a distinct message referencing the search rate limit.
Secondary rate limits: GitHub applies additional abuse-prevention limits that are deliberately not fully documented. They activate when you: make too many concurrent requests to the same endpoint, issue a high volume of mutation operations (POST, PATCH, PUT, DELETE) in rapid succession, or submit many CPU-intensive requests such as triggering GitHub Actions workflow runs.
GraphQL point-based limit: The GraphQL endpoint at https://api.github.com/graphql uses a point system granting 5,000 points per hour. Complex queries with many nested connections consume more points per call. The response extensions.rateLimit block reports consumed and remaining points.
HTTP 401 Unauthorized
Exact error messages you will see:
{"message": "Requires authentication", "documentation_url": "https://docs.github.com/rest"}
{"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}
Requires authentication means no Authorization header was sent. Bad credentials means a header was sent but the token is invalid, expired, or revoked.
Root causes and fixes:
- Missing header: Ensure every request includes
Authorization: Bearer ghp_yourtoken. The oldertoken ghp_yourtokenformat still works butBeareris preferred. - Expired or revoked PAT: Tokens with expiration dates silently stop working. Regenerate the token at
github.com/settings/tokensand update all consumers. - Whitespace contamination: Tokens read from environment variables or config files often carry a trailing newline. Trim programmatically before use.
- SSO enforcement: If the target organization enforces SAML SSO, your PAT must be explicitly authorized for that organization. Visit the token settings page, find your token, and click "Configure SSO".
- Fine-grained PAT limitations: Some GitHub API endpoints do not yet support fine-grained PATs. If you receive 401 with a fine-grained token, try a classic PAT.
Verify your token is working:
curl -sI -H "Authorization: Bearer YOUR_TOKEN" https://api.github.com/user | grep -E "HTTP|x-oauth-scopes|x-ratelimit"
HTTP 403 Forbidden
This is the most ambiguous error because it covers two completely different problems.
Permissions error message:
{"message": "Must have admin rights to Repository.", "documentation_url": "https://docs.github.com/rest"}
{"message": "Resource not accessible by integration", "documentation_url": "https://docs.github.com/rest"}
Secondary rate limit message:
{"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again.", "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"}
Diagnosing permissions 403: The X-OAuth-Scopes response header lists the scopes your token actually has. Compare them against what the endpoint requires. For example, creating a repository webhook requires the write:repo_hook scope.
Diagnosing secondary rate limit 403: Unlike 429 responses, secondary-rate-limit 403 responses do not always include a Retry-After header. When present, honor it. When absent, wait at least 60 seconds before retrying and reduce the concurrency of your requests.
Fix for secondary rate limits: Add a minimum 1-second sleep between consecutive mutation requests. For write-heavy batch operations, process them serially rather than firing all requests concurrently. GitHub's integrator best practices guide specifically states: "Make requests for a single user or client ID serially. Do not make requests for a single user or client ID concurrently."
HTTP 429 Too Many Requests
Exact error messages:
{"message": "API rate limit exceeded for 198.51.100.0. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)"}
{"message": "API rate limit exceeded for user ID 12345.", "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}
{"message": "You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later."}
Critical response headers to read:
x-ratelimit-limit— total requests allowed in the current windowx-ratelimit-remaining— requests remaining before hitting the limitx-ratelimit-reset— Unix timestamp (UTC epoch seconds) when the window resetsx-ratelimit-used— requests consumed so far in this windowRetry-After— seconds to wait before retrying (not always present)
Check your real-time rate limit status without consuming quota:
curl -s -H "Authorization: Bearer YOUR_TOKEN" https://api.github.com/rate_limit | jq '.resources'
The /rate_limit endpoint itself never counts against any limit.
Fix: Calculate the exact sleep duration from the reset timestamp: sleep_seconds = x-ratelimit-reset - $(date +%s) + 5. The +5 adds a small buffer for clock skew.
HTTP 502 Bad Gateway
What you see:
{"message": "Server Error"}
or an HTML page from GitHub's CDN with text like "There is a problem with the server."
502 errors originate in GitHub's infrastructure, not your client. They indicate a timeout or error between GitHub's edge proxy and its internal API servers. They are almost always transient and resolve within seconds to minutes.
When 502s are more likely: Requests that return very large payloads (listing all commits on a monorepo, fetching a large raw file), during GitHub-wide incident windows, or when making requests to GitHub Enterprise Server instances under heavy load.
Fix: Use exponential backoff starting at 1 second: wait 1s, 2s, 4s, 8s, 16s with random jitter (multiply by a random float between 0.5 and 1.5). Check https://www.githubstatus.com/ for active incidents — if there is an ongoing API incident, stop retrying until it resolves to avoid contributing to the load.
Timeouts
What you see:
Error: connect ETIMEDOUT api.github.com:443
Error: read ECONNRESET
curl: (28) Operation timed out after 30000 milliseconds with 0 bytes received
Causes: Corporate proxy timeout thresholds, a request generating a very large response that exceeds the client's read timeout, or degraded network path to GitHub's servers.
Fix: Set an explicit client-side timeout of 30 seconds. Paginate large list responses rather than requesting all records in one call. If timeouts are consistent from your network, test from a different egress IP or check for proxy interception.
Step 1: Build a Header-Aware HTTP Client
The single most impactful change you can make is reading rate limit headers on every response and acting on them before sending the next request. Never assume your request will succeed — always check x-ratelimit-remaining and preemptively sleep when it reaches a low threshold (e.g., < 10).
Step 2: Implement ETag Conditional Requests
GitHub returns an ETag header on most GET responses. Store the ETag value and send it as If-None-Match: <etag> on subsequent requests to the same URL. If the resource has not changed, GitHub returns 304 Not Modified — this response does not consume any rate limit quota. For polling scenarios, this optimization alone can cut API usage by 70–90%.
Step 3: Upgrade to GitHub Apps for High-Volume Use
If a single PAT is the bottleneck, create a GitHub App and install it on your organization. Installation tokens provide 15,000 requests per hour for orgs with 20+ users. Multiple installations multiply this budget further, though each installation must be used for requests scoped to the repositories it has been granted access to.
Step 4: Migrate Hot Read Paths to GraphQL
If your REST API usage involves fetching many related resources (a repository plus its topics, its latest release, and its contributors), a single GraphQL query can retrieve all of this in one request. This gives you more data per rate-limit unit consumed.
Frequently Asked Questions
#!/usr/bin/env bash
# GitHub API Rate Limit Diagnostic & Retry Toolkit
# Usage: source this file or run individual functions
API_BASE="https://api.github.com"
GH_TOKEN="${GITHUB_TOKEN:-}"
# ─── 1. Check all rate limit categories ───────────────────────────────────────
check_rate_limit() {
echo "=== Rate Limit Status ==="
curl -s \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${API_BASE}/rate_limit" \
| jq -r '.resources | to_entries[] |
"\(.key): \(.value.remaining)/\(.value.limit) remaining, resets \(.value.reset | todate)"'
}
# ─── 2. Validate token, list scopes, show rate limit headers ──────────────────
check_token() {
echo "=== Token Validation ==="
curl -sI \
-H "Authorization: Bearer ${GH_TOKEN}" \
"${API_BASE}/user" \
| grep -iE '^(http|x-oauth-scopes|x-ratelimit-remaining|x-ratelimit-limit|x-github-sso)'
}
# ─── 3. API call with full header-aware retry logic ───────────────────────────
api_call_with_retry() {
local url="$1"
local max_retries=5
local attempt=0
local backoff=1
while [ "${attempt}" -lt "${max_retries}" ]; do
# Capture headers and body together
local raw_response
raw_response=$(curl -si \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--max-time 30 \
"${url}")
local status
status=$(printf '%s' "${raw_response}" | grep -m1 'HTTP/' | awk '{print $2}')
local retry_after
retry_after=$(printf '%s' "${raw_response}" | grep -i '^retry-after:' | awk '{print $2}' | tr -d '\r')
local rate_reset
rate_reset=$(printf '%s' "${raw_response}" | grep -i '^x-ratelimit-reset:' | awk '{print $2}' | tr -d '\r')
local remaining
remaining=$(printf '%s' "${raw_response}" | grep -i '^x-ratelimit-remaining:' | awk '{print $2}' | tr -d '\r')
echo "[attempt $((attempt+1))/${max_retries}] HTTP ${status} | remaining=${remaining:-?}" >&2
case "${status}" in
200|201|204)
# Print only the body (everything after the blank line)
printf '%s' "${raw_response}" | awk 'NR==1{found=0} /^\r?$/{found=1; next} found{print}'
return 0
;;
401)
echo "FATAL 401: Bad credentials — check token validity and SSO authorization" >&2
return 1
;;
403)
local body
body=$(printf '%s' "${raw_response}" | tail -1)
if printf '%s' "${body}" | grep -q 'secondary rate limit'; then
local wait_for=60
[ -n "${retry_after}" ] && wait_for="${retry_after}"
echo "WARNING 403 secondary rate limit — waiting ${wait_for}s" >&2
sleep "${wait_for}"
else
echo "FATAL 403 permissions error: ${body}" >&2
return 1
fi
;;
429)
local wait_for
if [ -n "${retry_after}" ]; then
wait_for="${retry_after}"
echo "WARNING 429 — Retry-After: ${wait_for}s" >&2
elif [ -n "${rate_reset}" ]; then
local now
now=$(date +%s)
wait_for=$(( rate_reset - now + 5 ))
echo "WARNING 429 — waiting ${wait_for}s until x-ratelimit-reset" >&2
else
wait_for="${backoff}"
echo "WARNING 429 — backing off ${wait_for}s" >&2
backoff=$(( backoff * 2 ))
fi
sleep "${wait_for}"
;;
502|503|504)
echo "WARNING ${status} — server error, backing off ${backoff}s" >&2
sleep "${backoff}"
backoff=$(( backoff * 2 ))
;;
000)
echo "WARNING: timeout/network error, backing off ${backoff}s" >&2
sleep "${backoff}"
backoff=$(( backoff * 2 ))
;;
*)
echo "ERROR: unexpected HTTP ${status}" >&2
return 1
;;
esac
attempt=$(( attempt + 1 ))
done
echo "ERROR: exhausted ${max_retries} retries for ${url}" >&2
return 1
}
# ─── 4. ETag-aware conditional GET (304 = free, does not consume quota) ───────
ETAG_STORE="${TMPDIR:-/tmp}/gh_etag_cache.tsv"
conditional_get() {
local url="$1"
local key
key=$(printf '%s' "${url}" | sha256sum | awk '{print $1}')
local stored_etag
stored_etag=$(grep -m1 "^${key} " "${ETAG_STORE}" 2>/dev/null | cut -f2)
local extra_headers=()
[ -n "${stored_etag}" ] && extra_headers+=(-H "If-None-Match: ${stored_etag}")
local raw
raw=$(curl -si \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${extra_headers[@]}" \
"${url}")
local status new_etag
status=$(printf '%s' "${raw}" | grep -m1 'HTTP/' | awk '{print $2}')
new_etag=$(printf '%s' "${raw}" | grep -i '^etag:' | awk '{print $2}' | tr -d '\r"')
if [ "${status}" = "304" ]; then
echo "[304 CACHED — quota not consumed] ${url}" >&2
return 0
fi
if [ "${status}" = "200" ] && [ -n "${new_etag}" ]; then
# Upsert etag cache
grep -v "^${key} " "${ETAG_STORE}" 2>/dev/null > "${ETAG_STORE}.tmp" || true
printf '%s\t%s\n' "${key}" "${new_etag}" >> "${ETAG_STORE}.tmp"
mv "${ETAG_STORE}.tmp" "${ETAG_STORE}"
echo "[200 FRESH etag=${new_etag}] ${url}" >&2
fi
printf '%s' "${raw}" | awk '/^\r?$/{found=1; next} found{print}'
}
# ─── 5. Preemptive rate-limit guard (call before bulk operations) ──────────────
assert_rate_limit_headroom() {
local min_remaining="${1:-100}"
local remaining
remaining=$(curl -s \
-H "Authorization: Bearer ${GH_TOKEN}" \
"${API_BASE}/rate_limit" | jq -r '.resources.core.remaining')
if [ "${remaining}" -lt "${min_remaining}" ]; then
local reset_at
reset_at=$(curl -s \
-H "Authorization: Bearer ${GH_TOKEN}" \
"${API_BASE}/rate_limit" | jq -r '.resources.core.reset')
local sleep_for=$(( reset_at - $(date +%s) + 5 ))
echo "Rate limit headroom low (${remaining} < ${min_remaining}). Sleeping ${sleep_for}s." >&2
sleep "${sleep_for}"
fi
}
# ─── Usage ────────────────────────────────────────────────────────────────────
# check_rate_limit
# check_token
# api_call_with_retry "${API_BASE}/repos/octocat/Hello-World"
# conditional_get "${API_BASE}/repos/octocat/Hello-World"
# assert_rate_limit_headroom 200Error Medic Editorial
The Error Medic Editorial team consists of senior DevOps engineers and SREs with hands-on experience operating high-throughput integrations against GitHub's API at scale. Our troubleshooting guides are grounded in production incident postmortems, GitHub support escalation transcripts, and contributions to open-source API client libraries including octokit and ghapi.
Sources
- https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
- https://docs.github.com/en/rest/overview/resources-in-the-rest-api#secondary-rate-limits
- https://docs.github.com/en/developers/apps/building-github-apps/rate-limits-for-github-apps
- https://docs.github.com/en/rest/guides/best-practices-for-integrators
- https://stackoverflow.com/questions/20578800/github-api-rate-limits-and-avoiding-them
- https://github.com/octokit/octokit.js/discussions/2218