Hitting API Rate Limits Inside Workflows

GitHub API rate limits inside GitHub Actions: which token owns the hourly budget, how to read the x-ratelimit headers, back off safely, and cut calls.

A GitHub API call made inside a workflow is charged to whichever credential the step sent, and each credential carries its own hourly budget: 1,000 REST requests per hour per repository for GITHUB_TOKEN, 5,000 per hour for a personal access token, and 60 per hour for a call with no credential at all (rate limits for the REST API, checked on 2026-08-13). When a budget runs out, GitHub answers 403 or 429 with x-ratelimit-remaining: 0 and an x-ratelimit-reset timestamp, so the working fix is to read those headers, wait or exit on purpose, and then reduce the number of calls the job makes.

This guide covers which identity a call is attributed to and how that changes the limit that applies, workflow YAML that reads the headers and backs off instead of failing the job, the structural fixes that keep runs under the budget, and what the two wasteful designs cost per month on real runner rates.

Diagnosis

Start by separating the four failures that all surface as a red step.

What you seeWhat it isWhere to look
403 or 429, x-ratelimit-remaining: 0Primary rate limit for that credential and resourcex-ratelimit-reset, the UTC epoch second the window resets
403 or 429 with a retry-after headerSecondary rate limit, such as too many concurrent requestsThe retry-after value, in seconds
403 with budget remaining and no retry-afterPermissions. The token cannot do that thingThe permissions: block and the token's scopes
404 on a repository you can see in a browserThe token cannot see the repositoryThe token's repository access list

Every REST response carries x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-used, x-ratelimit-reset, and x-ratelimit-resource, which names the budget the call drew from (rate limits for the REST API, checked on 2026-08-13). GraphQL is tracked as its own resource with a points budget rather than a request count, and GET /rate_limit reports every resource at once without drawing down any of them.

Which identity the call is attributed to

The limit that applies is a property of the credential, so a step that swaps GITHUB_TOKEN for a personal access token moves the whole job onto a different budget.

Credential the step sendsWhat GitHub attributes it toDocumented primary REST budget
secrets.GITHUB_TOKEN or github.tokenAn installation token scoped to the repository running the workflow1,000 requests per hour per repository, and 15,000 per hour per repository for repositories owned by a GitHub Enterprise Cloud account
A personal access token stored as a secretThe user who minted it, pooled across every workflow, every repository, and that person's laptop5,000 requests per hour
A GitHub App installation token from actions/create-github-app-tokenThe installation5,000 requests per hour, scaling with organization size to a documented ceiling of 12,500 per hour
No credential, such as a plain curl https://api.github.com/...The source IP address60 requests per hour

Budgets and attribution come from rate limits for the REST API and automatic token authentication, both checked on 2026-08-13. Two consequences catch teams out. A personal access token shared across an organization is one 5,000 per hour pool for everyone using it, so an unrelated team's backfill script can starve your pull request checks; the personal access token entry covers the token type on its own, and GITHUB_TOKEN covers the per-job alternative. And an action that reaches api.github.com without a token lands on the 60 per hour per IP budget, which a runner shares with everything else behind the same egress address.

Secondary limits sit on top of all of this and no header budget predicts them. GitHub documents a cap of 100 concurrent requests across REST and GraphQL, and a cap on content-creating requests such as posting comments, at 80 per minute and 500 per hour (rate limits for the REST API, checked on 2026-08-13). A 20-way matrix that fans out API calls at the same instant trips the concurrency cap while the hourly budget still reads healthy.

Fix

Send the narrowest credential that works. GITHUB_TOKEN for anything inside the current repository, a GitHub App installation token when the job crosses a repository boundary, and a personal access token only when nothing else fits. A GitHub App installation also starts from a larger budget than a repository-scoped GITHUB_TOKEN and expires within the hour.

Never call the API without a credential. A step that fetches a release asset or resolves a tool version anonymously is spending the 60 per hour per IP budget. Pass ${{ secrets.GITHUB_TOKEN }} to setup actions that accept a token input, and add the Authorization header to raw curl calls.

Stop fetching what the checkout already has. The single largest source of avoidable calls is reading file contents over the API in a job that has already run actions/checkout. Read from the working copy.

