DEV Community

Cover image for GitHub API Rate Limits: an Unauthenticated 304 Still Costs You a Request
Alex Spinov
Alex Spinov

Posted on Originally published at blog.spinov.online

GitHub API Rate Limits: an Unauthenticated 304 Still Costs You a Request

No token. One IP. July 29, 2026:

GET /repos/python/cpython                      200   5996 B   remaining 32 -> 31
  + If-None-Match  (no Authorization header)    304      0 B   remaining 31 -> 30
  + If-None-Match                               304      0 B   remaining 30 -> 29
  + If-None-Match                               304      0 B   remaining 29 -> 28
Enter fullscreen mode Exit fullscreen mode

Three conditional requests. Three 304 Not Modified. Zero bytes of body across all three. Three requests gone from a bucket of 60 per hour.

I opened the terminal to write the opposite post.

The short version: if you call the GitHub REST API without an Authorization header, an If-None-Match request that comes back 304 still decrements x-ratelimit-remaining. The ETag saves you bytes. It does not save you quota. GitHub's documentation states the claim five times on one page and attaches the condition to two of them, and that clause falls off easily when a sentence gets quoted on its own.

The post I meant to write

My working title was something like "poll GitHub for free with ETags". I believed it. I had read the sentence about 304 responses not using your rate limit, I had repeated it to other people, and the plan was a tidy little piece with a before-and-after budget chart.

The first run killed it. remaining went down.

My first reaction was that my counter reading was wrong, which is the normal reaction and usually the correct one. It was not wrong. So the post changed, and the finding turned out to be worth more than the one I went in with.

Does a 304 count against the GitHub rate limit? What the docs actually say

Here is the part that matters, and I want to be precise because it would be easy and dishonest to turn this into "GitHub's docs are wrong". They are not.

On the page Best practices for using the REST API the claim shows up five times. Two of the five carry a condition; three do not. Here is the strict one, the only place on the page where the condition is spelled out as a header:

"Making a conditional request does not count against your primary rate limit if a 304 response is returned and the request was made while correctly authorized with an Authorization header."

Nothing I measured contradicts that sentence. There was no Authorization header on my requests, so the condition was not met, so no discount was owed.

I want to be careful about how much that proves, because it is less than it sounds. A null result on the unauthenticated branch does not verify the authenticated one. My numbers are equally consistent with two different worlds: one where the clause is load-bearing and the discount really does work once you send a token, and one where the discount no longer works for anybody and the documentation is simply stale. I cannot tell those apart, because telling them apart needs a token and I do not have one here. I have picked the generous reading throughout this post. That is a choice, not a finding.

The second sentence that carries the condition is easy to walk past, because it never says 304 at all. It is a bullet near the top of the page, in the "Avoid polling" list:

"Make authenticated conditional requests, so that unchanged data does not count against your primary rate limit."

One word is doing all the work there: authenticated. Drop it and you have the advice I was about to publish. I walked past that bullet on my first read of the page, which is funny in the wrong direction: I went hunting for the places where the clause falls off, and the place where it holds is the one I missed.

The other three drop the condition outright. Two of them sit in the same section as the strict one:

"This makes conditional requests especially useful when you poll an endpoint, because each 304 Not Modified response is fast and does not use your rate limit."

"If the data has not changed, you will receive a 304 Not Modified response, which does not count against your primary rate limit:"

The last one sits further down the same page, under the heading "Make requests that can be cached":

"A conditional request only saves you time and rate limit if the endpoint returns 304 Not Modified."

In context the page is not contradicting itself. All four curl examples on it carry Authorization: Bearer YOUR-TOKEN, so the whole page is written for the authenticated reader. Read top to bottom by a careful person, it is fine.

I read the page twice, three weeks apart: July 29 and again on August 19, 2026, right before publishing. It did not change between those two readings, and that part is checkable rather than a claim about my memory: the source file behind the page, content/rest/using-the-rest-api/best-practices-for-using-the-rest-api.md in github/docs, has not been touched since commit 29d8e509, dated July 27, 2026. The bullet was sitting there the whole time. Only my count of it changed.

But sentences travel alone, and the short unconditional ones travel best. I did not count how often, so I am not going to tell you it is everywhere. I can show you two places I checked.

The first is on GitHub's own community forum, in the discussion Working with the GitHub API rate limit, opened by wilsonwong1990 in March 2026: "As a 304 is a NOT MODIFIED return, it does not count against our rate limits." No clause.

In July I filed that as one developer telling another what everybody knows. It is not sitting there as that any more. Re-reading the page on August 19, 2026, before publishing, I found a badge on it dated that same day: ✅ Verified by GitHub, on an answer by a Maintainer, with the note that the content "has been reviewed and verified by GitHub subject-matter experts for accuracy and quality". The verified answer repeats the sentence above, still without the clause. The badge is on the answer as a whole rather than on that one line, and I have no idea how deep the review goes.

