CoreMesh

GitHub Search API Rate Limit: One Pacer That Stays Under It

GitHub's search API allows 30 requests a minute. Here's the shared pacer I use to stay under it: a 2.3 second gap plus one retry that honors Retry-After.

GitHub Search API Rate Limit: One Pacer That Stays Under It

GitHub's search API has its own rate limit, separate from the main REST limit. Authenticated calls to every /search/* endpoint get 30 requests per minute, except code search, which gets 10. Go over and you get a 403 or 429 with x-ratelimit-remaining: 0, and nothing else you send in that window comes back with results.

The way I stay under it is boring on purpose. Every /search call in the process goes through one shared limiter that enforces a 2.3 second gap between requests, which works out to about 26 calls a minute. If a call still fails with 403 or 429, it retries exactly once, waiting however long Retry-After or x-ratelimit-reset says. That's the whole design. The rest of this page is the numbers, the code, and the places where it's thinner than I'd like.

What the GitHub search API rate limit actually is#

GitHub tracks search in its own bucket. The search endpoints reference lists the limits, and the rate limits page covers the primary limit that everything else draws from.

Bucket Limit Notes
Search, authenticated 30 requests per minute All /search/* endpoints except code search
Code search, authenticated 10 requests per minute /search/code, authentication required
Search, unauthenticated 10 requests per minute Shared by everyone behind the same IP
Primary REST, authenticated user 5,000 requests per hour Repos, issues, pulls, and so on
Primary REST, unauthenticated 60 requests per hour Per IP
GITHUB_TOKEN inside Actions 1,000 requests per hour Per repository

Two things in that table trip people up. First, the search limit is per minute, not per hour, so a short burst of 31 calls fails even though your hourly budget is barely touched. Second, the search budget is tracked separately from the primary budget. Every response carries an x-ratelimit-resource header that names the bucket the request was counted against, so you can see search or code_search right there.

Each response also carries the headers you need to pace yourself:

  • x-ratelimit-limit, the ceiling for that bucket
  • x-ratelimit-remaining, what's left in the current window
  • x-ratelimit-used, what you've spent
  • x-ratelimit-reset, the UTC epoch second when the window resets

On a rate-limited response you'll see x-ratelimit-remaining: 0 and a 403 or 429 status. If you tripped a secondary limit instead, such as too many concurrent requests, GitHub may send a Retry-After header with a number of seconds. The best practices page spells out the order to check them in, and the retry code below follows that order.

One more cap that isn't a rate limit but feels like one: a single search query returns at most 1,000 results no matter how you page. If you need more than that, split the query with qualifiers like created: ranges instead of burning requests on pages that will never arrive.

Why one process-wide pacer instead of a limiter per caller#

The limit belongs to the token, not to the function making the call. If three modules in the same process each have their own well-behaved limiter, the token still sees the sum of all three, and the sum is what gets you blocked. So I keep exactly one limiter object per process and route every /search call through it, whichever module or thread is asking.

The gap is 2.3 seconds. Sixty divided by 2.3 is about 26, which leaves a few requests of headroom under the 30 per minute ceiling. That slack covers the retry itself, small clock drift between my side and GitHub's, and the odd call that lands right at a window boundary. Pushing the gap down to 2.0 seconds would use the full 30 and leave nothing for the retry.

Serializing through one gate has a side benefit. GitHub's secondary limits penalize concurrency, and a single lock around search calls means there's never more than one in flight. You lose parallelism you couldn't have used anyway.

The pacer in Python#

Here's the limiter with a fake clock so the demo finishes instantly. In production you pass nothing and it falls back to time.monotonic and time.sleep.

python
import threading
import time


class SearchPacer:
    """One process-wide gate for every /search call."""

    def __init__(self, gap_seconds=2.3, clock=None, sleeper=None):
        self.gap = gap_seconds
        self.clock = clock or time.monotonic
        self.sleeper = sleeper or time.sleep
        self.lock = threading.Lock()
        self.next_allowed = 0.0

    def wait(self):
        with self.lock:
            now = self.clock()
            delay = max(0.0, self.next_allowed - now)
            if delay:
                self.sleeper(delay)
            self.next_allowed = max(now, self.next_allowed) + self.gap
            return delay


# Fake clock so the demo runs in well under a second
t = [1000.0]


def clock():
    return t[0]


def sleeper(seconds):
    t[0] += seconds


pacer = SearchPacer(2.3, clock, sleeper)
for i in range(5):
    waited = pacer.wait()
    print(f"call {i + 1}: waited {waited:.1f}s, sent at t={clock():.1f}")

The lock is what makes it process-wide. Threads queue on it, each one sleeps for its share, and the next_allowed timestamp advances by one gap per caller. Instantiate it once at module level and import that instance everywhere, or hang it on whatever client object you already share.

If your bot is in Node rather than Python, the same shape works: one module-level promise chain that each caller awaits, with a 2.3 second timer between links. The request side is plain fetch with the headers shown further down, and the JavaScript Fetch API examples cover the error handling that fetch leaves to you.

One retry that honors Retry-After or x-ratelimit-reset#

When a search call comes back 403 or 429, the question is how long to wait. GitHub's own guidance gives a clear order of precedence, and this function follows it:

  1. If Retry-After is present, wait that many seconds.
  2. Otherwise, if x-ratelimit-remaining is 0, wait until x-ratelimit-reset, plus a second of slack.
  3. Otherwise fall back to a short fixed pause.
python
import time


def retry_delay(headers, now=None):
    """Seconds to wait before the single retry, from GitHub's headers."""
    now = time.time() if now is None else now
    if "retry-after" in headers:
        return float(headers["retry-after"])
    if headers.get("x-ratelimit-remaining") == "0" and "x-ratelimit-reset" in headers:
        return max(0.0, float(headers["x-ratelimit-reset"]) - now) + 1.0
    return 5.0


now = 1_700_000_000
print("Retry-After present:", retry_delay({"retry-after": "30"}, now))
print("reset 42s away:", retry_delay(
    {"x-ratelimit-remaining": "0", "x-ratelimit-reset": str(now + 42)}, now))
print("no usable headers:", retry_delay({}, now))

And here's the search call that uses both pieces. It needs a token in GITHUB_TOKEN, so it isn't runnable on this page.

python
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.github.com"
HEADERS = {
    "Accept": "application/vnd.github+json",
    "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
    "X-GitHub-Api-Version": "2022-11-28",
}

pacer = SearchPacer()  # the one shared instance


def search(kind, query, per_page=100):
    """kind is one of: repositories, issues, commits, users, topics, labels, code."""
    q = urllib.parse.quote(query)
    url = f"{API}/search/{kind}?q={q}&per_page={per_page}"
    for attempt in range(2):  # one try, one retry
        pacer.wait()
        req = urllib.request.Request(url, headers=HEADERS)
        try:
            with urllib.request.urlopen(req) as resp:
                return json.load(resp)
        except urllib.error.HTTPError as e:
            if attempt == 0 and e.code in (403, 429):
                time.sleep(retry_delay(e.headers))
                continue
            raise

Note that the retry goes back through pacer.wait(). The pacer's gap and the header-driven delay stack, which is fine. The retry delay is the one that matters, and the extra couple of seconds keeps the retry from colliding with whatever other caller is next in line.

Why only one retry? If the first retry waited out the window GitHub told me to wait out and still failed, the budget isn't the problem. Either the query is broken, the token is wrong, or something upstream has changed. Looping on that hides the error and keeps spending requests. Surfacing it after one honest retry is the better failure mode for an unattended bot.

Check what's left with the rate_limit endpoint#

You don't have to guess at your budget. The rate limit endpoint reports every bucket, and calling it does not count against any of them.

bash
curl -s \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/rate_limit | jq '.resources.search, .resources.code_search'

Each resource object has limit, used, remaining, and reset. If remaining on search is zero and reset is thirty seconds out, you know exactly why your bot is getting 403s, and you know not to start it again until then.

For a one-off look at the headers on a real search, add -i to any search request:

bash
curl -s -i \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  "https://api.github.com/search/repositories?q=topic:rate-limiting&per_page=1" \
  | grep -i '^x-ratelimit'

Can you increase the GitHub search API rate limit?#

For the search bucket specifically, there's no knob. Authenticating moves you from 10 to 30 per minute, and that's the top. GitHub Apps and Enterprise Cloud raise the primary hourly limit, but the search documentation does not list a higher search tier for them. If a 30 per minute ceiling is the bottleneck, the answer is spending fewer requests, not asking for more.

Ways to spend fewer:

  • Ask for 100 results per page. per_page=100 is the maximum for search. The default is 30, so a query that needs 300 results costs three requests instead of ten.
  • Narrow the query with qualifiers. repo:, org:, language:, created:, pushed: and is: all cut result counts, which means fewer pages and fewer wasted calls past the 1,000 result cap.
  • Use a non-search endpoint when one exists. Listing a known repo's issues through /repos/{owner}/{repo}/issues draws from the 5,000 per hour primary pool, not the search pool. Search is for when you don't know where the thing is.
  • Cache. If the same query runs every few minutes, keep the last result and the reset timestamp, and skip the call when nothing can have changed.
  • Don't run search from a workflow unless you need to. The GITHUB_TOKEN inside Actions has its own hourly budget, but the per-minute search limit still applies on top of it. If you're setting up a scheduled workflow that queries search, the GitHub Actions tutorial covers the basics, and the pacer above works unchanged inside a job step.

Where this setup is thin#

The pacer is per process. If I ever ran two processes against the same token, each would pace itself to 26 a minute and the token would see 52. The fix would be a shared store, such as a small SQLite table or a file lock that holds the next_allowed timestamp, but I haven't needed it and I'd rather not add it until I do.

Code search needs its own gate. A 2.3 second gap allows 26 calls a minute, which is more than double the 10 per minute that /search/code permits. If I route code search through the same pacer it will trip the limit, so code search either gets its own SearchPacer(6.5) or stays out of the bot altogether.

The gap is fixed. A smarter limiter would read x-ratelimit-remaining on each response and tighten or loosen itself. Mine doesn't, and I'd make the same choice again: a fixed gap is easy to reason about at 3 a.m. when the logs say 403, and a few unused requests a minute aren't worth adaptive logic.

One retry means one. A second 403 propagates as an exception and the job fails loudly. That's deliberate, but it does mean a long secondary-limit cooldown will kill a run rather than pause it. If your bot can't afford to fail, raise the retry count and keep honoring the headers on each attempt rather than switching to blind backoff.

Checklist#

  • Authenticate every search call. Unauthenticated search is capped at 10 per minute and shared by your IP.
  • Send Accept: application/vnd.github+json and X-GitHub-Api-Version: 2022-11-28 on each request.
  • Route all /search/* calls through one process-wide limiter, not one per module.
  • Set the gap above 2.0 seconds. 2.3 seconds leaves headroom for the retry and clock drift.
  • Give /search/code its own slower gate. Its limit is 10 per minute.
  • On 403 or 429, wait for Retry-After if present, else until x-ratelimit-reset when x-ratelimit-remaining is 0.
  • Retry once, then fail loudly.
  • Use per_page=100 and tight qualifiers so each query costs as few requests as possible.
  • Check GET /rate_limit before blaming your code. It's free and it tells you which bucket you emptied.
  • Prefer a direct REST endpoint over search whenever you already know the repo.

Quick answers

What is the GitHub search API rate limit?

Authenticated requests get 30 per minute across all search endpoints except code search. Unauthenticated requests get 10 per minute, and the limit is tracked separately from the 5,000 per hour primary REST limit.

What is the GitHub code search API rate limit?

The /search/code endpoint is limited to 10 requests per minute and requires authentication. Run it through its own slower pacer rather than sharing the 2.3 second gate used for other search endpoints.

How do I know I hit the GitHub rate limit?

The response is a 403 or 429 with x-ratelimit-remaining set to 0, and x-ratelimit-reset gives the epoch second when the window reopens. A secondary limit may instead send a Retry-After header with a number of seconds to wait.

Can I increase the GitHub API rate limit?

Authenticating raises search from 10 to 30 per minute and the primary limit from 60 to 5,000 per hour. GitHub Apps and Enterprise Cloud raise the primary limit further, but the search documentation lists no higher tier for search, so reducing requests is the practical route.

Does calling the rate_limit endpoint count against my limit?

No. GET https://api.github.com/rate_limit is free and returns the limit, used, remaining and reset values for every bucket, including search and code_search.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS