Skip to content

Diagnostic GitHub API

rotating the token invalidates every cached ETag at once

The graph is a sawtooth and it is a very tidy one. Quota consumption sits near zero for fifty-odd minutes, jumps, and settles back, every hour, on the hour. Nothing in the schedule matches. The poller runs every thirty seconds and has done for months. What runs hourly is the installation token.

Read-only token Python and Node.js Tests included
Blue and white visa card on silver laptop computer
Photo by CardMapr.nl on Unsplash
The short answer

An ETag is not a property of the resource. It is a property of the resource as served to that credential, so the moment the credential changes, every ETag you stored stops matching and every conditional request that was returning a free 304 returns a billable 200 instead. GitHub App installation tokens expire after an hour, so this happens on a schedule you did not write.

You can demonstrate it in three requests. Fetch a URL, replay its etag as If-None-Match with the same token — that should be 304 — then replay the same ETag with a second credential. If the second one comes back 200, the cache is credential-scoped, and the repair is to key it that way and to reuse each token for its whole hour instead of minting one per cycle.

The problem in plain words

Everything about this reads as someone else's fault. The cache works — you can watch it work — and then for one minute an hour it does not, and the requests it lets through are exactly the requests you thought you had eliminated. The first theory is always that GitHub changed something, because the client did not.

The cost is quiet until the fleet grows. One process polling forty URLs pays forty extra full responses an hour, which nobody notices. The same code deployed per tenant, polling two thousand URLs, pays two thousand full responses in the few seconds after each rotation, and because they all land together the graph shows a spike rather than a drift, which gets read as an incident rather than as a cache miss.

Then the well-meaning fix makes it worse. A team that mints a fresh installation token for every request — safer sounding, and easy to write — has a cache that never hits at all, because no two requests ever share a credential. The conditional-request machinery is still there, still correct, and permanently useless.

Poll 2,000 urlsetags keyed by url304 all the wayquota near flatToken expiresa new one ismintedEvery etagmisses2,000 full bodiesRead as anincidentnothing hadchangedevery hour, on the hour
The cache works. It works for fifty nine minutes, and the minute it does not is the one on the graph.

Why it happens

Validators are scoped to the response the server sent you. GitHub computes an ETag against the representation it produced for that request, and that representation depends on who asked: visibility, installation permissions and the fields a given credential is allowed to see all feed into it. A different credential can legitimately be owed a different body, so the old validator cannot be honoured.

Installation tokens expire in an hour, by design. That is the whole security argument for GitHub Apps over long-lived PATs: the credential is short-lived. It is a good property, but it means an App's cache has a built-in hourly cliff that a PAT-based integration never sees, which is why this shows up when a team migrates from a PAT to an App and not before.

A cache keyed only by URL will silently mix credentials. Store url -> etag and the entry written under yesterday's token is served to today's, produces a 200, and gets overwritten. Nothing errors. The only visible symptom is the bill.

A 200 answer to a conditional request is not a failed saving, it is a missing match. This is the same signal as a stripped header or a changed resource, so a client that only counts 304s cannot tell rotation apart from real change. Comparing the same ETag across two credentials is what separates them.

Minting per request costs twice. Every mint is itself a request, and it throws away the cache the previous token had warmed. Holding one installation token for its full hour is both fewer requests and more 304s.

The fix, as a flow

Three requests settle it. Fetch once and keep the etag, replay it with the credential that minted it as a control, then replay the same etag with a second credential. The third answer is the whole note, and it arrives in a second rather than in an hour.

One etag, two tokensreplayed back to back304 then 200scoped to the credential200 to its own etagheader stripped, or it changed304 then 304rotation is not the causeNo second credentiala projection, not a measurement
A resource that genuinely changed answers 200 to both. Only a credential scoped validator answers 304 and 200 in the same second.

How to fix it

Fetch once and keep the etag

Any GET that returns an etag works; use one your integration actually polls. Keep the header value exactly as sent, quotes and any W/ weak prefix included, because If-None-Match is compared as a string.

Replay it with the same credential as a control

Send the identical GET with If-None-Match: <etag> and the same token. This should be 304. If it is not, stop here: the endpoint is not returning a usable validator and the rest of the test would be measuring the wrong thing.

Replay it with a second credential

Same URL, same ETag, different token — a second PAT is fine for the demonstration, and for an App this is exactly what the next hour looks like. A 200 here is the finding. It proves the validator did not survive the credential change, without waiting an hour to watch it happen.