One thing in his defence, because it is the whole point of this post: the example script in that same write-up builds its client as new Octokit(TOKEN ? { auth: TOKEN } : {}), reading GITHUB_TOKEN from the environment. In his own context, with a token, he is very likely right. The sentence just does not survive being lifted out of it. But I went looking for examples of the short version travelling on its own, and the best one turned out to have GitHub's own stamp on it, applied while I was writing this.

The second is me. In a post about fingerprint spoofing on June 2 I wrote: "Send conditional GETs on anything you re-fetch. If-None-Match / If-Modified-Since. Free bandwidth, fewer requests, and it makes your traffic look like a cache-aware client instead of a firehose." Free bandwidth was right. Fewer requests was the part I had not measured, and I printed it anyway.

And notice who ends up holding the wrong belief. With a token you get a documented 5,000 requests per hour, and the docs say your 304s are free on top of that. Without one you get 60, and they are not free. The discount is missing exactly where the budget is tight.

The probe, and the three controls that had to be able to kill it

The claim here is about the arithmetic of a counter. So the controls have to bite at the counter, not at the transport. A probe that proves "HTTP works" proves nothing about counting, and I have shipped that mistake before.

Standard library, no token, no signup, runnable locally. It spends 6 requests.

#!/usr/bin/env python3
"""
gh_rate_budget.py - what actually spends your UNAUTHENTICATED GitHub REST budget.

WHY THIS EXISTS
    "Use ETags, a 304 doesn't count against your rate limit" is advice I have both read
    and repeated myself. GitHub's own docs state it five times on one page. Only two of
    those five sentences carry the condition GitHub attaches to it. This script measures
    which half of that sentence you are living in.

WHAT IT CLAIMS, AND THEREFORE WHAT IT MUST CONTROL FOR
    The claim is about the ARITHMETIC of the x-ratelimit counter - not about "does HTTP
    work". A transport control (bad host -> error, bad path -> 404) proves the fetcher
    runs; it proves nothing about counting. A probe can return HTTP 200 with perfectly
    correct-looking numbers and still be lying about the thing you are actually asserting.
    So the controls here bite at the counter layer:

      [A] IDLE CONTROL     - read the meter, send nothing for 10s, read it again.
                             If the counter moves while we are silent, this IP has
                             background traffic and NO per-request delta is attributable.
      [C] COST CONTROL     - a plain GET must be OBSERVED to cost exactly 1.
                             If it costs 0, we are being served from a cache/proxy and
                             every number below is noise: abort.
      [D] REPETITION       - the conditional request is measured N times, not once.
                             N=1 cannot tell "this cost 1" from "something else spent 1".

    Each of these can fail, and the script says so and then either stops or downgrades the
    result to an upper bound. A control that cannot fail is decoration.

ETHICS
    We never exhaust anything. Total spend is ~5 of the documented 60/hour core budget
    plus 1 from the separate search bucket of 10 - two buckets, not one - and we read
    the meter (GET /rate_limit - documented as not counted against the primary limit)
    instead of hammering until something breaks. An article about respecting rate limits
    does not get to DDoS a free endpoint for a screenshot.

stdlib only. Python 3.8+. No `timeout(1)` - that binary does not exist on macOS.
"""

import json
import random
import string
import sys
import time
import urllib.error
import urllib.request

API = "https://api.github.com"
REPO = "/repos/python/cpython"          # big, public, stable, boring on purpose
UA = ("SpinovContentEngine/1.0 "
      "(+https://blog.spinov.online; mailto:spinov001@gmail.com)")
TIMEOUT = 20
PAUSE = 1.0          # polite gap between calls
IDLE_SECONDS = 10    # length of the idle control
N_COND = 3           # how many conditional requests to measure

HDRS = {
    "User-Agent": UA,                       # GitHub rejects requests without one
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
}


# --------------------------------------------------------------------------- io
def call(path, extra=None, full_url=None):
    """One HTTP call. Never raises on HTTP status - 304/403/404/429 are data, not crashes."""
    url = full_url or (API + path)
    req = urllib.request.Request(url, headers=dict(HDRS, **(extra or {})))
    t0 = time.time()
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            return _pack(r.status, r.headers, r.read(), t0)
    except urllib.error.HTTPError as e:
        return _pack(e.code, e.headers, e.read(), t0)
    except Exception as e:
        return {"status": None, "err": "%s: %s" % (type(e).__name__, e),
                "ms": int((time.time() - t0) * 1000), "bytes": 0,
                "etag": None, "remaining": None, "used": None,
                "resource": None, "retry_after": None}


