Error Medic

GitHub API Rate Limit Exceeded (401, 403, 429, 502, Timeout): Complete Troubleshooting Guide

Fix GitHub API rate limit errors (401, 403, 429, 502, timeout) with proven diagnostic commands, token rotation scripts, and exponential backoff strategies.

Last updated:
Last verified:
2,084 words
Key Takeaways
  • HTTP 429 / 'API rate limit exceeded' means you've exhausted your hourly quota (60 req/hr unauthenticated, 5000 req/hr authenticated); check X-RateLimit-Remaining and X-RateLimit-Reset headers to confirm.
  • HTTP 401 'Bad credentials' indicates an invalid, expired, or revoked Personal Access Token (PAT) or GitHub App installation token — regenerate and re-export the token immediately.
  • HTTP 403 'Resource not accessible by integration' means the token lacks required OAuth scopes or the GitHub App is missing repository permissions; verify scopes with GET /user and add the missing permission.
  • HTTP 502 Bad Gateway and persistent timeouts point to GitHub infrastructure issues or oversized payloads; reduce page sizes, enable retries with exponential backoff, and monitor githubstatus.com.
  • Quick fix summary: authenticate every request (5000 req/hr vs 60), cache responses with ETags, implement exponential backoff with jitter, use conditional requests, and distribute load across multiple tokens or GitHub Apps.
Fix Approaches Compared
MethodWhen to UseTime to ImplementRisk
Authenticate with PATUnauthenticated client hitting 60 req/hr limit5 minutesLow — just add Authorization header
GitHub App Installation TokenCI/CD pipelines or multi-repo automation needing 15,000 req/hr30–60 minutesMedium — requires App registration and JWT signing
Conditional Requests (ETag/If-None-Match)Polling endpoints that rarely change (branches, releases)15 minutesLow — server returns 304 and does not count against quota
Exponential Backoff with JitterTransient 429, 502, or timeout errors under burst load30 minutesLow — pure client-side retry logic
Token Rotation PoolHigh-throughput scripts exceeding 5000 req/hr per token1–2 hoursMedium — requires secure secret storage and rotation logic
GraphQL Batching (single request)REST pagination consuming hundreds of requests for related data2–4 hoursMedium — GraphQL has own rate limit (5000 points/hr)
Response Caching (Redis/Memcached)Repeated identical queries within the rate-limit window2–4 hoursLow — stale data risk if TTL is too long
Secondary Rate Limit Back-offPOSTs/mutations hitting 1000 points/min secondary limit30 minutesLow — honor Retry-After header value exactly

Understanding GitHub API Rate Limit Errors

GitHub enforces multiple rate limit tiers on its REST and GraphQL APIs. Understanding which limit you've hit is the critical first diagnostic step — the error code and response headers tell the full story.

Rate Limit Tiers

Primary rate limit (REST): Unauthenticated requests are capped at 60 requests per hour per originating IP. Authenticated requests (PAT, OAuth token, GitHub App) receive 5,000 requests per hour per user or installation. GitHub Apps acting on behalf of organizations can reach 15,000 requests per hour.

Secondary rate limit: GitHub imposes a concurrent request cap and a points-per-minute cap (~1,000 points/minute for mutations). You'll see a Retry-After header and HTTP 429 or occasionally 403 when this limit triggers.

GraphQL rate limit: Measured in query complexity points. Each query costs between 1–5,000 points depending on resource count. Budget is 5,000 points per hour.


Step 1: Read the Response Headers

Every GitHub API response includes rate limit headers. Always log these in your HTTP client:

X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1708732800
X-RateLimit-Used: 5000
X-RateLimit-Resource: core
Retry-After: 3600

X-RateLimit-Reset is a Unix timestamp. Convert it to human-readable time with date -d @1708732800. Retry-After appears on secondary rate limit responses and must be honoured — do not retry before this time.

For the GraphQL API, query the rate limit endpoint directly:

query {
  rateLimit {
    limit
    cost
    remaining
    resetAt
    nodeCount
  }
}

Step 2: Identify the Specific Error

HTTP 401 — Bad credentials

Exact error body:

