Skip to content

Diagnostic GitHub API

polling without ETags spends full quota on unchanged data

The dashboard polls eight endpoints every thirty seconds and burns through 5,000 requests before lunch. Almost nothing it fetches has changed. GitHub has been sending an etag on every one of those responses, and every response that comes back 304 Not Modified is free — it does not count against the rate limit at all. This is the one problem in this section where the fix pays for itself in a number you can print.

Read-only token Python and Node.js Tests included
A purple background with a basket of items and a target
Photo by Growtika on Unsplash
The short answer

Every response carries an etag. Send it back as If-None-Match and an unchanged resource answers 304 Not Modified with an empty body, and that response does not count against your primary rate limit.

You do not have to take that on trust. x-ratelimit-used comes back on every response, so make the request twice — once plain, once conditional — and compare the two values. If the second call returned 304 and used did not move, the saving is proven for that endpoint, and multiplying it by your poll rate turns it into requests an hour.

The problem in plain words

Quota exhaustion is usually blamed on volume, so the first fix attempted is always to poll less often. That is a real cost: the dashboard gets staler, the bot reacts later, and the quota problem comes back the next time someone adds a repository. The requests were never the problem. Paying full price for answers that say "nothing changed" was.

What hides it is that the wasteful version works perfectly. There is no error, no warning header, no degraded response. A poller with no conditional requests and a poller with them return identical data; the only difference is a counter neither one reads. So this survives code review indefinitely, and it is discovered when the quota runs out and every call starts returning 403 — at which point it looks like a rate-limit incident rather than a caching one.

It also scales in the wrong direction. Add a repository and the poll cost grows linearly; add a repository to a conditional poller and the cost stays near zero as long as nothing changes in it. Two integrations with the same shape end up in completely different places six months later.

Poll 8endpointsevery 30 secondsetag arrives,discardedno If-None-Matchsent200 with a fullbodybilled in full960 requests anhouron unchanged dataQuota gone bynoonread as a rateincident
There is no error anywhere in this chain. The wasteful version and the cheap version return identical data.

Why it happens

A 304 is free, and that is the whole mechanism. GitHub documents conditional requests as not counting against the primary rate limit. The request is still made, the round trip still happens, the bandwidth is still tiny, and x-ratelimit-remaining does not move. Nothing else in the API offers a discount like this.

The evidence is on the response you already have. etag comes back on essentially every GET, and x-ratelimit-used comes back next to it. A script can therefore measure the saving rather than assert it, which is unusual: for most of the problems in these notes the fix has to be argued for.

The cache key is the full request, not the path. An ETag belongs to a URL including its query string, its Accept header and the credential that fetched it. Change per_page, change the sort order, rotate the token, and the stored ETag stops matching — the request is billed again and nobody notices, because a 200 is not an error. Keeping request parameters stable is part of the fix, not an optimisation on top of it.

304 is not a failure and clients keep treating it as one. Some HTTP libraries raise on any non-2xx, and a wrapper written for that behaviour turns the cheapest response in the API into an exception. The handling is one branch: on 304, keep what you already have.

Some endpoints support last-modified instead or as well. Where an etag is absent, if-modified-since against the last-modified value does the same job. Where both are present, the ETag is the stronger validator and is what you should send.

The fix, as a flow

This is the one note in the section where the fix can be measured rather than argued for. Two requests, three header values, one subtraction: if the second call came back 304 and x-ratelimit-used did not move, the saving is a fact.

Same GET sent twicesecond one conditional304 and used unchangedfree, saving is exact200 despite the headerproxy stripped it304 but used movedanother process shares the tokenNo etag at alltry if-modified-since
A 200 answer to a conditional request is not a saving that failed. It is a header that did not arrive.

How to fix it

Fetch the endpoint once and keep two numbers

Make the request your integration already makes and record etag and x-ratelimit-used from the response. If there is no etag, look for last-modified; if neither is present, this endpoint cannot be cached and the saving does not apply to it.

Repeat it with If-None-Match and read x-ratelimit-used again

Send the exact ETag string back, quotes and any W/ prefix included. An unchanged resource answers 304 with no body. Subtract the two used values: a difference of zero is the measurement this whole note exists to produce.