Cost the rotation against your own poll shape

Full responses per day equals rotations per day times cached URLs. With an hourly token that is 24 times your URL count, all of it arriving in the seconds after each mint. Compare that number against 5,000 an hour: it is not the volume that hurts, it is that it is concentrated.

Key the cache by credential and hold the token for its hour

Make the cache key (credential identity, url) — a hash of the token, or the installation id plus token expiry, never the token itself — so a rotation produces an honest miss instead of a silent one. Then mint one installation token per hour and let it serve the whole polling cycle, refreshing a minute or two before expires_at rather than on every request.

How to check it worked

Run the check again with the two credentials swapped. The control request should still be 304, and the projection should show what the rotation costs once the cache is keyed properly: nothing, because a miss under a new key is a first fetch rather than a repeat.

python3 github_etag_credential_check.py --path /user --urls 2000 --ttl 3600
# credential-scoped, rotation-dominates: 2000 full response(s) per rotation

The full code

Three GETs and some arithmetic. The classification takes the two status codes rather than the responses, so the tests can cover the combinations that need two live tokens and an hour of waiting to produce. token_ttl parses the expires_at an App hands back with an installation token, and takes now as an argument so "this expires in six minutes" is reproducible instead of dependent on when the suite runs.

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_credential_check.py
"""Prove whether a cached ETag survives a change of credential, and cost it.

Read only. Three GETs against one URL, and the third is only issued when a
second credential is available in the environment.

An ETag is scoped to the representation the server produced for that caller, so
rotating a credential invalidates the whole cache at once. For a GitHub App that
happens every hour, on a schedule nobody wrote.
"""
import argparse
import hashlib
import json
import logging
import math
import os
import sys
import time
from datetime import datetime, timezone

import requests

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

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

# Installation access tokens are valid for one hour.
INSTALLATION_TOKEN_TTL = 3600
HOURLY_LIMIT = 5000


def classify_pair(same, other):
    """Sort the two conditional replays into a finding. Pure.

    `same` is the status when the ETag is replayed with the credential that
    minted it, which is the control. `other` is the status for the same ETag
    under a second credential. Returns (state, detail).
    """
    def code(value):
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    same, other = code(same), code(other)

    if same is None:
        return ("inconclusive", "the control request did not complete, so "
                                "nothing below it can be trusted")
    if same == 200:
        return ("not-cacheable",
                "the endpoint answered 200 to its own etag. Either no validator "
                "came back, something between here and GitHub stripped the "
                "If-None-Match header, or the resource genuinely changed between "
                "the two calls. Rule those out before testing rotation.")
    if same != 304:
        return ("inconclusive",
                "the control request returned %d rather than 304 or 200, which "
                "is not a cache answer at all" % same)

    if other is None:
        return ("unproven",
                "the etag matched its own credential, but no second credential "
                "was available to test the rotation against. The projection "
                "below is arithmetic, not a measurement.")
    if other == 304:
        return ("shared",
                "the same etag matched under both credentials, so rotation is "
                "not what is draining this quota. Look for a poll interval or a "
                "cache key problem instead.")
    if other == 200:
        return ("credential-scoped",
                "the etag that returned 304 for the credential that minted it "
                "returned 200 for another. Every rotation therefore refetches "
                "the entire cache at full price.")
    return ("inconclusive",
            "the second credential returned %d, which is neither a match nor a "
            "miss. Check that it can read this URL at all." % other)


