Error Medic

GitHub API Rate Limit Errors: Fix 401, 403, 429, 502 & Timeout Issues

Diagnose and fix GitHub API rate limit, 401 unauthorized, 403 forbidden, 429 too many requests, 502 bad gateway, and timeout errors with step-by-step commands.

Last updated:
Last verified:
1,988 words
Key Takeaways
  • HTTP 429 and some 403 responses both signal rate limiting — primary limits (5000 req/hr authenticated) vs secondary limits (burst/concurrency) require different fixes
  • HTTP 401 means a missing, expired, or malformed Authorization header; HTTP 403 can mean either insufficient token scope or a secondary rate limit hit
  • HTTP 502 and timeouts are usually transient GitHub infrastructure issues but can be triggered by extremely large response payloads or GraphQL query complexity
  • Quick fix summary: authenticate all requests (eliminates 60 req/hr anonymous limit), cache ETag/conditional requests, implement exponential backoff on Retry-After header, and switch to GraphQL to batch queries
Fix Approaches Compared
MethodWhen to UseTime to ImplementRisk
Add/rotate Personal Access TokenHTTP 401 or anonymous 403/4295 minutesLow — straightforward credential swap
Respect Retry-After / x-ratelimit-reset headerHTTP 429 or secondary-rate-limit 4031–2 hoursLow — standard backoff pattern
Conditional requests (ETag / If-None-Match)Repeated reads on same resource2–4 hoursLow — reduces quota consumption up to 100%
GitHub App + installation tokensHigh-volume production pipelines (up to 15,000 req/hr per org)1–2 daysMedium — OAuth app registration required
Switch REST to GraphQL batchingMany round-trips for related resources1–3 daysMedium — query complexity limits apply
Distribute load across multiple tokens/orgsSustained throughput beyond single-token limits1–2 daysMedium — token rotation logic required
Increase client timeout / chunked paginationHTTP 502 or read timeout on large repos2–4 hoursLow — pagination is always safer than large requests

Understanding GitHub API Rate Limit and HTTP Error Codes

The GitHub REST API enforces several distinct rate-limiting tiers, and each tier surfaces a different HTTP status code. Conflating them leads to wasted debugging time. This guide walks through every error code in the cluster — 401, 403, 429, 502, and timeout — with concrete diagnostic commands and reproducible fixes.


HTTP 401 Unauthorized

Exact error message:

{"message": "Requires authentication", "documentation_url": "https://docs.github.com/rest"}

or

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

Root causes:

  • No Authorization header present (anonymous request)
  • Token has been revoked, expired (fine-grained PATs support expiration), or deleted
  • Token string is malformed — common culprit is a trailing newline from echo $TOKEN vs printf $TOKEN
  • Using a GitHub App JWT that has expired (JWTs are valid for 10 minutes max)

Fix — Step 1: Verify the token is present and well-formed

Always use printf or strip trailing newlines when reading tokens from environment or files:

TOKEN=$(cat ~/.github_token | tr -d '\n')
curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user

A 200 response with your user object confirms the token is valid.

Fix — Step 2: Check token scopes

curl -sI -H "Authorization: Bearer $TOKEN" https://api.github.com/user \
  | grep -i x-oauth-scopes

The x-oauth-scopes header lists granted scopes. If it is empty, the token has no scopes — regenerate it with at least repo for private repository access.


HTTP 403 Forbidden

This is the most ambiguous code because GitHub uses 403 for two completely different problems.

Case A — Insufficient token scope or repository permission:

{"message": "Must have admin rights to Repository.", "documentation_url": "..."}

Fix: regenerate the token with appropriate scopes (admin:repo_hook, write:packages, etc.) or ask a repository admin to grant collaborator access.

Case B — Secondary rate limit (burst/concurrency):

{"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you retry your request.", "documentation_url": "..."}

Secondary limits exist to prevent abusive request patterns: more than ~100 concurrent requests, more than 900 points/minute on the REST API, or more than 2000 points/minute on GraphQL. These limits are not reflected in the x-ratelimit-remaining header.

Fix — Distinguish the two 403 types programmatically:

import httpx, time, re