Multiply by your real poll rate

Eight endpoints every thirty seconds is 960 requests an hour, roughly a fifth of a 5,000-request budget, spent almost entirely on unchanged data. The same schedule with conditional requests costs close to nothing until something actually changes. That is the number to put in the pull request.

Store the ETag per URL and per credential

Key the cache on the full request — path plus query string plus Accept — and on the token that fetched it, because ETags are scoped to the credential. A rotation that silently invalidates the whole cache produces a quota spike with no error to explain it.

Treat 304 as data, not as an error

On 304, return the previously stored representation and do nothing else. Check that your HTTP client is not configured to raise on non-2xx, and that nothing between you and GitHub is stripping or rewriting the header: a proxy that drops If-None-Match turns every conditional request back into a billed one, which the measurement above will show as a 200 where a 304 was expected.

How to check it worked

Run the script against an endpoint your integration polls. A confirmed saving reports the conditional request as free and prices the current poll rate against the quota.

python3 github_etag_saving.py --repo acme/api --path /issues --poll-seconds 30 --endpoints 8
# free: the 304 cost 0 request(s); 960/hour becomes 0/hour, 19.2% of quota returned

The full code

Two GETs, three header values, one subtraction. The measurement is the point, so measure() takes the two responses already reduced to status, etag and used and returns a verdict without touching the network — which lets the tests cover the cases you cannot arrange on demand, including the awkward one where the endpoint answers 200 to a conditional request because a proxy dropped the header.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_etag_saving.py
"""Measure what conditional requests would save against the GitHub rate limit.

Read only. Two GETs against one endpoint: the second sends If-None-Match with the
ETag the first returned. A 304 Not Modified does not count against the primary
rate limit, and x-ratelimit-used on both responses proves it rather than
asserting it.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_etag_saving")

API = "https://api.github.com"
UA = "github-etag-saving/1.0"

DEFAULT_LIMIT = 5000


def measure(first, second):
    """Compare a plain response with the conditional one that followed. Pure.

    Each argument is {"status": int, "etag": str|None, "used": int|None}. Returns
    (state, report). The states are deliberately separate because they have
    nothing in common: an endpoint that sends no ETag cannot be cached at all, an
    endpoint that answers 200 to a conditional request is being interfered with,
    and a 304 that still increments used would mean the documented discount did
    not apply.
    """
    etag = (first or {}).get("etag")
    before = (first or {}).get("used")
    after = (second or {}).get("used")
    status = (second or {}).get("status")

    try:
        delta = int(after) - int(before)
    except (TypeError, ValueError):
        delta = None

    report = {"etag": etag, "used_before": before, "used_after": after,
              "cost_of_unchanged_poll": delta,
              "first_status": (first or {}).get("status"), "second_status": status}

    if not etag:
        return ("no-etag", report)
    if status != 304:
        return ("not-honoured", report)
    if delta is None:
        return ("unmeasured", report)
    if delta > 0:
        return ("billed", report)
    return ("free", report)


def project(poll_seconds, endpoints, limit=DEFAULT_LIMIT, unchanged_fraction=1.0):
    """Price a polling schedule with and without conditional requests. Pure.

    unchanged_fraction is how much of what you poll is typically unchanged. At
    1.0 every poll is a 304 and costs nothing; at 0.0 nothing is cacheable and
    conditional requests save nothing, which is the honest end of the range.
    """
    poll_seconds = max(1.0, float(poll_seconds))
    endpoints = max(1, int(endpoints))
    limit = max(1, int(limit))
    fraction = min(1.0, max(0.0, float(unchanged_fraction)))

    without = (3600.0 / poll_seconds) * endpoints
    with_etags = without * (1.0 - fraction)
    return {"per_hour_without": round(without, 1),
            "per_hour_with": round(with_etags, 1),
            "saved_per_hour": round(without - with_etags, 1),
            "percent_without": round(100.0 * without / limit, 1),
            "percent_with": round(100.0 * with_etags / limit, 1),
            "limit": limit}


def verdict(state, projection):
    """Turn the measurement and the projection into one line. Pure."""
    saved = (projection or {}).get("saved_per_hour", 0)
    percent = (projection or {}).get("percent_without", 0)

    if state == "no-etag":
        return ("unavailable",
                "the response carried no etag, so this endpoint cannot be polled "
                "conditionally. Check last-modified and use if-modified-since "
                "where it is present.")
    if state == "not-honoured":
        return ("ignored",
                "the conditional request came back 200 rather than 304. Either "
                "the resource genuinely changed between the two calls, or "
                "something between this client and GitHub is dropping the "
                "If-None-Match header, which silently reinstates the full cost.")
    if state == "billed":
        return ("billed",
                "the 304 arrived and x-ratelimit-used still moved, which is not "
                "how conditional requests are documented to behave. Re-run "
                "before acting on it: another process sharing this token spends "
                "the same counter.")
    if state == "unmeasured":
        return ("unmeasured",
                "the 304 arrived but x-ratelimit-used was missing from one of "
                "the responses, so the saving is real and its size is not "
                "measured here.")
    return ("saving" if percent < 25 else "large-saving",
            "the 304 cost 0 request(s). At this poll rate that is %.0f request(s) "
            "an hour, %.1f%% of the quota, currently spent on data that did not "
            "change." % (saved, percent))


def read(response):
    """Reduce a response to the three fields the measurement needs."""
    headers = {k.lower(): v for k, v in response.headers.items()}
    try:
        used = int(headers.get("x-ratelimit-used"))
    except (TypeError, ValueError):
        used = None
    return {"status": response.status_code, "etag": headers.get("etag"),
            "used": used, "last_modified": headers.get("last-modified"),
            "limit": headers.get("x-ratelimit-limit")}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", required=True, help="owner/name")
    ap.add_argument("--path", default="/issues",
                    help="path under the repository to probe, e.g. /issues")
    ap.add_argument("--poll-seconds", type=float, default=60.0,
                    help="how often your integration polls this endpoint")
    ap.add_argument("--endpoints", type=int, default=1,
                    help="how many endpoints are polled on that schedule")
    ap.add_argument("--unchanged", type=float, default=1.0,
                    help="fraction of polls that find nothing changed (0 to 1)")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    owner, _, name = args.repo.partition("/")
    if not (owner and name):
        log.error("--repo takes owner/name, for example acme/api")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    url = "%s/repos/%s/%s%s" % (API, owner, name, args.path)
    log.info("probing %s twice: once plain, once with If-None-Match", url)

    plain = session.get(url, timeout=30)
    if plain.status_code == 401:
        log.error("401 from GitHub: GITHUB_TOKEN is missing, expired or malformed")
        return 2
    if plain.status_code in (403, 404):
        log.error("%d from %s: this token cannot read that endpoint. GitHub "
                  "answers 404 rather than 403 when a token cannot see a "
                  "resource at all.", plain.status_code, url)
        return 2
    first = read(plain)
    log.info("  plain:       %d, etag %s, x-ratelimit-used %s",
             first["status"], first["etag"], first["used"])

    second = first
    if first["etag"]:
        conditional = session.get(url, timeout=30,
                                  headers={"If-None-Match": first["etag"]})
        second = read(conditional)
        log.info("  conditional: %d, x-ratelimit-used %s",
                 second["status"], second["used"])
    elif first["last_modified"]:
        log.warning("  no etag, but last-modified is %s: use if-modified-since "
                    "on this endpoint instead", first["last_modified"])

    state, report = measure(first, second)
    limit = DEFAULT_LIMIT
    try:
        limit = int(first["limit"])
    except (TypeError, ValueError):
        pass

    projection = project(args.poll_seconds, args.endpoints, limit, args.unchanged)
    level, detail = verdict(state, projection)
    log.info("%s: %s", level, detail)
    log.info("  %.0f request(s)/hour now (%.1f%% of %d), %.0f/hour with "
             "conditional requests (%.1f%%)",
             projection["per_hour_without"], projection["percent_without"],
             projection["limit"], projection["per_hour_with"],
             projection["percent_with"])

    if level in ("saving", "large-saving"):
        log.info("  repair: store %s against this exact URL and credential, send "
                 "it back as If-None-Match, and treat 304 as 'keep what you "
                 "have' rather than as an error.", report["etag"])
        log.info("  repair: keep per_page, sort and Accept stable, and key the "
                 "cache by token: an ETag is scoped to the credential that "
                 "fetched it, so a rotation invalidates every entry at once.")
    return 0 if level in ("saving", "large-saving", "unavailable") else 1


if __name__ == "__main__":
    sys.exit(main())
github-etag-saving.mjs
/**
 * Measure what conditional requests would save against the GitHub rate limit.
 *
 * Read only. Two GETs against one endpoint: the second sends If-None-Match with
 * the ETag the first returned. A 304 Not Modified does not count against the
 * primary rate limit, and x-ratelimit-used on both responses proves it.
 */
const API = 'https://api.github.com';
const UA = 'github-etag-saving/1.0';

export const DEFAULT_LIMIT = 5000;

/**
 * Compare a plain response with the conditional one that followed. Pure.
 * Each argument is { status, etag, used }. Returns [state, report].
 */
export function measure(first, second) {
  const etag = first?.etag ?? null;
  const before = first?.used;
  const after = second?.used;
  const status = second?.status;

  const parsedBefore = Number.parseInt(before, 10);
  const parsedAfter = Number.parseInt(after, 10);
  const delta = (Number.isFinite(parsedBefore) && Number.isFinite(parsedAfter))
    ? parsedAfter - parsedBefore : null;

  const report = {
    etag,
    used_before: before ?? null,
    used_after: after ?? null,
    cost_of_unchanged_poll: delta,
    first_status: first?.status ?? null,
    second_status: status ?? null,
  };

  if (!etag) return ['no-etag', report];
  if (status !== 304) return ['not-honoured', report];
  if (delta === null) return ['unmeasured', report];
  if (delta > 0) return ['billed', report];
  return ['free', report];
}

/**
 * Price a polling schedule with and without conditional requests. Pure.
 * unchangedFraction is how much of what you poll is typically unchanged.
 */
export function project(pollSeconds, endpoints, limit = DEFAULT_LIMIT, unchangedFraction = 1) {
  const seconds = Math.max(1, Number(pollSeconds));
  const count = Math.max(1, Math.trunc(endpoints));
  const cap = Math.max(1, Math.trunc(limit));
  const fraction = Math.min(1, Math.max(0, Number(unchangedFraction)));

  const without = (3600 / seconds) * count;
  const withEtags = without * (1 - fraction);
  const round = (n) => Math.round(n * 10) / 10;
  return {
    per_hour_without: round(without),
    per_hour_with: round(withEtags),
    saved_per_hour: round(without - withEtags),
    percent_without: round(100 * without / cap),
    percent_with: round(100 * withEtags / cap),
    limit: cap,
  };
}

/** Turn the measurement and the projection into one line. Pure. */
export function verdict(state, projection) {
  const saved = projection?.saved_per_hour ?? 0;
  const percent = projection?.percent_without ?? 0;

  if (state === 'no-etag') {
    return ['unavailable',
      'the response carried no etag, so this endpoint cannot be polled ' +
      'conditionally. Check last-modified and use if-modified-since where it ' +
      'is present.'];
  }
  if (state === 'not-honoured') {
    return ['ignored',
      'the conditional request came back 200 rather than 304. Either the ' +
      'resource genuinely changed between the two calls, or something between ' +
      'this client and GitHub is dropping the If-None-Match header, which ' +
      'silently reinstates the full cost.'];
  }
  if (state === 'billed') {
    return ['billed',
      'the 304 arrived and x-ratelimit-used still moved, which is not how ' +
      'conditional requests are documented to behave. Re-run before acting on ' +
      'it: another process sharing this token spends the same counter.'];
  }
  if (state === 'unmeasured') {
    return ['unmeasured',
      'the 304 arrived but x-ratelimit-used was missing from one of the ' +
      'responses, so the saving is real and its size is not measured here.'];
  }
  return [percent < 25 ? 'saving' : 'large-saving',
    `the 304 cost 0 request(s). At this poll rate that is ${Math.round(saved)} ` +
    `request(s) an hour, ${percent}% of the quota, currently spent on data that ` +
    'did not change.'];
}

function read(res) {
  const headers = {};
  for (const [k, v] of res.headers.entries()) headers[k.toLowerCase()] = v;
  const used = Number.parseInt(headers['x-ratelimit-used'], 10);
  return {
    status: res.status,
    etag: headers.etag ?? null,
    used: Number.isFinite(used) ? used : null,
    last_modified: headers['last-modified'] ?? null,
    limit: headers['x-ratelimit-limit'] ?? null,
  };
}

function head(token, extra = {}) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
    ...extra,
  };
}

async function main() {
  const repo = process.argv[2];
  const path = process.argv[3] ?? '/issues';
  const pollSeconds = Number.parseFloat(process.argv[4] ?? '60') || 60;
  const endpoints = Number.parseInt(process.argv[5] ?? '1', 10) || 1;
  const token = process.env.GITHUB_TOKEN;

  if (!token) {
    console.error('set GITHUB_TOKEN (a read-only token is enough)');
    process.exitCode = 2;
    return;
  }
  if (!repo || !repo.includes('/')) {
    console.error('usage: node github-etag-saving.mjs owner/name [/path] ' +
      '[pollSeconds] [endpoints]');
    process.exitCode = 2;
    return;
  }

  const url = `${API}/repos/${repo}${path}`;
  console.log(`probing ${url} twice: once plain, once with If-None-Match`);

  const plain = await fetch(url, { headers: head(token) });
  if (plain.status === 401) {
    console.error('401 from GitHub: GITHUB_TOKEN is missing, expired or malformed');
    process.exitCode = 2;
    return;
  }
  if (plain.status === 403 || plain.status === 404) {
    console.error(`${plain.status} from ${url}: this token cannot read that ` +
      'endpoint. GitHub answers 404 rather than 403 when a token cannot see a ' +
      'resource at all.');
    process.exitCode = 2;
    return;
  }
  const first = read(plain);
  console.log(`  plain:       ${first.status}, etag ${first.etag}, ` +
    `x-ratelimit-used ${first.used}`);

  let second = first;
  if (first.etag) {
    const conditional = await fetch(url, {
      headers: head(token, { 'If-None-Match': first.etag }),
    });
    second = read(conditional);
    console.log(`  conditional: ${second.status}, x-ratelimit-used ${second.used}`);
  } else if (first.last_modified) {
    console.warn(`  no etag, but last-modified is ${first.last_modified}: use ` +
      'if-modified-since on this endpoint instead');
  }

  const [state, report] = measure(first, second);
  const limit = Number.parseInt(first.limit, 10) || DEFAULT_LIMIT;
  const projection = project(pollSeconds, endpoints, limit, 1);
  const [level, detail] = verdict(state, projection);
  console.log(`${level}: ${detail}`);
  console.log(`  ${projection.per_hour_without} request(s)/hour now ` +
    `(${projection.percent_without}% of ${projection.limit}), ` +
    `${projection.per_hour_with}/hour with conditional requests ` +
    `(${projection.percent_with}%)`);

  if (level === 'saving' || level === 'large-saving') {
    console.log(`  repair: store ${report.etag} against this exact URL and ` +
      "credential, send it back as If-None-Match, and treat 304 as 'keep what " +
      "you have' rather than as an error.");
    console.log('  repair: keep per_page, sort and Accept stable, and key the ' +
      'cache by token: an ETag is scoped to the credential that fetched it, so ' +
      'a rotation invalidates every entry at once.');
  }
  process.exitCode = ['saving', 'large-saving', 'unavailable'].includes(level) ? 0 : 1;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The measurement has one honest outcome and three dishonest ones, and the tests exist to keep them apart. A 200 where a 304 was expected is not a saving that failed to materialise, it is a header that did not arrive. A missing etag is not a bug in the endpoint. And a 304 whose used counter moved anyway is most likely another process spending the same shared quota, which the report has to say rather than quietly reporting a smaller saving.

test_github_etag_saving.py
from github_etag_saving import measure, project, verdict

ETAG = 'W/"6c1a2f9e0b7d4a3c"'


def response(status, etag=ETAG, used=None):
    return {"status": status, "etag": etag, "used": used}


def test_a_304_that_did_not_move_the_counter_is_the_finding():
    state, report = measure(response(200, used=101), response(304, used=101))
    assert state == "free"
    assert report["cost_of_unchanged_poll"] == 0
    assert report["etag"] == ETAG


def test_an_endpoint_with_no_etag_cannot_be_polled_conditionally():
    state, _ = measure(response(200, etag=None, used=10), response(200, used=11))
    assert state == "no-etag"


def test_a_200_answer_to_a_conditional_request_is_its_own_finding():
    # A proxy that strips If-None-Match reinstates the full cost silently.
    state, report = measure(response(200, used=10), response(200, used=11))
    assert state == "not-honoured"
    assert report["cost_of_unchanged_poll"] == 1


def test_a_304_that_still_billed_is_reported_rather_than_smoothed_over():
    state, report = measure(response(200, used=10), response(304, used=12))
    assert state == "billed"
    assert report["cost_of_unchanged_poll"] == 2


def test_a_missing_used_header_leaves_the_saving_unmeasured():
    state, report = measure(response(200, used=None), response(304, used=None))
    assert state == "unmeasured"
    assert report["cost_of_unchanged_poll"] is None


def test_the_projection_prices_a_real_polling_schedule():
    p = project(30, 8, 5000, 1.0)
    assert p["per_hour_without"] == 960.0
    assert p["per_hour_with"] == 0.0
    assert p["saved_per_hour"] == 960.0
    assert p["percent_without"] == 19.2


def test_a_partly_changing_workload_saves_only_part_of_it():
    p = project(60, 1, 5000, 0.75)
    assert p["per_hour_without"] == 60.0
    assert p["per_hour_with"] == 15.0
    assert p["saved_per_hour"] == 45.0


def test_nothing_unchanged_means_nothing_saved():
    p = project(60, 1, 5000, 0.0)
    assert p["saved_per_hour"] == 0.0


def test_the_projection_refuses_nonsense_inputs_instead_of_dividing_by_zero():
    p = project(0, 0, 0, 5.0)
    assert p["limit"] == 1
    assert p["per_hour_without"] == 3600.0
    assert p["per_hour_with"] == 0.0


def test_a_large_share_of_quota_is_called_out_as_such():
    level, detail = verdict("free", project(30, 8, 5000, 1.0))
    assert level == "saving"
    assert "19.2%" in detail
    assert verdict("free", project(10, 8, 5000, 1.0))[0] == "large-saving"


def test_each_unhappy_state_names_a_different_repair():
    assert verdict("no-etag", project(60, 1))[0] == "unavailable"
    assert verdict("not-honoured", project(60, 1))[0] == "ignored"
    assert verdict("billed", project(60, 1))[0] == "billed"
    assert verdict("unmeasured", project(60, 1))[0] == "unmeasured"


def test_the_ignored_state_blames_the_header_not_the_quota():
    _, detail = verdict("not-honoured", project(60, 1))
    assert "If-None-Match" in detail


def test_the_billed_state_points_at_the_shared_counter():
    _, detail = verdict("billed", project(60, 1))
    assert "shares" in detail or "sharing" in detail
github-etag-saving.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { measure, project, verdict } from './github-etag-saving.mjs';

const ETAG = 'W/"6c1a2f9e0b7d4a3c"';

const response = (status, etag = ETAG, used = null) => ({ status, etag, used });

test('a 304 that did not move the counter is the finding', () => {
  const [state, report] = measure(response(200, ETAG, 101), response(304, ETAG, 101));
  assert.equal(state, 'free');
  assert.equal(report.cost_of_unchanged_poll, 0);
  assert.equal(report.etag, ETAG);
});

test('an endpoint with no etag cannot be polled conditionally', () => {
  const [state] = measure(response(200, null, 10), response(200, ETAG, 11));
  assert.equal(state, 'no-etag');
});

test('a 200 answer to a conditional request is its own finding', () => {
  const [state, report] = measure(response(200, ETAG, 10), response(200, ETAG, 11));
  assert.equal(state, 'not-honoured');
  assert.equal(report.cost_of_unchanged_poll, 1);
});

test('a 304 that still billed is reported rather than smoothed over', () => {
  const [state, report] = measure(response(200, ETAG, 10), response(304, ETAG, 12));
  assert.equal(state, 'billed');
  assert.equal(report.cost_of_unchanged_poll, 2);
});

test('a missing used header leaves the saving unmeasured', () => {
  const [state, report] = measure(response(200, ETAG, null), response(304, ETAG, null));
  assert.equal(state, 'unmeasured');
  assert.equal(report.cost_of_unchanged_poll, null);
});

test('the projection prices a real polling schedule', () => {
  const p = project(30, 8, 5000, 1);
  assert.equal(p.per_hour_without, 960);
  assert.equal(p.per_hour_with, 0);
  assert.equal(p.saved_per_hour, 960);
  assert.equal(p.percent_without, 19.2);
});

test('a partly changing workload saves only part of it', () => {
  const p = project(60, 1, 5000, 0.75);
  assert.equal(p.per_hour_without, 60);
  assert.equal(p.per_hour_with, 15);
  assert.equal(p.saved_per_hour, 45);
});

test('nothing unchanged means nothing saved', () => {
  assert.equal(project(60, 1, 5000, 0).saved_per_hour, 0);
});

test('the projection refuses nonsense inputs instead of dividing by zero', () => {
  const p = project(0, 0, 0, 5);
  assert.equal(p.limit, 1);
  assert.equal(p.per_hour_without, 3600);
  assert.equal(p.per_hour_with, 0);
});

test('a large share of quota is called out as such', () => {
  const [level, detail] = verdict('free', project(30, 8, 5000, 1));
  assert.equal(level, 'saving');
  assert.match(detail, /19\.2%/);
  assert.equal(verdict('free', project(10, 8, 5000, 1))[0], 'large-saving');
});

test('each unhappy state names a different repair', () => {
  assert.equal(verdict('no-etag', project(60, 1))[0], 'unavailable');
  assert.equal(verdict('not-honoured', project(60, 1))[0], 'ignored');
  assert.equal(verdict('billed', project(60, 1))[0], 'billed');
  assert.equal(verdict('unmeasured', project(60, 1))[0], 'unmeasured');
});

test('the ignored state blames the header, not the quota', () => {
  assert.match(verdict('not-honoured', project(60, 1))[1], /If-None-Match/);
});

test('the billed state points at the shared counter', () => {
  assert.match(verdict('billed', project(60, 1))[1], /shar/);
});

FAQ

Does a 304 really cost nothing against the rate limit?

Yes, and you do not have to believe it on principle. x-ratelimit-used comes back on the 304 exactly as it does on the 200, so make the plain request, note the number, repeat with If-None-Match, and compare. A difference of zero is the measurement. That is what the script does and why its output is a number rather than a recommendation.

Why did my conditional request come back 200 instead of 304?

Either the resource changed between the two calls, which on a busy repository is entirely possible, or the If-None-Match header did not arrive. Proxies and some HTTP client wrappers strip or rewrite conditional headers. Re-run against a quiet endpoint to tell the two apart: if a repository's metadata still answers 200 to its own ETag, something in the path is removing the header.

Do ETags survive a token rotation?

No. An ETag is scoped to the credential that fetched it, so rotating a personal access token or minting a new installation token invalidates every cached entry at once. The symptom is a quota spike on a fixed schedule with no error attached to it, which is why the cache should be keyed by token as well as by URL.

What about endpoints that send no etag?

Look for last-modified and send if-modified-since instead; the discount is the same. A few endpoints support neither, and for those the answer is not caching but asking less often, or replacing the poll with a webhook so GitHub tells you when something changed instead of you asking.

Should I use conditional requests or webhooks?

Webhooks where you can, conditional requests everywhere else. A webhook removes the poll entirely; a conditional request makes the poll free. They are not alternatives so much as layers, and most integrations end up with both because there is always some state that no event describes.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.