Error Medic

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.

Last updated:
Last verified:
2,722 words
Key Takeaways
  • 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
Fix Approaches Compared
MethodWhen to UseTime to ImplementRisk
Add or refresh Personal Access TokenGetting 401 or hitting 60 req/hr unauthenticated ceiling5 minutesLow
Conditional requests with ETag cachingPolling endpoints that rarely change (repo metadata, user info)30 minutesLow
Header-aware retry with Retry-AfterHandling 429, 502, and transient 503 errors1–2 hoursLow
Serialize mutation requests with 1s delayTriggering secondary 403 rate limits on POST/PATCH/DELETE1 hourLow
GitHub Apps installation tokensProduction workloads exceeding 5,000 req/hr on a single tokenHalf dayMedium
ETag response cache in RedisMultiple services querying the same GitHub endpoints1 dayMedium
GraphQL API migrationREST calls that over-fetch and waste rate limit budgetDays to weeksMedium-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 older token ghp_yourtoken format still works but Bearer is preferred.
  • Expired or revoked PAT: Tokens with expiration dates silently stop working. Regenerate the token at github.com/settings/tokens and 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 window
  • x-ratelimit-remaining — requests remaining before hitting the limit
  • x-ratelimit-reset — Unix timestamp (UTC epoch seconds) when the window resets
  • x-ratelimit-used — requests consumed so far in this window
  • Retry-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

bash
#!/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 200
E

Error 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

Related Guides