def rotation_waste(urls, poll_interval_s, token_ttl_s,
                   hourly_limit=HOURLY_LIMIT, hours=24):
    """Full responses per day caused by rotation alone. Pure.

    The headline is not the daily total, which is usually modest. It is
    per_rotation: those requests all arrive in the seconds after a mint, which
    is why this reads as a spike rather than as a drift.
    """
    try:
        urls = max(0, int(urls))
    except (TypeError, ValueError):
        urls = 0
    interval = max(1, int(poll_interval_s or 1))
    ttl = max(1, int(token_ttl_s or 1))
    window = max(0, int(hours)) * 3600

    rotations = window // ttl
    polls = (window // interval) * urls
    return {"rotations": rotations,
            "per_rotation": urls,
            "daily": rotations * urls,
            "polls": polls,
            "hourly_share": round(urls / max(1, hourly_limit), 4)}


def token_ttl(expires_at, now):
    """Seconds left on an installation token from its ISO-8601 expires_at. Pure.

    None when it cannot be read, rather than 0: "already expired" and "I could
    not parse this" lead to different next steps.
    """
    if not expires_at:
        return None
    text = str(expires_at).strip().replace("Z", "+00:00")
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    try:
        return max(0, int(parsed.timestamp() - float(now)))
    except (TypeError, ValueError):
        return None


def verdict(state, waste):
    """Combine the measurement and the projection into one finding. Pure."""
    if state in ("not-cacheable", "inconclusive"):
        return (state, "no rotation cost can be projected until the control "
                       "request behaves")
    if state == "shared":
        return ("shared", "rotation is not the problem here")

    share = waste.get("hourly_share", 0)
    per_rotation = waste.get("per_rotation", 0)
    daily = waste.get("daily", 0)

    if state == "unproven" and not daily:
        return ("clear", "nothing to project: no cached urls, or a credential "
                         "that outlives the window")
    if share >= 0.25:
        return ("rotation-dominates",
                "%d full response(s) land in the seconds after every mint, which "
                "is %.0f%% of one hour's entire quota, %d time(s) a day"
                % (per_rotation, share * 100, waste.get("rotations", 0)))
    if daily:
        return ("rotation-costs",
                "%d full response(s) per rotation, %d a day, all of which a "
                "credential-keyed cache would have kept as 304s"
                % (per_rotation, daily))
    return ("clear", "the credential outlives the window, so no rotation cost "
                     "falls inside it")


def fingerprint(token):
    """A stable, non-reversible id for a credential, for use as a cache key.

    The token itself must never be the key: cache keys get logged, dumped and
    put in error messages.
    """
    return hashlib.sha256(("gh:" + str(token)).encode("utf-8")).hexdigest()[:12]


def get(session, url, token, etag=None):
    """One GET, optionally conditional. Returns (status, etag, used)."""
    headers = {
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    }
    if etag:
        headers["If-None-Match"] = etag
    r = session.get(url, headers=headers, timeout=30)
    lowered = {k.lower(): v for k, v in r.headers.items()}
    return r.status_code, lowered.get("etag"), lowered.get("x-ratelimit-used")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--path", default="/user",
                    help="any GET that returns an etag; use one you poll")
    ap.add_argument("--urls", type=int, default=40,
                    help="how many distinct urls your cache holds")
    ap.add_argument("--interval", type=int, default=30,
                    help="seconds between polls of each url")
    ap.add_argument("--ttl", type=int, default=INSTALLATION_TOKEN_TTL,
                    help="credential lifetime in seconds (an installation token "
                         "is 3600)")
    ap.add_argument("--expires-at",
                    help="ISO-8601 expires_at from an installation token, if you "
                         "have one; overrides --ttl for the report")
    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
    second = os.environ.get("GITHUB_TOKEN_SECOND")

    url = API + args.path if args.path.startswith("/") else args.path
    session = requests.Session()

    first_status, etag, used_before = get(session, url, token)
    if first_status != 200 or not etag:
        log.error("first GET %s returned %d with etag %r; pick a url that "
                  "returns a validator", url, first_status, etag)
        return 2
    log.info("cache key would be (%s, %s)", fingerprint(token), args.path)

    same_status, _, used_control = get(session, url, token, etag)
    log.info("control: same credential, same etag -> %d", same_status)

    other_status = None
    if second:
        other_status, _, _ = get(session, url, second, etag)
        log.info("rotation: second credential, same etag -> %d", other_status)
    else:
        log.warning("set GITHUB_TOKEN_SECOND to a second credential to measure "
                    "the rotation rather than project it")

    state, detail = classify_pair(same_status, other_status)
    log.info("%s: %s", state, detail)

    ttl = args.ttl
    if args.expires_at:
        left = token_ttl(args.expires_at, time.time())
        if left is None:
            log.warning("could not read --expires-at %r; falling back to --ttl",
                        args.expires_at)
        else:
            log.info("the credential you named expires in %ds", left)

    waste = rotation_waste(args.urls, args.interval, ttl)
    final, why = verdict(state, waste)
    log.info("%s: %s", final, why)

    if final in ("rotation-dominates", "rotation-costs"):
        log.info("repair: key the cache by (credential fingerprint, url) so a "
                 "rotation is an honest miss rather than a silent one.")
        log.info("repair: hold one installation token for its full hour and "
                 "refresh a minute before expires_at, rather than minting a "
                 "fresh one per request.")

    print(json.dumps({"measured": state, "state": final, "waste": waste,
                      "used_before": used_before, "used_control": used_control},
                     indent=2))
    return 1 if final in ("rotation-dominates", "rotation-costs",
                          "not-cacheable") else 0