Batch instead of looping. Ask for --per-page 100 on every paginated list, and replace a loop of per-object REST calls with one GraphQL query that returns the same fields.

Cache responses within the run. Repository, team, and membership objects do not change during a six minute job. gh api --cache 10m stores the response on the runner and serves repeats from disk without a network call.

Bound the backoff. Waiting is billed. A cap turns an unbounded sleep into a known worst case, and a job that exits at the cap is cheaper than one that sleeps out the window.

Where the job runs does not change any of this. GitHub mints GITHUB_TOKEN per job and attributes the call to the credential the step sent, so the budget is identical on GitHub-hosted runners, on ARC, and on managed runners. The token behavior above is the same on all four. Build secrets stay in your repository rather than with the runner provider, and each runner runs in its own virtual machine that is created on demand and destroyed after the build (security documentation).

Work that talks to WarpBuild rather than to GitHub draws on a different budget entirely. warpbuild.com` and leaves the repository's GitHub budget untouched (automation documentation).

Configuration

A preflight step prints the budget before the job spends it, and GET /rate_limit costs nothing against any resource.

name: pr-audit
on:
  pull_request:

jobs:
  audit:
    runs-on: warp-ubuntu-latest-x64-2x
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: Report the budget before spending it
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api rate_limit \
            --jq '.resources.core | "core remaining=\(.remaining) of \(.limit), resets at \(.reset)"'

The audit step itself wraps every call in a function that reads the headers and decides between sleeping, exiting, and failing fast.

      - name: Walk the pull request with bounded backoff
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR: ${{ github.event.pull_request.number }}
          MAX_WAIT_SECONDS: "900"
        run: |
          waited=0
          header() {
            awk -v key="$1" -F': ' \
              'tolower($1)==key { gsub(/\r/, "", $2); print $2 }' headers.txt
          }
          github_api() {
            local path="$1"
            while :; do
              code=$(curl -sS -o body.json -D headers.txt -w '%{http_code}' \
                -H "Authorization: Bearer $GH_TOKEN" \
                -H "Accept: application/vnd.github+json" \
                -H "X-GitHub-Api-Version: 2022-11-28" \
                "https://api.github.com${path}")

              remaining=$(header x-ratelimit-remaining)
              reset=$(header x-ratelimit-reset)
              retry_after=$(header retry-after)

              if [ "$code" = "200" ]; then cat body.json; return 0; fi
              if [ "$code" != "403" ] && [ "$code" != "429" ]; then
                echo "::error::${path} returned ${code}"; return 1
              fi

              if [ -n "$retry_after" ]; then
                nap="$retry_after"
              elif [ "${remaining:-1}" = "0" ]; then
                nap=$(( reset - $(date -u +%s) + 1 ))
              else
                echo "::error::403 on ${path} with budget left, so this is permissions"
                return 1
              fi

              if [ "$nap" -lt 1 ]; then nap=1; fi
              waited=$(( waited + nap ))
              if [ "$waited" -gt "$MAX_WAIT_SECONDS" ]; then
                echo "::error::backoff would exceed ${MAX_WAIT_SECONDS}s, exiting rather than billing idle minutes"
                return 1
              fi
              echo "::notice::rate limited on ${path}, sleeping ${nap}s"
              sleep "$nap"
            done
          }

          github_api "/repos/${GITHUB_REPOSITORY}/pulls/${PR}/files?per_page=100" > files.json

Three decisions in that function matter more than the shell around them. A retry-after header means a secondary limit, so the value is used directly. A missing retry-after with x-ratelimit-remaining at zero means a primary limit, so the sleep is computed from x-ratelimit-reset and the current UTC second. A 403 with budget remaining is a permissions failure and returns immediately, because retrying it can only spend minutes.

The read-only lookups that repeat inside the same run go through the client cache instead:

      - name: Resolve owners once per run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api --cache 10m "/repos/${GITHUB_REPOSITORY}" --jq '.default_branch'
          gh api --cache 10m "/orgs/${GITHUB_REPOSITORY_OWNER}/teams" --jq '.[].slug'

Cost or Time Model

Backoff is billed at the runner rate, so the design choice has a monthly number attached to it.

Assumptions

InputValueSource
Repositories running the audit workflow6Your workflow inventory
Workflow runs per repository per hour4Pull request activity
REST calls per run260The run log of the job above
Budget with GITHUB_TOKEN1,000 requests per hour per repositoryGitHub rate limits, checked 2026-08-13
Job length to the tripping call5 minutes, of a 6 minute jobThe run log
Runner rate$0.004 per minute on warp-ubuntu-latest-x64-2xpricing page, checked 2026-08-13

Four runs at 260 calls is 1,040 calls per repository per hour against a 1,000 budget, so the fourth run of each hour trips 221 calls into its work. That is one run in four. Runs per repository per month are 4 x 24 x 30 = 2,880, so six repositories run 17,280 times and 4,320 of those runs trip the limit.

Three designs, same workflow

DesignExtra billed minutes per tripped runExtra minutes per monthExtra cost per month
Fail at the tripping call, rerun the job by hand1147,520$190.08
Sleep until x-ratelimit-reset, capped at 15 minutes1043,200$172.80
Batch and cache so each run makes 18 calls00$0

Arithmetic for the first row: 5 billed minutes to reach the failing call plus a 6 minute rerun is 11 minutes, times 4,320 tripped runs is 47,520 minutes, times $0.004 is $190.08. For the second row: the fourth run of each hour reaches the tripping call about 10 minutes before the window resets, so 4,320 x 10 = 43,200 minutes, times $0.004 is $172.80. The cap earns its place on the tail rather than the average, because a burst that lands early in a window can otherwise sleep out most of an hour.

Where the 260 calls go

Call pattern in the runBeforeAfterChange
Paginating the pull request file list at the default 30 per page72per_page=100
Fetching each changed file's contents over the API1800Read them from the actions/checkout working copy
Re-reading the repository, team, and membership objects per file603gh api --cache 10m, one lookup per object per run
Posting review comments1313Unchanged
Total26018

At 18 calls, four runs spend 72 calls per repository per hour against the 1,000 budget, and no run trips. The 13 comment posts across four runs are 52 content-creating requests per hour, inside the documented 500 per hour secondary cap.

Size the waiting job down

A job that sleeps on a reset window does no work while it waits, so the label it runs on is a pure multiplier on wasted spend.

LabelvCPURAMRate per minute
warp-ubuntu-latest-arm64-2x28 GB$0.003
warp-ubuntu-latest-x64-2x28 GB$0.004
warp-ubuntu-latest-x64-4x416 GB$0.008

Rates come from the pricing page, checked on 2026-08-13. The 43,200 idle minutes in the second design cost $172.80 a month on warp-ubuntu-latest-x64-2x and $345.60 a month on warp-ubuntu-latest-x64-4x, for a job whose busiest instruction is sleep.

If the same jobs are also stalling on the network rather than on a budget, diagnose network timeouts in GitHub Actions covers that path, dispatch calls across repositories are counted in triggering workflows across repositories, and the wider pipeline view is in speeding up GitHub Actions.

FAQ

Which rate limit applies to GITHUB_TOKEN in a workflow?

GITHUB_TOKEN is an installation token scoped to the repository whose workflow is running, and GitHub documents its REST budget as 1,000 requests per hour per repository, rising to 15,000 requests per hour per repository for repositories owned by a GitHub Enterprise Cloud account (rate limits for the REST API, checked on 2026-08-13). Every job in every workflow in that repository draws on the same hourly budget, so a 20-way matrix shares one pool.

How do I tell a rate limit 403 from a permissions 403?

Read the headers. A primary rate limit answers 403 or 429 with x-ratelimit-remaining at 0 and x-ratelimit-reset carrying the UTC epoch second the window resets. A secondary rate limit answers 403 or 429 with a retry-after header in seconds. A 403 with budget remaining and no retry-after is a permissions problem, and retrying it burns minutes without ever succeeding, which is why the function in the configuration section returns immediately on that case.

Does moving to managed runners change the rate limit?

No. GitHub mints GITHUB_TOKEN per job and attributes the call to whichever credential the step sent, so the budget is the same whether the job runs on GitHub-hosted runners, on ARC, or on WarpBuild runners. What changes is what an idle backoff costs per minute, which is a runner rate question rather than a rate limit question.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.