def _pack(status, h, body, t0):
    return {
        "status": status,
        "err": None,
        "ms": int((time.time() - t0) * 1000),
        "bytes": len(body),
        "body": body,
        "etag": h.get("etag"),
        "remaining": h.get("x-ratelimit-remaining"),
        "used": h.get("x-ratelimit-used"),
        "resource": h.get("x-ratelimit-resource"),
        "retry_after": h.get("retry-after"),
    }


def meter():
    """Read the meter without spending core budget. Returns (core, search) dicts."""
    r = call("/rate_limit")
    if r["status"] != 200:
        return None, None
    res = json.loads(r["body"])["resources"]
    return res.get("core"), res.get("search")


def show(tag, r, note=""):
    print("    %-28s status=%-6s %5dms %7sb  rl[%s %s used=%s] %s"
          % (tag, r["status"] if r["status"] is not None else "ERR",
             r["ms"], r["bytes"], r["resource"] or "-",
             r["remaining"] or "-", r["used"] or "-", note))


# ------------------------------------------------------------------------- main
def main():
    out = {"utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
    print("=" * 92)
    print("GitHub REST API - what actually spends the unauthenticated budget")
    print("started", out["utc"])
    print("=" * 92)

    # --- transport control: the fetcher must be able to fail at all ------------
    print("\n[T] TRANSPORT CONTROL (weak, but it must still pass)")
    bad = call(None, full_url="https://api.github.invalid-tld-zzz/rate_limit")
    show("nonexistent host", bad, "MUST be ERR")
    if bad["status"] is not None:
        print("    FATAL: a nonexistent host answered. The fetcher is not real. Stop.")
        return 3

    core, _ = meter()
    if core is None:
        print("\nABORT: cannot read /rate_limit. Nothing below would mean anything.")
        return 2
    print("\n    starting budget: %d/%d left, used=%d, resets in %+ds"
          % (core["remaining"], core["limit"], core["used"], core["reset"] - int(time.time())))
    out["limit"] = core["limit"]

    # --- [A] idle control ------------------------------------------------------
    print("\n[A] IDLE CONTROL - send nothing for %ds, see if the counter moves" % IDLE_SECONDS)
    before = core["remaining"]
    time.sleep(IDLE_SECONDS)
    core, _ = meter()
    drift = before - core["remaining"]
    out["idle_drift"] = drift
    print("    remaining %d -> %d  (drift %d)" % (before, core["remaining"], drift))
    if drift != 0:
        print("    !! background traffic shares this IP. Per-request costs below are UPPER BOUNDS,")
        print("       not measurements. Say so in anything you publish.")
    else:
        print("    OK - nothing else is spending this bucket. Deltas below are ours.")
        print("    (this control CAN fail: on a shared NAT or a busy CI box it does)")

    # --- meter is free? --------------------------------------------------------
    print("\n[B] IS READING THE METER FREE?  (docs: /rate_limit is not counted)")
    r1 = core["remaining"]
    core, _ = meter()
    out["meter_is_free"] = (core["remaining"] == r1)
    print("    two consecutive /rate_limit reads: %d -> %d => meter costs %s"
          % (r1, core["remaining"], "NOTHING" if out["meter_is_free"] else "A REQUEST"))

    # --- [C] cost control: a plain GET must cost exactly 1 ----------------------
    print("\n[C] COST CONTROL - a plain GET must be OBSERVED to cost exactly 1")
    before = core["remaining"]
    plain = call(REPO)
    show("GET " + REPO, plain)
    etag = plain["etag"]
    time.sleep(PAUSE)
    core, _ = meter()
    cost_plain = before - core["remaining"]
    out["plain_get_cost"] = cost_plain
    out["bytes_200"] = plain["bytes"]
    print("    remaining %d -> %d => plain GET cost = %d" % (before, core["remaining"], cost_plain))
    if cost_plain == 0:
        print("    !!! CONTROL FAILED: a real request cost nothing (cache/proxy/replay). ABORT.")
        return 4
    if cost_plain != 1:
        print("    !! cost != 1 - treat everything below as an upper bound.")

    # --- THE QUESTION: does a 304 cost a request when unauthenticated? ---------
    print("\n[D] %d CONDITIONAL REQUESTS (If-None-Match), NO Authorization header" % N_COND)
    if not etag:
        print("    no ETag returned => NOT MEASURED")
        out["conditional"] = "not measured (no etag)"
    else:
        print("    etag: %s" % etag)
        before = core["remaining"]
        statuses, sizes = [], []
        for i in range(N_COND):
            c = call(REPO, {"If-None-Match": etag})
            statuses.append(c["status"])
            sizes.append(c["bytes"])
            show("#%d  + If-None-Match" % (i + 1), c)
            time.sleep(PAUSE)
        core, _ = meter()
        drop = before - core["remaining"]
        out.update(conditional_statuses=statuses, conditional_bytes=sizes,
                   conditional_n=N_COND, conditional_total_cost=drop)
        print("    remaining %d -> %d => %d conditional requests cost %d"
              % (before, core["remaining"] + 0, N_COND, drop))
        if drop == 0:
            print("    => 304 IS FREE here.")
        elif drop == N_COND:
            print("    => 304 COSTS A FULL REQUEST each. The ETag saved bytes, not budget.")
        else:
            print("    => AMBIGUOUS (%d for %d). Do not publish a per-request cost from this."
                  % (drop, N_COND))

    # --- does an error cost you? ----------------------------------------------
    print("\n[E] DOES A 404 COST YOU A REQUEST?")
    before = core["remaining"]
    rnd = "".join(random.choice(string.ascii_lowercase) for _ in range(12))
    miss = call("/repos/python/zz-no-such-repo-%s" % rnd)
    show("GET /repos/.../<random>", miss, "expect 404")
    time.sleep(PAUSE)
    core, _ = meter()
    out["status_404"], out["cost_404"] = miss["status"], before - core["remaining"]
    print("    remaining %d -> %d => 404 cost = %d" % (before, core["remaining"], out["cost_404"]))

    # --- separate buckets ------------------------------------------------------
    print("\n[F] IS SEARCH THE SAME BUDGET?")
    before = core["remaining"]
    s = call("/search/repositories?q=stars:>100000&per_page=1")
    show("GET /search/repositories", s)
    time.sleep(PAUSE)
    core, search = meter()
    out.update(search_status=s["status"], search_resource=s["resource"],
               core_delta_from_search=before - core["remaining"],
               search_limit=search["limit"] if search else None)
    print("    core %d -> %d (delta %d) | search bucket: %s/%s"
          % (before, core["remaining"], out["core_delta_from_search"],
             search["remaining"] if search else "?", search["limit"] if search else "?"))

    # --- window ----------------------------------------------------------------
    print("\n[G] THE WINDOW")
    print("    core: %d/%d used=%d, resets in %+ds (fixed window, not a sliding one:"
          % (core["remaining"], core["limit"], core["used"], core["reset"] - int(time.time())))
    print("     the reset epoch stays put while you spend, then the whole bucket refills)")
    out["core_reset_in_s"] = core["reset"] - int(time.time())

    # --- what we refuse to measure --------------------------------------------
    print("\n[H] DELIBERATELY NOT MEASURED")
    print("    - 403 vs 429 at exhaustion: we did not drain the bucket. GitHub's docs say")
    print("      '403 or 429'; that is THEIR sentence, not our measurement.")
    print("    - the authenticated case (documented 5000/h, and 304s documented free):")
    print("      no valid token in this environment => NOT MEASURED.")
    print("    - secondary rate limits: NOT MEASURED.")

    print("\n" + "=" * 92)
    print(json.dumps(out, indent=2, sort_keys=True))
    print("=" * 92)
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Output, my machine, pasted as it came out:

============================================================================================
GitHub REST API - what actually spends the unauthenticated budget
started 2026-07-29T19:03:54Z
============================================================================================

[T] TRANSPORT CONTROL (weak, but it must still pass)
    nonexistent host             status=ERR     1463ms       0b  rl[- - used=-] MUST be ERR

    starting budget: 32/60 left, used=28, resets in +1336s

[A] IDLE CONTROL - send nothing for 10s, see if the counter moves
    remaining 32 -> 32  (drift 0)
    OK - nothing else is spending this bucket. Deltas below are ours.
    (this control CAN fail: on a shared NAT or a busy CI box it does)

[B] IS READING THE METER FREE?  (docs: /rate_limit is not counted)
    two consecutive /rate_limit reads: 32 -> 32 => meter costs NOTHING

[C] COST CONTROL - a plain GET must be OBSERVED to cost exactly 1
    GET /repos/python/cpython    status=200     1191ms    5996b  rl[core 31 used=29] 
    remaining 32 -> 31 => plain GET cost = 1

[D] 3 CONDITIONAL REQUESTS (If-None-Match), NO Authorization header
    etag: W/"546a3c1630ebc233b23407963e71de343062fc074ad45e4086b6fd3a17545a99"
    #1  + If-None-Match          status=304      931ms       0b  rl[core 30 used=30] 
    #2  + If-None-Match          status=304      973ms       0b  rl[core 29 used=31] 
    #3  + If-None-Match          status=304      974ms       0b  rl[core 28 used=32] 
    remaining 31 -> 28 => 3 conditional requests cost 3
    => 304 COSTS A FULL REQUEST each. The ETag saved bytes, not budget.

[E] DOES A 404 COST YOU A REQUEST?
    GET /repos/.../<random>      status=404     1031ms     118b  rl[core 27 used=33] expect 404
    remaining 28 -> 27 => 404 cost = 1

[F] IS SEARCH THE SAME BUDGET?
    GET /search/repositories     status=200     1064ms    5963b  rl[search 9 used=1] 
    core 27 -> 27 (delta 0) | search bucket: 10/10

[G] THE WINDOW
    core: 27/60 used=33, resets in +1307s (fixed window, not a sliding one:
     the reset epoch stays put while you spend, then the whole bucket refills)

[H] DELIBERATELY NOT MEASURED
    - 403 vs 429 at exhaustion: we did not drain the bucket. GitHub's docs say
      '403 or 429'; that is THEIR sentence, not our measurement.
    - the authenticated case (documented 5000/h, and 304s documented free):
      no valid token in this environment => NOT MEASURED.
    - secondary rate limits: NOT MEASURED.

============================================================================================
{
  "bytes_200": 5996,
  "conditional_bytes": [
    0,
    0,
    0
  ],
  "conditional_n": 3,
  "conditional_statuses": [
    304,
    304,
    304
  ],
  "conditional_total_cost": 3,
  "core_delta_from_search": 0,
  "core_reset_in_s": 1307,
  "cost_404": 1,
  "idle_drift": 0,
  "limit": 60,
  "meter_is_free": true,
  "plain_get_cost": 1,
  "search_limit": 10,
  "search_resource": "search",
  "search_status": 200,
  "status_404": 404,
  "utc": "2026-07-29T19:03:54Z"
}
============================================================================================
Enter fullscreen mode Exit fullscreen mode

The [H] block is printed by the script, not added afterwards. A probe that lists what it refused to measure is harder to over-read later, including by me.

One note on the byte counts. The probe goes through urllib, which sets Accept-Encoding: identity on its own, so the log above shows the uncompressed body. Any normal client asks for gzip and gets the same JSON in about 1.4 kB on the wire: I checked that separately with curl against the same endpoint, content-encoding: gzip and content-length: 1423, which unpacks to the same 5,996 bytes. The same check three weeks later gave 1428, which is the sort of wobble you get when the JSON changes by a star count. Two measurements, 1,423 and 1,428, and no third number in between that I am rounding to. So the ETag saves you roughly 1.4 kB per poll, not 6 kB.

Two things the script gets wrong about itself, since you are going to read its output. The [G] line calls the window fixed and says the whole bucket refills; that is GitHub's documented model, not something a 29-second run can see. And [H], the block that lists what I refused to measure, does not list it. The prose above is the accurate version: the reset epoch did not move while I spent, and that is all I saw.

The idle control, and the header that beats it

My bucket did not start at 60. It started at 32, with used=28, because earlier runs the same evening had already spent from this IP. That is the whole reason the idle control exists.

If somebody else is spending from your bucket while you measure, a drop of 1 after your request is not evidence that your request cost 1. It is evidence that the counter moved. Those are different claims.

So: read the meter, say nothing for ten seconds, read it again. Drift was 0. On a shared NAT or a busy CI runner that control fails, and when it fails the honest thing to publish is an upper bound rather than a cost.

Ten seconds of silence is a weak certificate, though, and I want to say so before I lean on it. It proves nothing about the seconds during which I am spending. There is a better guarantee sitting in every response, and further down you will see it catch something the idle control slept through.

The cost control is the cheap one, and it is the one that would have caught the embarrassing failure. A plain GET has to be observed to cost exactly 1. If it had cost 0, I would be reading a cache or a proxy and every number below it would be decoration. The script prints that failure and exits with code 4.

The third control is repetition. One conditional request cannot distinguish "this cost 1" from "something else spent 1 in the same second". Three in a row dropped the counter by exactly 3, four times over that evening, and a fifth time three weeks later.

The letters in the docstring match the letters in the output. They did not in the first version I ran, which is the kind of thing you only notice when somebody else runs your file.

Four runs total, at 18:45:36Z, 18:49:46Z, 18:58:34Z and 19:03:54Z on July 29, 2026. Same answer each time, off different starting values.

Three weeks later, and something else in the bucket

I ran the same script again on August 19, 2026, before publishing this, because a measurement that is three weeks old is a claim about the past unless you check. Same machine, same endpoint, still no token:

[C] COST CONTROL - a plain GET must be OBSERVED to cost exactly 1
    GET /repos/python/cpython    status=200     1018ms    5996b  rl[core 45 used=15]
    remaining 46 -> 45 => plain GET cost = 1

[D] 3 CONDITIONAL REQUESTS (If-None-Match), NO Authorization header
    etag: W/"dd11a90c1106ce4de24a4f803c2604a00c6969b75610a03e4641ba4a46261a58"
    #1  + If-None-Match          status=304     1014ms       0b  rl[core 44 used=16]
    #2  + If-None-Match          status=304     2623ms       0b  rl[core 43 used=17]
    #3  + If-None-Match          status=304     1120ms       0b  rl[core 42 used=18]
    remaining 45 -> 42 => 3 conditional requests cost 3

[E] DOES A 404 COST YOU A REQUEST?
    GET /repos/.../<random>      status=404     1572ms     118b  rl[core 41 used=19] expect 404
    remaining 42 -> 41 => 404 cost = 1
Enter fullscreen mode Exit fullscreen mode

Different ETag, same 5,996 bytes, same three zero-byte 304s, same three requests off the counter, used still stepping 15, 16, 17, 18, 19 without a gap. Twenty-one days, and nothing moved. Three months from now I still cannot tell you.

The interesting part came from a shorter check I ran earlier the same morning, before the full probe, and it is the reason the controls are in the script at all:

GET /rate_limit                 200     424 B  remaining=60 used=0
GET /repos/python/cpython       200    5996 B  remaining=59 used=1
  + If-None-Match #1            304       0 B  remaining=59 -> 58  used=2
  + If-None-Match #2            304       0 B  remaining=58 -> 56  used=4
  + If-None-Match #3            304       0 B  remaining=56 -> 55  used=5
Enter fullscreen mode Exit fullscreen mode

Look at step two. remaining falls by two, and used jumps from 2 straight to 4. A 304 had not doubled in price overnight. The jump in used is the tell: my own request accounts for one of those two, and something that was not my probe accounts for the other.

That something was another process on the same machine, talking to the same API at the same time. GitHub is explicit about why that lands in my bucket. From Rate limits for the REST API: "Unauthenticated requests are associated with the originating IP address, not with the user or application that made the request." One address, one bucket, no isolation between the programs behind it. When I read the meter right before the clean run, the window had been open for about two minutes and 12 of its 60 requests were already spent.

So the honest version of the finding has a second half. A 304 costs you one request. A single unattended measurement on a shared address can tell you it cost two, and x-ratelimit-used is the header that catches the lie: consecutive responses of yours should carry consecutive numbers. A gap means somebody else is spending your hour. Mine happened to be a program of my own. On a CI runner it is a stranger, and you will never see them.

What the same six requests answered on the way past

Question Measured, no token, July 29 and again August 19, 2026
Ceiling without a token x-ratelimit-limit: 60 in the headers. That it is tied to the IP is GitHub's sentence, not something one address can measure
GET /rate_limit 0. Two reads in a row did not move remaining
Plain GET /repos/python/cpython 1 (HTTP 200, 5,996 bytes of JSON; 1,423 and 1,428 bytes on the wire with gzip, in the two months)
3 x If-None-Match on the same URL 304, 304, 304, zero bytes each, counter down exactly 3
GET of a repository that does not exist 1 (HTTP 404, 118 bytes)
GET /search/repositories core untouched (delta 0), own bucket, x-ratelimit-resource: search, limit 10. The header gives the number; the docs give the unit, per minute, against core's 60 per hour
The reset epoch Did not move while I was spending. What the window does at reset: not measured

Four of those deserve a sentence each.

Errors are charged. The 404 cost the same as the 200. Probing whether a repository exists spends the identical budget as fetching one that does.

In the keyless package-registry post I wrote that the 403 you get when the bucket runs out looks nothing like a 404. I did not verify that then and I have not verified it here either, which I will come back to. The half I did measure is this one: the 404 itself is charged like any other answer.

The buckets are separate. Search did not touch core at all. It came back with x-ratelimit-resource: search and a limit of 10, which lines up with GitHub's search documentation: "For unauthenticated requests, the rate limit allows you to make up to 10 requests per minute." Note the unit. Search is 10 a minute, core is 60 an hour, so the smaller-looking number is the more generous budget by an order of magnitude. If you are tracking one total budget for api.github.com, you are tracking the wrong number.

Something I noticed and did not chase. The search response header and the meter disagree, and I do not have an explanation. In two runs the response header said remaining: 9 while the meter, read a second later, said 10 of 10. In a later run the same place gave remaining: 8 with used=2. Three different pictures in the same spot. Core agreed with itself at every checkpoint in every run; only search wobbles. I am not going to build a story about per-minute rollover on top of that, because a rollover landing inside a one-second gap three times running is not a story I would believe from somebody else.

The reset epoch stayed put while I spent. That is the whole observation. My runs lasted about 29 seconds and never saw a reset, so what happens at the boundary, whether the bucket trickles back or refills whole, is not measured here. GitHub documents a fixed window; I am repeating that, not confirming it.

The arithmetic that kills a poller

This next part is arithmetic on the numbers above, not a separate measurement. Label it that way in your head.

Poll one endpoint every 30 seconds with a beautifully implemented ETag cache and no token. That is 120 requests per hour against a ceiling of 60. Your bucket is empty roughly 30 minutes into every hour, and the ETags did not slow that down by a single request. Then you wait for the reset.

The failure is quiet, which is what makes it expensive. The poller looks correct. Every response is a 304, every 304 says "nothing changed", the log stays clean, and the thing dies half way through the hour for a reason the code never mentions.

The header that was in every response the whole time

Here is the part I missed on the first pass, and it is embarrassing because the server had been telling me since the first request. GET /repos/python/cpython comes back with:

cache-control: public, max-age=60, s-maxage=60
Enter fullscreen mode Exit fullscreen mode

Revalidation and freshness are two different things, and I had been treating them as one. A conditional request is revalidation: you ask the server whether your copy is stale, and without a token that question costs you a request whatever the answer is. max-age=60 is freshness: for sixty seconds the server is telling you not to ask at all.

Run the same arithmetic with a client that honours it. Polling every 30 seconds, half of those calls fall inside a window the server already declared fresh, so they never reach the network. Sixty requests an hour instead of 120, which is exactly the ceiling rather than double it. That is the difference between a job that dies at minute thirty and one that survives the hour, and note how thin that is: exactly the ceiling means a reserve of zero. One retry, one phase mismatch with the fixed window, one other process on the same address, and you are at 61.

I saw this header on one endpoint. Whether every endpoint sends it, and with what value, is not measured.

Back on July 2 I put GitHub in a list of keyless APIs an AI agent can read the web with and named the ceiling there: 60 requests per hour, per IP. That post named the size of the bucket. This one is about what drains it, and the answer includes several things that feel like they should be free.

Ask the meter instead of catching the exception

GitHub's rate-limit page says that if you exceed the primary limit, "you will receive a 403 or 429 response". Two possible codes, and both of them mean other things in other contexts. There is no single status you can hang an except on.

The counter is better than the exception, for three reasons that came out of the same runs.

x-ratelimit-remaining is on every response, including the 304s and the 404 above. The meter endpoint is free: two reads in a row did not move it, and GitHub documents it as "does not count against your primary rate limit". And a number tells you how close you are, while an exception only tells you that you already arrived.

Runnable locally, standard library, no token:

#!/usr/bin/env python3
"""Ask the meter before you act, instead of catching an exception afterwards.

Standard library, no token, no signup. Runnable locally.
GET /rate_limit is documented as not counted against the primary limit, and it
measured at zero cost here, so the guard is free. The call it guards is not.
"""
import json
import time
import urllib.error
import urllib.request

API = "https://api.github.com"
HDRS = {"User-Agent": "budget-guard (contact: you@example.com)",
        "Accept": "application/vnd.github+json"}


class OutOfBudget(Exception):
    pass


def core_left():
    """(remaining, limit, seconds_to_reset). This read is free."""
    req = urllib.request.Request(API + "/rate_limit", headers=HDRS)
    with urllib.request.urlopen(req, timeout=20) as r:
        core = json.loads(r.read())["resources"]["core"]
    return core["remaining"], core["limit"], core["reset"] - int(time.time())


def get(path, etag=None, keep=5):
    """One guarded call. Returns (status, bytes, remaining_after, etag)."""
    left, limit, in_s = core_left()
    if left <= keep:
        raise OutOfBudget("%d/%d left, window resets in %ds" % (left, limit, in_s))
    h = dict(HDRS)
    if etag:
        h["If-None-Match"] = etag      # saves bytes; without a token it does not save budget
    req = urllib.request.Request(API + path, headers=h)
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            body, status, hdrs = r.read(), r.status, r.headers
    except urllib.error.HTTPError as e:
        body, status, hdrs = e.read(), e.code, e.headers   # 304 and 404 are answers, not crashes
    return status, len(body), hdrs.get("x-ratelimit-remaining"), hdrs.get("etag")


print("A. plain call")
st, n, left, tag = get("/repos/python/cpython")
print("   status=%s bytes=%s remaining=%s" % (st, n, left))

print("B. same call, now with the ETag")
st, n, left, _ = get("/repos/python/cpython", etag=tag)
print("   status=%s bytes=%s remaining=%s" % (st, n, left))

print("C. the guard has to be able to say no")
try:
    get("/repos/python/cpython", keep=999)
    print("   guard stayed silent  <-- broken guard")
except OutOfBudget as e:
    print("   OutOfBudget: %s" % e)
Enter fullscreen mode Exit fullscreen mode

Output:

A. plain call
   status=200 bytes=5996 remaining=33
B. same call, now with the ETag
   status=304 bytes=0 remaining=32
C. the guard has to be able to say no
   OutOfBudget: 32/60 left, window resets in 1412s
Enter fullscreen mode Exit fullscreen mode

Block C is there because a guard that cannot refuse is not a guard. Setting keep=999 forces the refusal on a healthy bucket, and if that line ever prints "guard stayed silent" the guard is broken and everything above it is theatre.

Block B is the finding again, this time inside the client you would actually ship: 200 at remaining=33, then a 304 with an empty body at remaining=32. Zero bytes of body, one request off the counter. The headers still travel, in both directions; the body is what the ETag saves you.

Two things I would not read into this snippet. The keep=5 reserve is a number I picked, not a measured optimum.

And calling core_left() before every single request is fine at these volumes but wasteful at any real rate. Cache the value, refresh it from the x-ratelimit-remaining header that every response already carries, and re-read the meter when the numbers look stale.

Keep sending the ETag anyway. Without a token it still saves you the body: 5,996 bytes of JSON, about 1.4 kB on the wire once you let the server gzip it. It costs one header. Just do not budget as though it buys you a request.

Why I did not drain the bucket

The obvious next experiment is to keep going until something breaks, screenshot the error, and settle the 403 versus 429 question. I did not run it, and the reason is not squeamishness.

That budget belongs to GitHub, and on a shared address it belongs to whoever else is behind it. Burning through somebody's free quota to illustrate an article about respecting free quotas would make the method contradict the point.

So the status code at exhaustion is genuinely unknown to me. Where I mention "403 or 429" above, I am quoting their sentence, not reporting mine.

One run of the probe costs 6 requests: 5 from the core bucket of 60 and 1 from the search bucket of 10. Two buckets, counted separately, because a post that just spent a section proving the buckets are separate does not get to add them together at the till.

Four probe runs and the guard snippet come to 22 core requests, and I would happily have printed that number. The meter disagrees, and the meter wins. All four runs landed inside one window: the last of them reported resets in +1336s at 19:03:54Z, which puts the window's start at 18:26:10Z and every run after it. The last line that run printed reads used=33. So this address spent 33 of that hour's 60, not the 22 I can name, and after the section above I am not going to swear the other eleven were even me. Thirty-three of sixty is the number that belongs in a section about other people's quota. It is also still not a load test. A question about rate limits does not need one to answer, it needs the headers you are already being sent.

I have done the restrained version before. When I checked keyless certificate-transparency and DNS APIs on July 20, crt.sh returned a 502, then a dead connection, then two 200s, four calls in twelve seconds. I published that as something I observed while making a handful of ordinary calls, not as something I induced.

Honest limits

  • This is four runs on one IP inside 19 minutes on July 29, 2026, plus one confirming run on August 19. Not "GitHub always does this". Three weeks of stability is what I have; whether it holds over months is not measured. If you are reading this later than that, run the script before quoting me.
  • The authenticated case is not measured at all. There is no valid token in this environment. The 5,000 per hour and the free 304s that come with an Authorization header are documented by GitHub, not verified by me, and I am not going to pretend otherwise. If you have a token and 30 seconds, that is the experiment I would most like someone else to run.
  • The status code at exhaustion is not measured. See above.
  • Secondary rate limits are not measured. I never went near them. GitHub documents them separately, including a note that the free meter endpoint can count against them.
  • One endpoint, one conditional mechanism. I measured If-None-Match against /repos/{owner}/{repo}. Whether /repos/.../commits behaves the same, or whether If-Modified-Since behaves the same, is not measured.
  • I did not read their source, so I cannot tell you why. All I can say is that the observed behaviour matches the documented rule read strictly: no Authorization header, no discount. Whether that is deliberate or incidental, I do not know.
  • Latency is not an argument here. In July the 304s came back in 931 to 974 ms against 1,191 ms for the 200; in August, 1,014, 2,623 and 1,120 ms against 1,018. On a 6 kB body that is noise plus one outlier, and I am not presenting any of it as a benefit. urllib opens a fresh connection per call, so most of what those numbers measure is the handshake.

The open question

The advice that follows from all of this is "use a token", and for most people that is the end of it. A token is free, it raises the documented ceiling from 60 to 5,000, and per the docs it makes the 304s free too.

But there are places where a token is the thing you cannot have. A public demo that runs client-side. A community tool where you refuse to ask users for a scope.

Or a CI job on a shared runner, where the address is not yours and 60 an hour is split with strangers you cannot see. That is exactly the situation my idle control was built to detect, and exactly the situation it would have failed in.

In those places, every answer I have reduces to "poll less often" or "put a server in front of it with a token on it". Both work. Neither is clever, and the second one just moves the token somewhere else.

So here is what I actually want to know: without an Authorization header, is there anything smarter than reducing frequency? Something that gets more signal out of 60 requests an hour rather than spending them more slowly? If you have shipped one, I want to hear how it behaves on the day the bucket is shared. 👇

Follow along if you want the next set of numbers when I measure them.


Written with AI assistance. Every status code, byte count, header value and counter reading above comes from my own runs against api.github.com on July 29 and August 19, 2026, and is either pasted as it was printed or read straight off that printed output, with the controls shown. Two things the probe cannot see and I measured separately with curl on the same endpoint: the on-the-wire gzip size (content-length: 1423 in July, 1428 in August, both unpacking to the same 5,996 bytes) and the cache-control header. Documentation quotes were re-checked against the GitHub pages linked above on August 19, 2026.

Top comments (0)