if __name__ == "__main__":
    sys.exit(main())
github-etag-credential-check.mjs
/**
 * Prove whether a cached ETag survives a change of credential, and cost it.
 *
 * Read only. Three GETs against one URL, and the third is only issued when a
 * second credential is available in the environment.
 *
 * An ETag is scoped to the representation the server produced for that caller,
 * so rotating a credential invalidates the whole cache at once.
 */
import { createHash } from 'node:crypto';

const API = 'https://api.github.com';
const UA = 'github-etag-credential-check/1.0';

// Installation access tokens are valid for one hour.
export const INSTALLATION_TOKEN_TTL = 3600;
export const HOURLY_LIMIT = 5000;

/**
 * Sort the two conditional replays into a finding. Pure.
 * `same` is the control: the etag replayed with the credential that minted it.
 * `other` is the same etag under a second credential.
 */
export function classifyPair(same, other) {
  const code = (v) => {
    const n = Number.parseInt(v, 10);
    return Number.isFinite(n) ? n : null;
  };
  const control = code(same);
  const rotated = code(other);

  if (control === null) {
    return ['inconclusive',
      'the control request did not complete, so nothing below it can be trusted'];
  }
  if (control === 200) {
    return ['not-cacheable',
      'the endpoint answered 200 to its own etag. Either no validator came ' +
      'back, something between here and GitHub stripped the If-None-Match ' +
      'header, or the resource genuinely changed between the two calls. Rule ' +
      'those out before testing rotation.'];
  }
  if (control !== 304) {
    return ['inconclusive',
      `the control request returned ${control} rather than 304 or 200, which ` +
      'is not a cache answer at all'];
  }
  if (rotated === null) {
    return ['unproven',
      'the etag matched its own credential, but no second credential was ' +
      'available to test the rotation against. The projection below is ' +
      'arithmetic, not a measurement.'];
  }
  if (rotated === 304) {
    return ['shared',
      'the same etag matched under both credentials, so rotation is not what ' +
      'is draining this quota. Look for a poll interval or a cache key ' +
      'problem instead.'];
  }
  if (rotated === 200) {
    return ['credential-scoped',
      'the etag that returned 304 for the credential that minted it returned ' +
      '200 for another. Every rotation therefore refetches the entire cache ' +
      'at full price.'];
  }
  return ['inconclusive',
    `the second credential returned ${rotated}, which is neither a match nor a ` +
    'miss. Check that it can read this URL at all.'];
}

/**
 * Full responses per day caused by rotation alone. Pure.
 * The headline is per_rotation, not the daily total: those requests arrive
 * together, which is why this reads as a spike rather than a drift.
 */
export function rotationWaste(urls, pollIntervalS, tokenTtlS,
                              hourlyLimit = HOURLY_LIMIT, hours = 24) {
  const n = Math.max(0, Number.parseInt(urls, 10) || 0);
  const interval = Math.max(1, Number.parseInt(pollIntervalS, 10) || 1);
  const ttl = Math.max(1, Number.parseInt(tokenTtlS, 10) || 1);
  const window = Math.max(0, Number.parseInt(hours, 10) || 0) * 3600;

  const rotations = Math.floor(window / ttl);
  const polls = Math.floor(window / interval) * n;
  return {
    rotations,
    per_rotation: n,
    daily: rotations * n,
    polls,
    hourly_share: Math.round((n / Math.max(1, hourlyLimit)) * 10000) / 10000,
  };
}

/**
 * Seconds left on an installation token from its ISO-8601 expires_at. Pure.
 * null when unreadable, because "already expired" and "could not parse" lead
 * to different next steps.
 */
export function tokenTtl(expiresAt, now) {
  if (!expiresAt) return null;
  const at = Date.parse(String(expiresAt));
  const n = Number(now);
  if (!Number.isFinite(at) || !Number.isFinite(n)) return null;
  return Math.max(0, Math.trunc(at / 1000 - n));
}

