GitHub API rate limit exceeded in Actions
GitHub API rate limit exceeded in Actions means the credential that step used has spent its hourly budget, and which budget that is depends entirely on what you authenticated with. GITHUB_TOKEN gets 1,000 requests an hour per repository and an unauthenticated call gets 60 an hour for the address the runner is behind, and those two numbers are far enough apart that most of these failures are a missing token rather than a busy workflow.

What this error means
A step that calls the API fails with a 403 and a message naming a limit, and the shape of the message tells you which credential was spent: an address for an unauthenticated call, a user ID for a personal token, the word installation for a GitHub App. A secondary limit reads differently again, mentioning abuse detection or asking you to retry later, and it is not the same budget. The response headers are the real diagnosis, because x-ratelimit-limit says which bucket you were in without you having to guess: 60, 1,000 and 5,000 are three different stories. This page carries no recorded run. Spending a runner's unauthenticated budget is easy to do and takes an hour to give back, and the address is shared with every other job leaving the same gateway, so the block below is the sample text the Latchkey pattern library matches on, labeled as such, and every number on the page is GitHub's own, read on 2026-09-20.
GraphQL: API rate limit exceeded for user ID 12345.
API rate limit exceeded for installation. If you reach out to GitHub Support for help, please include the request ID
x-ratelimit-remaining: 0Which budget your step was spending
GitHub publishes the primary limits per credential, and the gap between them is the whole story. A step that forgot its token is not slightly over budget; it is on roughly a sixteenth of the budget it was supposed to be using.
| What the request carried | Requests per hour | Counted against |
|---|---|---|
| Nothing | 60 | The IP address the request came from |
GITHUB_TOKEN in a workflow | 1,000 | The repository the workflow runs in |
| A personal access token | 5,000 | The user account |
| A GitHub App installation token | 5,000 or more | The installation |
Common causes
The call was unauthenticated and nobody noticed
A curl without an authorization header, gh in a step where GH_TOKEN was never exported, or a third-party action that calls the API anonymously. It works for weeks because 60 requests an hour is enough for a quiet repository, and it fails the day traffic doubles. The address is also shared, so other jobs on the same gateway are spending the same 60.
The workflow token budget is per repository, not per job
The 1,000 an hour figure covers every job in every workflow run in that repository at once. A matrix of twenty legs that each list pull requests, on a repository with a busy merge queue, can spend it in a morning. Nothing in the failing job looks extravagant, because the spend is spread across all of them.
A step is paginating through more than it needs
Listing every workflow run, every artifact or every commit on a branch is a hundred requests before it is anything else, and the default page size makes it worse. In our experience one careless listing step accounts for most of the traffic in a repository that runs out of budget, and it is usually a step that could have asked a question instead of downloading an answer.
You hit a secondary limit and treated it as a primary one
Concurrency and content creation have their own ceilings, and a job that opens comments, creates issues or dispatches workflows in a loop can trip them with a total request count nowhere near the hourly budget. The message differs, the headers differ, and the fix is to slow down rather than to find a bigger token.
How to fix it
Authenticate every call, including the ones inside actions
- Pass
GITHUB_TOKENto every step that touches the API, including third-party actions that accept a token input. - Export
GH_TOKENfor theghCLI, which will otherwise run unauthenticated and quietly spend the 60. - Grant only the permissions the step needs, so the bigger budget does not come with a bigger blast radius.
permissions:
contents: read
pull-requests: read
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr list --limit 20 --json number,titleAsk for less, in fewer requests
Raise the page size, filter server side, and prefer one GraphQL query over a walk through REST endpoints that fetches ten times what you use. A listing that returns 100 items per page instead of 30 is the same data for a third of the budget.
gh api --paginate -X GET /repos/$GITHUB_REPOSITORY/pulls \
-F per_page=100 -f state=open --jq '.[] | .number'Wait the way GitHub asks you to
Honor retry-after when it is present, and otherwise wait until x-ratelimit-reset rather than retrying on a timer of your own. If the reset is further away than the job can reasonably wait, fail the step with the reset time in the message, so the person reading the log knows when to try again instead of re-running immediately.
reset=$(grep -i '^x-ratelimit-reset:' headers.txt | tr -d '\r' | cut -d' ' -f2)
echo "budget returns in $((reset - $(date -u +%s))) seconds"Move up a budget when the work genuinely needs it
Cross-repository automation is the case the workflow token is not built for, and a GitHub App installation token is the documented answer: its own budget, its own permissions, and no dependence on one person's account. For work inside one repository, reducing requests is almost always cheaper than changing credentials.
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}Primary and secondary limits are different failures
The numbers above are the primary limits, and they are a budget per hour. Secondary limits are about shape rather than volume: GitHub documents no more than 100 concurrent requests, no more than 900 points per minute against REST endpoints and 2,000 against GraphQL, and for anything that creates content no more than 80 requests per minute and 500 per hour.
This matters because the fixes are opposite. A primary limit is fixed by spending fewer requests or by moving to a bigger budget. A secondary limit is fixed by slowing down: GitHub's own advice is to make requests serially rather than concurrently, and to queue them. A workflow that answers a secondary limit by adding parallel retries will make it worse in a way that looks random.
The headers tell you what to do, exactly
Every response carries the state of your budget, and GitHub's guidance is specific about reading it. "If the retry-after response header is present, you should not retry your request until after that many seconds has elapsed." And when you are out: "If the x-ratelimit-remaining header is 0, you should not make another request until after the time specified by the x-ratelimit-reset header."
That is a better retry policy than any backoff you can write, because it is not a guess. x-ratelimit-reset is an epoch timestamp, so the wait is arithmetic, and a step that reads it can decide whether waiting is even sensible inside a job that has a timeout.
resp=$(curl -sS -D headers.txt -o body.json \
-H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/$GITHUB_REPOSITORY/pulls)
grep -i '^x-ratelimit-' headers.txtWhat a runner does about it
Latchkey runs GitHub Actions jobs on managed runners that read a failing step and act on what it says. A rate limit is an awkward member of that family: it is genuine and transient, but the window is measured in an hour rather than in seconds, so waiting it out inside a job is rarely the right answer and never a free one. This page names no pattern and claims no repair, because both belong with a recorded run. See how self-healing works for what the runner does with a failing step, and where it hands the problem back.
How to prevent it
- Pass a token to every step and action that calls the API, and fail the workflow lint if one is missing.
- Log
x-ratelimit-remainingin API-heavy jobs so you see the budget draining before a build goes red. - Keep listing steps filtered and paginated at 100 per page.
- Serialize anything that creates content, and keep it well under 80 requests a minute.
Frequently asked questions
How many API requests does GITHUB_TOKEN get per hour?
Why does my workflow get API rate limit exceeded after 60 requests?
Why does a third-party action fail with API rate limit exceeded when my own steps do not?
Should my step retry when x-ratelimit-remaining is zero?
x-ratelimit-reset, which is GitHub's own instruction. A retry before then spends nothing and gains nothing, and on a secondary limit it can extend the block. Read the header, work out the wait, and if it is longer than the job should sit idle, fail with the reset time in the message.