def github_get(url, token, retries=5):
    for attempt in range(retries):
        r = httpx.get(url, headers={"Authorization": f"Bearer {token}"})
        if r.status_code == 200:
            return r.json()
        if r.status_code == 403:
            body = r.json().get("message", "")
            if "secondary rate limit" in body.lower():
                wait = int(r.headers.get("retry-after", 60))
                print(f"Secondary rate limit hit, sleeping {wait}s")
                time.sleep(wait)
                continue
            else:
                raise PermissionError(f"Permission denied: {body}")
        r.raise_for_status()
    raise RuntimeError("Max retries exceeded")

HTTP 429 Too Many Requests (Primary Rate Limit)

Authenticated REST API requests are capped at 5,000 requests per hour per user. GitHub Apps acting as an installation receive up to 15,000 per hour per organization. Unauthenticated requests are limited to 60 per hour per IP.

Exact error message:

{"message": "API rate limit exceeded for user ID 12345.", "documentation_url": "https://docs.github.com/rest/overview/rate-limits-for-the-rest-api"}

Check remaining quota before you hit zero:

curl -sI -H "Authorization: Bearer $TOKEN" https://api.github.com/rate_limit \
  | grep x-ratelimit
# x-ratelimit-limit: 5000
# x-ratelimit-remaining: 47
# x-ratelimit-reset: 1708694400   <-- Unix timestamp

Convert the reset timestamp:

date -d @1708694400

Fix — Conditional requests with ETags to conserve quota:

For resources that rarely change (repository metadata, user profiles), cache the ETag header and send If-None-Match on subsequent requests. A 304 Not Modified response costs zero against your rate limit.

# First request — capture ETag
ETAG=$(curl -sI -H "Authorization: Bearer $TOKEN" \
  https://api.github.com/repos/octocat/hello-world \
  | grep -i etag | awk '{print $2}' | tr -d '\r')

# Subsequent request — free if unchanged
curl -si -H "Authorization: Bearer $TOKEN" \
     -H "If-None-Match: $ETAG" \
     https://api.github.com/repos/octocat/hello-world \
  | head -1
# HTTP/2 304  <-- no quota consumed

HTTP 502 Bad Gateway

{"message": "Server Error"}

or an empty response body with status 502.

502s from GitHub are almost always transient infrastructure errors, but they are also reliably triggered by:

  • GraphQL queries exceeding the 5,000-node complexity limit
  • REST requests that would return payloads over ~1MB (e.g., listing all events on a very active repository without pagination)
  • Burst of parallel connections during a GitHub service incident

Diagnostic — Check GitHub status first:

curl -s https://www.githubstatus.com/api/v2/status.json | python3 -m json.tool | grep -A2 status

Fix — Paginate aggressively and add retry logic:

# Always paginate; never request more than 100 items per page
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.github.com/repos/OWNER/REPO/issues?per_page=100&page=1"

Connection Timeouts

Timeouts manifest as curl: (28) Operation timed out or equivalent library exceptions (requests.exceptions.ReadTimeout, net/http: request canceled).

Common causes:

  • Client-side timeout set too low for large Git data (tarball/blob endpoints)
  • Network path issue between CI runner and api.github.com
  • GitHub API response streaming stalled (rare, retry resolves it)

Fix — Increase timeout and verify DNS:

# Test DNS resolution time
time nslookup api.github.com

# Test with explicit timeout (30s)
curl --max-time 30 -H "Authorization: Bearer $TOKEN" \
  https://api.github.com/repos/OWNER/REPO/tarball/main -o /dev/null -s -w "%{http_code}\n"

For SDK users (Python requests library):

import requests
r = requests.get(
    "https://api.github.com/repos/OWNER/REPO/contents/",
    headers={"Authorization": f"Bearer {token}"},
    timeout=(5, 30)   # (connect_timeout, read_timeout) in seconds
)

Production-Grade Retry Wrapper

For any service making sustained GitHub API calls, a unified retry layer handles all the error codes above:

import time, math, httpx