/** Combine the measurement and the projection into one finding. Pure. */
export function verdict(state, waste) {
  if (state === 'not-cacheable' || state === 'inconclusive') {
    return [state, 'no rotation cost can be projected until the control request behaves'];
  }
  if (state === 'shared') return ['shared', 'rotation is not the problem here'];

  const share = waste.hourly_share ?? 0;
  const perRotation = waste.per_rotation ?? 0;
  const daily = waste.daily ?? 0;

  if (state === 'unproven' && !daily) {
    return ['clear',
      'nothing to project: no cached urls, or a credential that outlives the window'];
  }
  if (share >= 0.25) {
    return ['rotation-dominates',
      `${perRotation} full response(s) land in the seconds after every mint, ` +
      `which is ${Math.round(share * 100)}% of one hour's entire quota, ` +
      `${waste.rotations ?? 0} time(s) a day`];
  }
  if (daily) {
    return ['rotation-costs',
      `${perRotation} full response(s) per rotation, ${daily} a day, all of ` +
      'which a credential-keyed cache would have kept as 304s'];
  }
  return ['clear', 'the credential outlives the window, so no rotation cost falls inside it'];
}

/** A stable, non-reversible id for a credential, safe to use as a cache key. */
export function fingerprint(token) {
  return createHash('sha256').update(`gh:${token}`).digest('hex').slice(0, 12);
}

