# GitHub API rate limit exceeded in Actions

> GitHub API rate limit exceeded in Actions means the token your workflow step used has spent its hour. Which budget you are on, and how to stay in it.

Source: https://latchkey.dev/learn/failures/github-api-rate-limit-exceeded-in-ci  
Updated: 2026-09-20

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.

```Sample log from the Latchkey pattern library
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: 0
```

## 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

1. Pass `GITHUB_TOKEN` to every step that touches the API, including third-party actions that accept a token input.
2. Export `GH_TOKEN` for the `gh` CLI, which will otherwise run unauthenticated and quietly spend the 60.
3. Grant only the permissions the step needs, so the bigger budget does not come with a bigger blast radius.

```.github/workflows/ci.yml
permissions:
  contents: read
  pull-requests: read

steps:
  - env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    run: gh pr list --limit 20 --json number,title
```

### Ask 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.

```Terminal
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.

```Terminal
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.

```.github/workflows/ci.yml
- uses: actions/create-github-app-token@v1
  id: app-token
  with:
    app-id: ${{ vars.APP_ID }}
    private-key: ${{ secrets.APP_PRIVATE_KEY }}
```

## 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-remaining` in 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.

## Which 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 |

> On GitHub Enterprise Cloud the workflow token figure is 15,000 requests per hour per repository, and an App installed on an Enterprise Cloud organization also gets 15,000.

## 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.

```Terminal
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.txt
```

## What 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](/documentation/self-healing) for what the runner does with a failing step, and where it hands the problem back.

## FAQ

### How many API requests does GITHUB_TOKEN get per hour?

GitHub documents 1,000 requests per hour per repository for the token in a workflow, and 15,000 per hour per repository on GitHub Enterprise Cloud. The budget belongs to the repository rather than to the job, so every workflow run in that repository draws on the same pool at the same time.

### Why does my workflow get API rate limit exceeded after 60 requests?

Because the request was unauthenticated. GitHub gives unauthenticated requests 60 per hour, counted against the originating IP address rather than against any account, and hosted runners share addresses. The fix is to pass a token, not to retry: the workflow budget is about sixteen times bigger.

### Why does a third-party action fail with API rate limit exceeded when my own steps do not?

Because most actions that read the API take a token as an input and fall back to unauthenticated requests when it is missing, which puts them on 60 an hour while your own steps spend the repository's 1,000. Labeler and changed-files actions are the ones this catches most often. Pass the token to every action that reads the API, not only to the steps you wrote.

### Should my step retry when x-ratelimit-remaining is zero?

Not before the time in `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.

## References

- [GitHub Docs: rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
- [GitHub Docs: best practices for using the REST API](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api)
- [cli/cli#8321: API rate limit exceeded from the gh CLI](https://github.com/cli/cli/issues/8321)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