{"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}

Causes: expired PAT, revoked token, incorrect Authorization header format, using a deleted GitHub App credential.

Fix: Run curl -H "Authorization: Bearer YOUR_TOKEN" https://api.github.com/user and verify the token is active in Settings → Developer settings → Personal access tokens. Ensure the header uses Bearer (not token) for fine-grained PATs.

HTTP 403 — Forbidden / Resource not accessible

Exact error body:

{"message": "Resource not accessible by integration", "documentation_url": "https://docs.github.com/rest/overview/permissions-required-for-github-apps"}

Causes: OAuth token missing required scope, GitHub App installation lacks repository permission, organization SSO not authorized for the token.

Fix: Inspect the X-OAuth-Scopes header in any API response to see granted scopes. Compare against X-Accepted-OAuth-Scopes which lists required scopes. If the token is SAML-protected, navigate to Settings → Applications → Authorized OAuth Apps and click Authorize next to your organization.

HTTP 429 — Too Many Requests (Secondary Rate Limit)

Exact error body:

{"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/rate-limits-for-the-rest-api"}

Fix: Immediately stop all requests. Read the Retry-After header value in seconds. Sleep for that exact duration before resuming. Do not parallelize write operations (POST, PUT, PATCH, DELETE) — GitHub counts concurrent mutations heavily against the secondary limit.

HTTP 502 — Bad Gateway

Causes: Oversized response payloads, GitHub infrastructure instability, network intermediaries.

Fix: Reduce per_page from 100 to 30, add request timeouts (15–30s), implement retry logic, and check https://www.githubstatus.com for active incidents.

Timeout (no HTTP response)

Causes: Very large repository operations (git clone, archive download), slow network, lack of request timeout configuration.

Fix: Set explicit connection and read timeouts in your HTTP client. For curl, use --max-time 30. For Python requests, use requests.get(url, timeout=(5, 30)) (connect timeout, read timeout).


Step 3: Apply the Correct Fix

Fix A — Add authentication

The single highest-impact change. Move from 60 to 5,000 requests/hour instantly:

export GITHUB_TOKEN=ghp_yourtoken
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
     -H "Accept: application/vnd.github+json" \
     https://api.github.com/rate_limit

Fix B — Implement conditional requests

Cache the ETag from any GET response and send If-None-Match on subsequent requests. A 304 response costs 0 against your quota:

# First request — capture ETag
ETAG=$(curl -si -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/owner/repo/branches \
  | grep -i etag | awk '{print $2}' | tr -d '\r')

# Subsequent request — returns 304 if unchanged, free of charge
curl -si -H "Authorization: Bearer $GITHUB_TOKEN" \
     -H "If-None-Match: $ETAG" \
     https://api.github.com/repos/owner/repo/branches

Fix C — Exponential backoff with jitter

For any 429, 500, or 502 response:

import time, random, requests

def github_get(url, token, max_retries=5):
    headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers, timeout=(5, 30))
        if resp.status_code == 200:
            return resp
        if resp.status_code == 429 or resp.status_code == 503:
            retry_after = int(resp.headers.get("Retry-After", 0))
            if retry_after:
                time.sleep(retry_after)
            else:
                sleep_time = (2 ** attempt) + random.uniform(0, 1)
                time.sleep(sleep_time)
        elif resp.status_code == 403 and "rate limit" in resp.text.lower():
            reset_time = int(resp.headers.get("X-RateLimit-Reset", time.time() + 3600))
            time.sleep(max(reset_time - time.time(), 1))
        else:
            resp.raise_for_status()
    raise Exception(f"Max retries exceeded for {url}")

Fix D — Token rotation

For high-throughput automation, rotate across multiple tokens when X-RateLimit-Remaining drops below a threshold:

import itertools
TOKENS = ["ghp_token1", "ghp_token2", "ghp_token3"]
token_pool = itertools.cycle(TOKENS)

def get_healthy_token(tokens):
    import requests
    for token in tokens:
        r = requests.get("https://api.github.com/rate_limit",
                         headers={"Authorization": f"Bearer {token}"})
        data = r.json()
        if data["resources"]["core"]["remaining"] > 100:
            return token
    return None  # all tokens exhausted — wait for reset

Step 4: Verify the Fix

After applying changes, confirm your rate limit status and token validity:

curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/rate_limit | jq '.resources.core'

Expected healthy output:

{"limit": 5000, "used": 42, "remaining": 4958, "reset": 1708736400}

If remaining is 0, calculate wait time:

echo $(( $(date -d @$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/rate_limit | jq .resources.core.reset) +%s) - $(date +%s) )) seconds until reset

Frequently Asked Questions