async function get(url, token, etag) {
  const headers = {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
  if (etag) headers['If-None-Match'] = etag;
  const res = await fetch(url, { headers });
  return {
    status: res.status,
    etag: res.headers.get('etag'),
    used: res.headers.get('x-ratelimit-used'),
  };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN (a read-only token is enough)');
    process.exitCode = 2;
    return;
  }
  const second = process.env.GITHUB_TOKEN_SECOND;
  const path = process.argv[2] ?? '/user';
  const urls = Number.parseInt(process.argv[3] ?? '40', 10) || 40;
  const interval = Number.parseInt(process.argv[4] ?? '30', 10) || 30;
  const ttl = Number.parseInt(process.argv[5] ?? String(INSTALLATION_TOKEN_TTL), 10)
    || INSTALLATION_TOKEN_TTL;
  const url = path.startsWith('/') ? API + path : path;

  const first = await get(url, token);
  if (first.status !== 200 || !first.etag) {
    console.error(`first GET ${url} returned ${first.status} with etag ` +
      `${first.etag}; pick a url that returns a validator`);
    process.exitCode = 2;
    return;
  }
  console.log(`cache key would be (${fingerprint(token)}, ${path})`);

  const control = await get(url, token, first.etag);
  console.log(`control: same credential, same etag -> ${control.status}`);

  let rotated = null;
  if (second) {
    rotated = (await get(url, second, first.etag)).status;
    console.log(`rotation: second credential, same etag -> ${rotated}`);
  } else {
    console.warn('set GITHUB_TOKEN_SECOND to a second credential to measure ' +
      'the rotation rather than project it');
  }

  const [state, detail] = classifyPair(control.status, rotated);
  console.log(`${state}: ${detail}`);

  const waste = rotationWaste(urls, interval, ttl);
  const [final, why] = verdict(state, waste);
  console.log(`${final}: ${why}`);

  if (final === 'rotation-dominates' || final === 'rotation-costs') {
    console.log('repair: key the cache by (credential fingerprint, url) so a ' +
      'rotation is an honest miss rather than a silent one.');
    console.log('repair: hold one installation token for its full hour and ' +
      'refresh a minute before expires_at, rather than minting a fresh one ' +
      'per request.');
  }

  console.log(JSON.stringify({
    measured: state, state: final, waste,
    used_before: first.used, used_control: control.used,
  }, null, 2));
  process.exitCode = (final === 'rotation-dominates' || final === 'rotation-costs' ||
    final === 'not-cacheable') ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail on the missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The whole point of the note is a combination that takes two live credentials and an expiring token to produce, so the classifier takes two status codes and nothing else. That makes 304-then-200 a one-line test, and it makes the near misses testable too: the control that answers 200 because a proxy stripped the header, and the second credential that answers 404 because it cannot see the repository at all, which is not a cache finding and must not be reported as one.

test_github_etag_credential_check.py
from datetime import datetime, timezone

from github_etag_credential_check import (
    classify_pair, fingerprint, rotation_waste, token_ttl, verdict)

NOON = datetime(2026, 8, 30, 12, 0, 0, tzinfo=timezone.utc).timestamp()


def test_a_304_that_becomes_a_200_under_another_credential_is_the_finding():
    state, detail = classify_pair(304, 200)
    assert state == "credential-scoped"
    assert "full price" in detail


def test_a_304_under_both_credentials_clears_rotation():
    assert classify_pair(304, 304)[0] == "shared"


def test_a_control_that_answers_200_is_not_a_rotation_result():
    state, detail = classify_pair(200, 200)
    assert state == "not-cacheable"
    assert "If-None-Match" in detail


def test_no_second_credential_is_unproven_rather_than_clear():
    state, detail = classify_pair(304, None)
    assert state == "unproven"
    assert "arithmetic, not a measurement" in detail


def test_a_second_credential_that_cannot_see_the_url_is_not_a_cache_finding():
    state, detail = classify_pair(304, 404)
    assert state == "inconclusive"
    assert "404" in detail


def test_a_control_that_did_not_complete_stops_the_analysis():
    assert classify_pair(None, 200)[0] == "inconclusive"
    assert classify_pair(500, 200)[0] == "inconclusive"


def test_an_hourly_token_rotates_twenty_four_times_a_day():
    waste = rotation_waste(40, 30, 3600)
    assert waste["rotations"] == 24
    assert waste["per_rotation"] == 40
    assert waste["daily"] == 960
    assert waste["polls"] == 115200


def test_a_credential_that_outlives_the_window_costs_nothing_inside_it():
    waste = rotation_waste(10, 60, 172800)
    assert waste["rotations"] == 0
    assert waste["daily"] == 0


def test_the_share_is_of_one_hours_quota_not_of_the_day():
    assert rotation_waste(2000, 60, 3600)["hourly_share"] == 0.4


def test_a_zero_interval_does_not_divide_by_zero():
    assert rotation_waste(5, 0, 0)["polls"] >= 0


def test_token_ttl_reads_the_z_suffix_github_actually_sends():
    assert token_ttl("2026-08-30T13:00:00Z", NOON) == 3600
    assert token_ttl("2026-08-30T13:00:00+00:00", NOON) == 3600


def test_an_expired_token_is_zero_and_an_unreadable_one_is_none():
    assert token_ttl("2026-08-30T11:00:00Z", NOON) == 0
    assert token_ttl("next tuesday", NOON) is None
    assert token_ttl(None, NOON) is None


def test_a_fleet_sized_cache_spends_a_quarter_of_an_hour_of_quota_per_mint():
    state, detail = verdict("credential-scoped", rotation_waste(2000, 60, 3600))
    assert state == "rotation-dominates"
    assert "40%" in detail


def test_a_small_cache_is_still_reported_as_a_cost():
    state, detail = verdict("credential-scoped", rotation_waste(40, 30, 3600))
    assert state == "rotation-costs"
    assert "960 a day" in detail


def test_nothing_is_projected_until_the_control_behaves():
    assert verdict("not-cacheable", rotation_waste(40, 30, 3600))[0] == "not-cacheable"
    assert verdict("shared", rotation_waste(40, 30, 3600))[0] == "shared"


def test_the_cache_key_is_a_digest_and_never_the_token():
    key = fingerprint("ghp_secretvalue")
    assert "ghp_secretvalue" not in key
    assert key == fingerprint("ghp_secretvalue")
    assert key != fingerprint("ghp_other")
github-etag-credential-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  classifyPair, fingerprint, rotationWaste, tokenTtl, verdict,
} from './github-etag-credential-check.mjs';

const NOON = Date.parse('2026-08-30T12:00:00Z') / 1000;

test('a 304 that becomes a 200 under another credential is the finding', () => {
  const [state, detail] = classifyPair(304, 200);
  assert.equal(state, 'credential-scoped');
  assert.match(detail, /full price/);
});

test('a 304 under both credentials clears rotation', () => {
  assert.equal(classifyPair(304, 304)[0], 'shared');
});

test('a control that answers 200 is not a rotation result', () => {
  const [state, detail] = classifyPair(200, 200);
  assert.equal(state, 'not-cacheable');
  assert.match(detail, /If-None-Match/);
});

test('no second credential is unproven rather than clear', () => {
  const [state, detail] = classifyPair(304, null);
  assert.equal(state, 'unproven');
  assert.match(detail, /arithmetic, not a measurement/);
});

test('a second credential that cannot see the url is not a cache finding', () => {
  const [state, detail] = classifyPair(304, 404);
  assert.equal(state, 'inconclusive');
  assert.match(detail, /404/);
});