def github_request(method, url, token, **kwargs):
    headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
    for attempt in range(6):
        try:
            r = httpx.request(method, url, headers=headers, timeout=30, **kwargs)
        except httpx.TimeoutException:
            wait = 2 ** attempt
            print(f"Timeout, retry {attempt+1} in {wait}s")
            time.sleep(wait)
            continue

        if r.status_code in (200, 201, 204, 304):
            return r

        if r.status_code == 401:
            raise ValueError("GitHub token invalid or expired — rotate credentials")

        if r.status_code == 403:
            msg = r.json().get("message", "")
            if "secondary rate limit" in msg.lower():
                wait = int(r.headers.get("retry-after", 60))
            else:
                raise PermissionError(msg)
            time.sleep(wait)
            continue

        if r.status_code == 429:
            reset = int(r.headers.get("x-ratelimit-reset", time.time() + 60))
            wait = max(reset - time.time(), 1)
            print(f"Primary rate limit, sleeping until reset ({wait:.0f}s)")
            time.sleep(wait)
            continue

        if r.status_code == 502:
            wait = 2 ** attempt
            print(f"502 Bad Gateway, retry {attempt+1} in {wait}s")
            time.sleep(wait)
            continue

        r.raise_for_status()

    raise RuntimeError(f"All retries exhausted for {url}")

Frequently Asked Questions

bash
#!/usr/bin/env bash
# GitHub API Diagnostic Script
# Usage: TOKEN=ghp_xxx bash github_api_diag.sh [owner/repo]

set -euo pipefail
API="https://api.github.com"
REPO="${1:-octocat/hello-world}"

if [[ -z "${TOKEN:-}" ]]; then
  echo "ERROR: Set TOKEN environment variable"
  exit 1
fi

echo "=== 1. Validate token ==="
USER_RESP=$(curl -sf -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.github+json" \
  "$API/user" 2>&1) && echo "Token valid — authenticated as: $(echo $USER_RESP | python3 -c 'import sys,json; print(json.load(sys.stdin)["login"])')" \
  || echo "FAIL: $USER_RESP"

echo ""
echo "=== 2. Check rate limit quota ==="
curl -sI -H "Authorization: Bearer $TOKEN" \
  "$API/rate_limit" | grep -i x-ratelimit

echo ""
echo "=== 3. Check x-ratelimit-reset in human time ==="
RESET=$(curl -sI -H "Authorization: Bearer $TOKEN" "$API/rate_limit" \
  | grep -i x-ratelimit-reset | awk '{print $2}' | tr -d '\r')
[[ -n "$RESET" ]] && echo "Rate limit resets at: $(date -d @$RESET 2>/dev/null || date -r $RESET)" || echo "Could not parse reset time"

echo ""
echo "=== 4. Check token scopes ==="
curl -sI -H "Authorization: Bearer $TOKEN" "$API/user" \
  | grep -i 'x-oauth-scopes\|x-accepted-oauth-scopes'

echo ""
echo "=== 5. Test conditional request (ETag caching) ==="
ETAG=$(curl -sI -H "Authorization: Bearer $TOKEN" \
  "$API/repos/$REPO" \
  | grep -i '^etag' | awk '{print $2}' | tr -d '\r')
if [[ -n "$ETAG" ]]; then
  STATUS=$(curl -so /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer $TOKEN" \
    -H "If-None-Match: $ETAG" \
    "$API/repos/$REPO")
  echo "Conditional request returned: $STATUS (304 = free, no quota used)"
else
  echo "WARN: No ETag returned for $API/repos/$REPO"
fi

echo ""
echo "=== 6. Check GitHub status ==="
STATUS=$(curl -s https://www.githubstatus.com/api/v2/status.json \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["status"]["description"])')
echo "GitHub status: $STATUS"

echo ""
echo "=== 7. Test timeout sensitivity ==="
TIME=$(curl -so /dev/null -w "%{time_total}" --max-time 10 \
  -H "Authorization: Bearer $TOKEN" \
  "$API/repos/$REPO" 2>&1)
echo "Response time: ${TIME}s (warn if >5s)"

echo ""
echo "=== Diagnostic complete ==="
E

Error Medic Editorial

The Error Medic Editorial team consists of senior DevOps engineers, SREs, and platform engineers with combined experience across GitHub Actions, GitLab CI, and large-scale API integrations. We test every command and code snippet against live APIs before publishing.

Sources

Related Guides