bash
#!/usr/bin/env bash
# github-api-diagnose.sh — GitHub API rate limit diagnostic toolkit
# Usage: GITHUB_TOKEN=ghp_xxx bash github-api-diagnose.sh

set -euo pipefail
API="https://api.github.com"
HEADERS=(-H "Authorization: Bearer ${GITHUB_TOKEN:-}" -H "Accept: application/vnd.github+json")

# ── 1. Validate token and identity ─────────────────────────────────────────
echo "=== Token Identity ==="
STATUS=$(curl -sw "%{http_code}" -o /tmp/gh_user.json "${HEADERS[@]}" "$API/user")
if [[ "$STATUS" == "200" ]]; then
  jq '{login, type: .type, scopes: "see X-OAuth-Scopes header"}' /tmp/gh_user.json
else
  echo "ERROR: HTTP $STATUS — token invalid or missing"
  cat /tmp/gh_user.json
  exit 1
fi

# ── 2. Check all rate limit resources ──────────────────────────────────────
echo -e "\n=== Rate Limit Status ==="
curl -s "${HEADERS[@]}" "$API/rate_limit" | jq '
  .resources | to_entries[] |
  select(.value.remaining < (.value.limit * 0.2)) |
  {resource: .key, remaining: .value.remaining, limit: .value.limit,
   resets_in_seconds: (.value.reset - now | floor)}
'

# ── 3. Show full rate limit for core resource ───────────────────────────────
echo -e "\n=== Core Resource Detail ==="
curl -s "${HEADERS[@]}" "$API/rate_limit" | jq '.resources.core + {resets_at: (.resources.core.reset | todate)}'

# ── 4. Check OAuth scopes granted to token ─────────────────────────────────
echo -e "\n=== Token Scopes ==="
curl -si "${HEADERS[@]}" "$API/user" | grep -i 'x-oauth-scopes\|x-ratelimit' | sort

# ── 5. Check secondary rate limit with a safe read request ─────────────────
echo -e "\n=== Secondary Rate Limit Probe ==="
HTTP_CODE=$(curl -sw "%{http_code}" -o /tmp/gh_probe.json "${HEADERS[@]}" "$API/repos/octocat/Hello-World")
if [[ "$HTTP_CODE" == "429" ]]; then
  RETRY=$(curl -si "${HEADERS[@]}" "$API/repos/octocat/Hello-World" | grep -i retry-after | awk '{print $2}')
  echo "Secondary rate limit hit. Retry-After: ${RETRY} seconds"
elif [[ "$HTTP_CODE" == "403" ]]; then
  MSG=$(jq -r .message /tmp/gh_probe.json)
  echo "Forbidden: $MSG"
else
  echo "OK (HTTP $HTTP_CODE) — secondary limits appear healthy"
fi

# ── 6. Calculate time until reset ──────────────────────────────────────────
echo -e "\n=== Time Until Core Reset ==="
RESET_TS=$(curl -s "${HEADERS[@]}" "$API/rate_limit" | jq .resources.core.reset)
NOW=$(date +%s)
WAIT=$(( RESET_TS - NOW ))
if [[ $WAIT -gt 0 ]]; then
  echo "Rate limit resets in ${WAIT} seconds ($(date -d @$RESET_TS))"
else
  echo "Rate limit window has already reset"
fi

# ── 7. Test conditional request (ETag caching) ─────────────────────────────
echo -e "\n=== ETag / Conditional Request Test ==="
RESPONSE=$(curl -si "${HEADERS[@]}" "$API/repos/octocat/Hello-World/branches")
ETAG=$(echo "$RESPONSE" | grep -i '^etag:' | awk '{print $2}' | tr -d '\r')
if [[ -n "$ETAG" ]]; then
  COND_STATUS=$(curl -sw "%{http_code}" -o /dev/null "${HEADERS[@]}" \
    -H "If-None-Match: $ETAG" "$API/repos/octocat/Hello-World/branches")
  echo "ETag: $ETAG"
  echo "Conditional request status: $COND_STATUS (304 = free cache hit, 200 = data changed)"
else
  echo "No ETag returned — endpoint may not support conditional requests"
fi

echo -e "\nDiagnostic complete."
E

Error Medic Editorial

The Error Medic Editorial team comprises senior DevOps engineers and SRE practitioners with decades of combined experience operating distributed systems at scale. Our troubleshooting guides are written from real incident post-mortems and validated against current API documentation, ensuring every fix command works against the production endpoints described.

Sources

Related Guides