test('a control that did not complete stops the analysis', () => {
  assert.equal(classifyPair(null, 200)[0], 'inconclusive');
  assert.equal(classifyPair(500, 200)[0], 'inconclusive');
});

test('an hourly token rotates twenty-four times a day', () => {
  const waste = rotationWaste(40, 30, 3600);
  assert.equal(waste.rotations, 24);
  assert.equal(waste.per_rotation, 40);
  assert.equal(waste.daily, 960);
  assert.equal(waste.polls, 115200);
});

test('a credential that outlives the window costs nothing inside it', () => {
  const waste = rotationWaste(10, 60, 172800);
  assert.equal(waste.rotations, 0);
  assert.equal(waste.daily, 0);
});

test('the share is of one hour of quota, not of the day', () => {
  assert.equal(rotationWaste(2000, 60, 3600).hourly_share, 0.4);
});

test('a zero interval does not divide by zero', () => {
  assert.ok(rotationWaste(5, 0, 0).polls >= 0);
});

test('tokenTtl reads the Z suffix GitHub actually sends', () => {
  assert.equal(tokenTtl('2026-08-30T13:00:00Z', NOON), 3600);
  assert.equal(tokenTtl('2026-08-30T13:00:00+00:00', NOON), 3600);
});

test('an expired token is zero and an unreadable one is null', () => {
  assert.equal(tokenTtl('2026-08-30T11:00:00Z', NOON), 0);
  assert.equal(tokenTtl('next tuesday', NOON), null);
  assert.equal(tokenTtl(null, NOON), null);
});

test('a fleet-sized cache spends a quarter of an hour of quota per mint', () => {
  const [state, detail] = verdict('credential-scoped', rotationWaste(2000, 60, 3600));
  assert.equal(state, 'rotation-dominates');
  assert.match(detail, /40%/);
});

test('a small cache is still reported as a cost', () => {
  const [state, detail] = verdict('credential-scoped', rotationWaste(40, 30, 3600));
  assert.equal(state, 'rotation-costs');
  assert.match(detail, /960 a day/);
});

test('nothing is projected until the control behaves', () => {
  assert.equal(verdict('not-cacheable', rotationWaste(40, 30, 3600))[0], 'not-cacheable');
  assert.equal(verdict('shared', rotationWaste(40, 30, 3600))[0], 'shared');
});

test('the cache key is a digest and never the token', () => {
  const key = fingerprint('ghp_secretvalue');
  assert.ok(!key.includes('ghp_secretvalue'));
  assert.equal(key, fingerprint('ghp_secretvalue'));
  assert.notEqual(key, fingerprint('ghp_other'));
});

FAQ

Why would an ETag depend on which token asked for it?

Because the ETag validates a representation, not a resource, and the representation depends on the caller. What a token is permitted to see feeds into the body GitHub builds, so the same URL can legitimately produce different bytes for two credentials. A validator computed for one of those bodies cannot be honoured for the other, and the server has no way to know your two tokens would have been owed identical content.

Does this affect personal access tokens as well as GitHub Apps?

Yes, but on a schedule you control rather than an hourly one. Rotating a PAT invalidates the cache in exactly the same way; the difference is that a PAT might be rotated quarterly while an App installation token expires every hour by design. That is why teams usually meet this failure during a migration from a PAT to an App: the code did not change, the credential lifetime did.

Should I stop rotating tokens to keep the cache warm?

No. Short-lived credentials are the point of GitHub Apps and a cache is not a reason to give that up. Reuse each installation token for the full hour it is valid, refreshing shortly before expires_at, and key the cache so a rotation registers as a miss. That gets you one cold minute an hour instead of a permanently cold cache, at no cost to the security property.

Can I use the token itself as part of the cache key?

Do not. Cache keys end up in logs, in dumps and in error messages, and a token in any of those places is a token you have to rotate. Use a digest of it, or the installation id combined with the token's expiry, which identifies the credential just as precisely and is safe to print. The script uses a truncated SHA-256 for exactly this reason.

How do I tell rotation apart from the resource actually changing?

By replaying one ETag under two credentials in quick succession, which is what the script does. A resource that changed answers 200 to both. A credential-scoped validator answers 304 to the credential that minted it and 200 to the other, in the same second, with nothing having changed in between. That is the difference a rate-limit graph cannot show you.

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.