Handling API rate limits in Git automation Jump to heading
Forge APIs limit how many requests an identity can make. The primary limit is a budget per hour β a few thousand requests for a token, more for an app installation. Secondary limits apply to bursts: too many concurrent requests, too many writes per minute, too much CPU time on expensive queries. Scripts that loop over repositories, pull requests or commits hit these limits sooner than expected, and the failure modes are unpleasant: a job dies halfway through a bulk change, a bot stops responding during a busy afternoon, or the identity is temporarily blocked and every automation sharing it stops too. This page shows how to read the limits, back off correctly, and make fewer calls in the first place, within forge API and webhook automation.
When to use this approach Jump to heading
- Scripts fail intermittently with 403 βrate limit exceededβ or 429 responses.
- A bot slows down or stops responding at busy times.
- You are about to run a loop over hundreds of repositories or thousands of pull requests, as in bulk updating repository settings with the API.
- Several automations share one token and interfere with each other.
Step 1 β Read your budget before and during a run Jump to heading
Every response carries headers describing the budget: the limit, how much remains and when it resets. A dedicated endpoint reports the same without spending a request.
gh api rate_limit --jq '.resources | {core: .core, graphql: .graphql, search: .search}
| to_entries[] | "\(.key): \(.value.remaining)/\(.value.limit), resets \(.value.reset | todate)"'
# Headers on any response
gh api -i "repos/$OWNER/$REPO" | grep -i '^x-ratelimit-' Before a large run, estimate the calls it needs β repositories Γ calls per repository β and compare with the remaining budget. If it does not fit, plan to spread it or reduce the calls first.
Step 2 β Back off correctly when limited Jump to heading
When a response says you are limited, wait β for the time Retry-After gives, or until the reset time for the primary limit β then retry. Never retry immediately in a loop; that extends secondary limits and can get the identity blocked.
#!/bin/sh
# api_call <args...> β gh api with rate-limit aware retries
api_call() {
attempt=0
while :; do
out=$(gh api -i "$@" 2>&1) && { printf '%s\n' "$out" | sed '1,/^\r\{0,1\}$/d'; return 0; }
status=$(printf '%s\n' "$out" | sed -n '1s/^HTTP[^ ]* \([0-9]*\).*/\1/p')
retry=$(printf '%s\n' "$out" | sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9]*\).*/\1/p')
reset=$(printf '%s\n' "$out" | sed -n 's/^[Xx]-[Rr]ate[Ll]imit-[Rr]eset: *\([0-9]*\).*/\1/p')
case "$status" in
403|429) wait=${retry:-$(( ${reset:-$(date +%s)} - $(date +%s) + 5 ))}
[ "$wait" -gt 0 ] || wait=$(( 2 ** attempt * 10 ))
echo "rate limited, sleeping ${wait}s" >&2; sleep "$wait" ;;
5??) sleep $(( 2 ** attempt * 5 )) ;;
*) printf '%s\n' "$out" >&2; return 1 ;;
esac
attempt=$((attempt + 1)); [ "$attempt" -lt 6 ] || return 1
done
} Step 3 β Avoid secondary limits on writes Jump to heading
Secondary limits are most often hit by writes: creating many comments, labels, branches or pull requests quickly, or making concurrent requests. Serialise writes and add a short pause between them; do not parallelise mutating calls.
# Serial writes with a small gap β slower per item, far faster than being blocked
while read -r repo; do
api_call -X PATCH "repos/$repo" -F delete_branch_on_merge=true >/dev/null
sleep 1
done < repos.txt Reads can be parallelised modestly β a handful at a time β but writes should be one at a time.
Step 4 β Make fewer calls Jump to heading
The best limit handling is not needing it. Three techniques cut call counts substantially.
# 1. GraphQL: fetch nested data in one request instead of one per item
gh api graphql -f query='
query($org: String!) { organization(login: $org) {
repositories(first: 100, isArchived: false) { nodes {
nameWithOwner deleteBranchOnMerge squashMergeAllowed
defaultBranchRef { name } } } } }' -f org="$ORG"
# 2. Conditional requests: unchanged resources return 304 and usually do not count
etag=$(gh api -i "repos/$OWNER/$REPO" | sed -n 's/^[Ee][Tt]ag: *//p' | tr -d '\r')
gh api -i -H "If-None-Match: $etag" "repos/$OWNER/$REPO" | head -1 # HTTP/2.0 304
# 3. Webhooks instead of polling: react to events rather than asking repeatedly Step 5 β Give each automation its own identity Jump to heading
When several automations share one token, one noisy job exhausts the budget for all of them. Separate identities give separate budgets and make it obvious which job is responsible for a spike. GitHub App installations also receive larger limits that scale with the organisation.
# Which identity is a token? Check before sharing it with another job
gh api user --jq .login 2>/dev/null || gh api installation/repositories --jq '.total_count' App identities for automation are introduced in forge API and webhook automation and used in verified bot commits with a GitHub App identity.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Do GITHUB_TOKEN workflow tokens have their own limit? Jump to heading
Yes, per repository, and it is lower than an app installationβs. Workflows that make many API calls across repositories should use an app token instead.
Does GitLab have the same limits? Jump to heading
GitLab applies per-user and per-IP limits, configurable on self-managed instances, and returns 429 with Retry-After and RateLimit-* headers. The same wrapper logic applies with the header names adjusted.
Are search API limits different? Jump to heading
Search endpoints have a much smaller, separate budget. Avoid using search for things that can be listed directly, and cache search results within a run.
Related Jump to heading
- Forge API & Webhook Automation β the parent topic.
- Verifying Webhook Signatures β receiving events instead of polling.
- Closing Stale Branches and Pull Requests β a job that makes many writes.
- Measuring Pipeline Duration Trends β a reporting job that benefits from fewer calls.