Skip to content

Diagnostic GitHub API

the x-poll-interval header is ignored on events endpoints

The events consumer polls every five seconds because five seconds felt responsive. It gets the same page back seven hundred times an hour. On every one of those responses GitHub has been returning a header that says how long to wait before the next one, and the client has never read it.

Read-only token Python and Node.js Tests included
Text
Photo by Tamanna Rumee on Unsplash
The short answer

The events endpoints — /repos/{owner}/{repo}/events, /users/{user}/events, /orgs/{org}/events and the rest — return x-poll-interval on every response. It is the server telling you the minimum number of seconds to wait, usually 60, and it is the only place in the API where GitHub states a rate for you rather than leaving you to guess one.

The feed is cached and regenerated no faster than that interval, so polling underneath it returns the page you already have. With If-None-Match those extra calls come back 304 and cost no quota, which makes them merely pointless. Without it they are full 200s billed at full price for data that has not changed. Read the header on every response and use it as the sleep, because the value is not a constant — GitHub raises it under load.

The problem in plain words

The reason this survives review is that a faster poll looks like it is working. Events do arrive; they simply arrive no sooner than they would have. The client cannot tell the difference between a page that is fresh and a page that is a cached copy of the last one, so nobody discovers that eleven of every twelve requests were answered from the same cache entry.

It gets worse under exactly the conditions you would want it to get better. GitHub raises x-poll-interval when the service is under pressure, so a client that hardcoded 60 seconds keeps its rate constant while the server is asking everyone to slow down, and a client that hardcoded five seconds is now twenty-four times over a floor it never read.

Then the accounting misleads. If the client does send ETags, the wasted polls are free, so the quota graph stays flat and there is nothing to find. The waste is real — connections, wakeups, log lines, a scheduler that never idles — but it does not show up in the one place anyone looks for it.

x-poll-interval60on every response720 polls anhourclient sleeps 5sSame cachedpagefeed notregeneratedNoIf-None-Matcheach one billed infullFloor raisedunder loadclient speeds pastit
Events do arrive. They simply arrive no sooner than they would have at a twelfth of the cost.

Why it happens

The events feed is a cache, not a live stream. It is regenerated on its own schedule, and x-poll-interval is the period of that schedule. A request that arrives between regenerations cannot see anything the previous one did not, whatever it costs you.

The header is dynamic and per-response. It is not a documented constant to be pasted into a config file. Read it off each response and let it set the next sleep; that way a client that is asked to back off actually backs off, and one that could safely be quicker is.

A 304 does not count against the primary rate limit, but it is not free of everything. Conditional requests turn the extra polls from expensive into harmless, which is a real improvement and not the same as correct. The conditional-request note covers the saving itself; this one is about the fact that the server already told you how often to ask.

Polling slower than the floor is a different mistake with the opposite cost. A five-minute interval against a 60-second floor wastes no quota at all and adds up to four minutes of avoidable staleness. Both directions are worth reporting, and only one of them shows up on a bill.

Events are also capped and deduplicated. The feed holds a limited window of recent activity, so an interval far above the floor can miss events entirely on a busy repository rather than merely noticing them late. That is the case where the fix is a webhook rather than a better interval.

The fix, as a flow

One request, and the answer is in its headers. The script reports where the floor came from as well as what it is, because a number the server declared and a number the script assumed deserve different amounts of trust.

Configured intervalagainst the declared floorUnder the floor, no etagbillable duplicatesFar above the flooravoidable stalenessUnder it, with an etagfree, and cannot helpAt the floornothing to reclaim
Both directions are findings, and only one of them ever shows up on a quota graph.

How to fix it

Make one request and read the header

GET /repos/{owner}/{repo}/events and look at x-poll-interval. It is in seconds. That number, not the one in your config, is the fastest useful poll for this endpoint at this moment.

Check that an etag came back on the same response

The events endpoints return one. If your client is not sending it back as If-None-Match, every poll under the floor is a billable duplicate rather than a free one, which decides whether this is a quota problem or just a pointless one.

Compare your configured interval against the floor

Polls an hour is 3,600 divided by your interval; the allowance is 3,600 divided by the floor. The difference is the number of requests that cannot return anything new. Five seconds against a 60-second floor is 720 polls an hour where 60 would do.

Use the header as the sleep, on every cycle

Not a constant read once at startup: the value changes, and it goes up precisely when GitHub wants fewer requests. Store it per endpoint alongside the ETag and treat a missing header as the documented default rather than as permission to go faster.

If you need faster than the floor, stop polling events

The floor is not negotiable and no interval gets under it, so a requirement for sub-minute reaction is a requirement for a webhook. That is a different note, but it is the honest end of this one: the events API is a reconciliation feed, not a notification channel.

How to check it worked

Re-run the check with the interval taken from the header. The report should show the configured interval sitting at the floor, with no wasted polls in either direction.

python3 github_poll_interval_check.py --repo acme/api --interval 60
# at-floor: polling every 60s against a floor of 60s, nothing to reclaim

The full code

One GET, and everything after it is header arithmetic. floor_seconds() returns the source of the number as well as the number, because "the server said 60" and "nothing said anything so I assumed 60" are the same value with different confidence, and a report that conflates them will be trusted more than it should be.

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_poll_interval_check.py
"""Compare a configured poll interval against the floor GitHub declares.

Read only. One GET against an events endpoint, and the finding comes from its
response headers.

Events endpoints return x-poll-interval: the minimum seconds to wait before the
next poll. The feed is regenerated no faster than that, so a request underneath
it returns the page you already have.
"""
import argparse
import json
import logging
import os
import re
import sys

import requests

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

API = "https://api.github.com"
UA = "github-poll-interval-check/1.0"

# What the events endpoints have historically returned when nothing else says
# otherwise. Used only as a last resort, and labelled as an assumption.
DEFAULT_FLOOR = 60


def parse_max_age(value):
    """Seconds from a Cache-Control header, or None. Pure."""
    match = re.search(r"max-age\s*=\s*(\d+)", str(value or ""), re.I)
    if not match:
        return None
    try:
        seconds = int(match.group(1))
    except ValueError:
        return None
    return seconds if seconds > 0 else None


def floor_seconds(headers, default=DEFAULT_FLOOR):
    """The minimum poll interval the server declared. Pure.

    Returns (seconds, source). The source matters: "the server said 60" and
    "nothing said anything so I assumed 60" are the same number with very
    different confidence, and a report that prints only the number will be
    trusted more than it has earned.
    """
    lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
    raw = lowered.get("x-poll-interval")
    try:
        declared = int(str(raw).strip())
    except (TypeError, ValueError):
        declared = None
    if declared and declared > 0:
        return (declared, "x-poll-interval")

    age = parse_max_age(lowered.get("cache-control"))
    if age:
        return (age, "cache-control max-age")
    return (default, "documented default")


def assess(configured, floor, has_etag):
    """Compare the configured interval against the floor. Pure.

    Both directions are findings. Under the floor costs requests that cannot
    return anything new; over it costs freshness and nothing else, which is why
    only one of the two ever shows up on a quota graph.
    """
    try:
        configured = max(1, int(configured))
    except (TypeError, ValueError):
        configured = 1
    floor = max(1, int(floor or 1))

    polls = round(3600 / configured)
    allowed = round(3600 / floor)
    wasted = max(0, polls - allowed)

    if configured < floor:
        state = "under-floor"
    elif configured <= floor * 1.5:
        state = "at-floor"
    else:
        state = "over-floor"

    return {"state": state, "configured": configured, "floor": floor,
            "polls_per_hour": polls, "allowed_per_hour": allowed,
            "wasted_per_hour": wasted,
            "billable_per_hour": 0 if has_etag else wasted,
            "extra_staleness_s": max(0, configured - floor)}


def verdict(assessment):
    """Turn the comparison into a finding. Pure."""
    state = assessment.get("state")
    floor = assessment.get("floor", DEFAULT_FLOOR)
    configured = assessment.get("configured", floor)

    if state == "under-floor":
        if assessment.get("billable_per_hour"):
            return ("burning-quota",
                    "%d request(s) an hour beyond the %ds floor the server "
                    "declared, and every one of them is billable because no "
                    "etag is being sent. They return the page you already have."
                    % (assessment.get("billable_per_hour", 0), floor))
        return ("free-but-pointless",
                "%d conditional request(s) an hour beyond the %ds floor. They "
                "cost no quota, because an unchanged feed answers 304, but they "
                "cannot return anything new either: the feed is not regenerated "
                "faster than that." % (assessment.get("wasted_per_hour", 0), floor))
    if state == "over-floor":
        return ("slower-than-needed",
                "polling every %ds against a %ds floor adds up to %ds of "
                "avoidable staleness and saves nothing, because the requests "
                "you skipped would have been 304s."
                % (configured, floor, assessment.get("extra_staleness_s", 0)))
    return ("at-floor",
            "polling every %ds against a floor of %ds: nothing to reclaim in "
            "either direction." % (configured, floor))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", help="owner/name; polls that repository's events")
    ap.add_argument("--user", help="poll a user's events instead")
    ap.add_argument("--interval", type=int, default=5,
                    help="the interval your client is configured with, seconds")
    args = ap.parse_args()

    if not args.repo and not args.user:
        log.error("give --repo owner/name or --user login")
        return 2

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

    path = ("/repos/%s/events" % args.repo) if args.repo else ("/users/%s/events" % args.user)
    r = requests.get(API + path, timeout=30, headers={
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })
    if r.status_code != 200:
        log.error("GET %s returned %d", path, r.status_code)
        return 2

    headers = dict(r.headers)
    lowered = {k.lower(): v for k, v in headers.items()}
    floor, source = floor_seconds(headers)
    etag = lowered.get("etag")

    log.info("%s: floor %ds (from %s), etag %s, %d event(s) on this page",
             path, floor, source, "present" if etag else "absent",
             len(r.json() if r.content else []))
    if source != "x-poll-interval":
        log.warning("x-poll-interval was not on the response, so the floor "
                    "above is an assumption. Read it per response rather than "
                    "hardcoding one: the value goes up when GitHub is busy.")

    result = assess(args.interval, floor, bool(etag))
    state, detail = verdict(result)
    log.info("%s: %s", state, detail)

    if state != "at-floor":
        log.info("repair: sleep for the value of x-poll-interval on the last "
                 "response, re-reading it every cycle, and send the etag back "
                 "as If-None-Match so an unchanged page is free.")
    if state == "slower-than-needed":
        log.info("repair: the events feed holds only a window of recent "
                 "activity, so an interval far above the floor can miss events "
                 "outright rather than merely notice them late.")

    print(json.dumps({"path": path, "floor": floor, "floor_source": source,
                      "etag": bool(etag), "assessment": result,
                      "state": state}, indent=2))
    return 1 if state in ("burning-quota", "slower-than-needed") else 0


if __name__ == "__main__":
    sys.exit(main())
github-poll-interval-check.mjs
/**
 * Compare a configured poll interval against the floor GitHub declares.
 *
 * Read only. One GET against an events endpoint, and the finding comes from its
 * response headers.
 *
 * Events endpoints return x-poll-interval: the minimum seconds to wait. The
 * feed is regenerated no faster than that, so a request underneath it returns
 * the page you already have.
 */
const API = 'https://api.github.com';
const UA = 'github-poll-interval-check/1.0';

// What the events endpoints have historically returned when nothing else says
// otherwise. Used only as a last resort, and labelled as an assumption.
export const DEFAULT_FLOOR = 60;

/** Seconds from a Cache-Control header, or null. Pure. */
export function parseMaxAge(value) {
  const match = /max-age\s*=\s*(\d+)/i.exec(String(value ?? ''));
  if (!match) return null;
  const seconds = Number.parseInt(match[1], 10);
  return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}

/**
 * The minimum poll interval the server declared. Pure.
 * Returns [seconds, source]. The source matters: "the server said 60" and
 * "nothing said anything so I assumed 60" are the same number with very
 * different confidence.
 */
export function floorSeconds(headers, fallback = DEFAULT_FLOOR) {
  const lowered = {};
  for (const [k, v] of Object.entries(headers ?? {})) lowered[String(k).toLowerCase()] = v;

  const declared = Number.parseInt(String(lowered['x-poll-interval'] ?? '').trim(), 10);
  if (Number.isFinite(declared) && declared > 0) return [declared, 'x-poll-interval'];

  const age = parseMaxAge(lowered['cache-control']);
  if (age) return [age, 'cache-control max-age'];
  return [fallback, 'documented default'];
}

/**
 * Compare the configured interval against the floor. Pure.
 * Both directions are findings; only one of them shows up on a quota graph.
 */
export function assess(configured, floor, hasEtag) {
  const every = Math.max(1, Number.parseInt(configured, 10) || 1);
  const min = Math.max(1, Number.parseInt(floor, 10) || 1);

  const polls = Math.round(3600 / every);
  const allowed = Math.round(3600 / min);
  const wasted = Math.max(0, polls - allowed);

  let state = 'at-floor';
  if (every < min) state = 'under-floor';
  else if (every > min * 1.5) state = 'over-floor';

  return {
    state,
    configured: every,
    floor: min,
    polls_per_hour: polls,
    allowed_per_hour: allowed,
    wasted_per_hour: wasted,
    billable_per_hour: hasEtag ? 0 : wasted,
    extra_staleness_s: Math.max(0, every - min),
  };
}

/** Turn the comparison into a finding. Pure. */
export function verdict(assessment) {
  const floor = assessment.floor ?? DEFAULT_FLOOR;
  const configured = assessment.configured ?? floor;

  if (assessment.state === 'under-floor') {
    if (assessment.billable_per_hour) {
      return ['burning-quota',
        `${assessment.billable_per_hour} request(s) an hour beyond the ${floor}s ` +
        'floor the server declared, and every one of them is billable because ' +
        'no etag is being sent. They return the page you already have.'];
    }
    return ['free-but-pointless',
      `${assessment.wasted_per_hour} conditional request(s) an hour beyond the ` +
      `${floor}s floor. They cost no quota, because an unchanged feed answers ` +
      '304, but they cannot return anything new either: the feed is not ' +
      'regenerated faster than that.'];
  }
  if (assessment.state === 'over-floor') {
    return ['slower-than-needed',
      `polling every ${configured}s against a ${floor}s floor adds up to ` +
      `${assessment.extra_staleness_s}s of avoidable staleness and saves ` +
      'nothing, because the requests you skipped would have been 304s.'];
  }
  return ['at-floor',
    `polling every ${configured}s against a floor of ${floor}s: nothing to ` +
    'reclaim in either direction.'];
}

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 target = process.argv[2];
  if (!target) {
    console.error('usage: node github-poll-interval-check.mjs owner/name [interval]');
    process.exitCode = 2;
    return;
  }
  const interval = Number.parseInt(process.argv[3] ?? '5', 10) || 5;
  const path = target.includes('/') ? `/repos/${target}/events` : `/users/${target}/events`;

  const res = await fetch(API + path, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  if (res.status !== 200) {
    console.error(`GET ${path} returned ${res.status}`);
    process.exitCode = 2;
    return;
  }

  const headers = {};
  for (const [k, v] of res.headers.entries()) headers[k.toLowerCase()] = v;
  const [floor, source] = floorSeconds(headers);
  const etag = headers.etag;
  const page = await res.json().catch(() => []);

  console.log(`${path}: floor ${floor}s (from ${source}), etag ` +
    `${etag ? 'present' : 'absent'}, ${Array.isArray(page) ? page.length : 0} event(s) on this page`);
  if (source !== 'x-poll-interval') {
    console.warn('x-poll-interval was not on the response, so the floor above ' +
      'is an assumption. Read it per response rather than hardcoding one: the ' +
      'value goes up when GitHub is busy.');
  }

  const result = assess(interval, floor, Boolean(etag));
  const [state, detail] = verdict(result);
  console.log(`${state}: ${detail}`);

  if (state !== 'at-floor') {
    console.log('repair: sleep for the value of x-poll-interval on the last ' +
      'response, re-reading it every cycle, and send the etag back as ' +
      'If-None-Match so an unchanged page is free.');
  }
  if (state === 'slower-than-needed') {
    console.log('repair: the events feed holds only a window of recent ' +
      'activity, so an interval far above the floor can miss events outright ' +
      'rather than merely notice them late.');
  }

  console.log(JSON.stringify({
    path, floor, floor_source: source, etag: Boolean(etag),
    assessment: result, state,
  }, null, 2));
  process.exitCode = (state === 'burning-quota' || state === 'slower-than-needed') ? 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

Three things decide whether the report is honest. Header names arrive in whatever case the server felt like, so the lookup has to be case-insensitive. A missing x-poll-interval must fall through to a labelled assumption rather than to a confident number. And the same interval has to produce two different verdicts depending on whether an ETag is being sent, because that is the difference between wasteful and merely futile.

test_github_poll_interval_check.py
from github_poll_interval_check import assess, floor_seconds, parse_max_age, verdict


def test_the_declared_interval_wins_and_is_named_as_the_source():
    seconds, source = floor_seconds({"X-Poll-Interval": "60"})
    assert seconds == 60
    assert source == "x-poll-interval"


def test_header_case_does_not_matter():
    assert floor_seconds({"x-poll-interval": "90"})[0] == 90


def test_cache_control_is_the_fallback_before_the_assumption():
    seconds, source = floor_seconds({"Cache-Control": "public, max-age=45, s-maxage=60"})
    assert seconds == 45
    assert source == "cache-control max-age"


def test_a_missing_header_is_labelled_as_an_assumption():
    seconds, source = floor_seconds({})
    assert seconds == 60
    assert source == "documented default"


def test_junk_and_zero_values_do_not_become_the_floor():
    assert floor_seconds({"x-poll-interval": "soon"})[1] == "documented default"
    assert floor_seconds({"x-poll-interval": "0"})[1] == "documented default"
    assert parse_max_age("max-age=0") is None
    assert parse_max_age(None) is None


def test_polling_under_the_floor_counts_the_requests_that_cannot_help():
    result = assess(5, 60, has_etag=False)
    assert result["state"] == "under-floor"
    assert result["polls_per_hour"] == 720
    assert result["allowed_per_hour"] == 60
    assert result["wasted_per_hour"] == 660
    assert result["billable_per_hour"] == 660


def test_an_etag_makes_the_same_extra_polls_free():
    result = assess(5, 60, has_etag=True)
    assert result["wasted_per_hour"] == 660
    assert result["billable_per_hour"] == 0


def test_the_floor_itself_is_at_the_floor():
    assert assess(60, 60, has_etag=True)["state"] == "at-floor"
    assert assess(75, 60, has_etag=True)["state"] == "at-floor"


def test_polling_far_slower_is_measured_in_staleness_not_requests():
    result = assess(600, 60, has_etag=True)
    assert result["state"] == "over-floor"
    assert result["wasted_per_hour"] == 0
    assert result["extra_staleness_s"] == 540


def test_a_zero_interval_is_clamped_rather_than_dividing_by_zero():
    assert assess(0, 60, has_etag=True)["polls_per_hour"] == 3600


def test_extra_polls_without_an_etag_are_a_quota_finding():
    state, detail = verdict(assess(5, 60, has_etag=False))
    assert state == "burning-quota"
    assert "660 request(s)" in detail


def test_extra_polls_with_an_etag_are_pointless_rather_than_expensive():
    state, detail = verdict(assess(5, 60, has_etag=True))
    assert state == "free-but-pointless"
    assert "cost no quota" in detail


def test_too_slow_is_reported_as_staleness():
    state, detail = verdict(assess(600, 60, has_etag=True))
    assert state == "slower-than-needed"
    assert "540s" in detail


def test_matching_the_floor_has_nothing_to_reclaim():
    state, detail = verdict(assess(60, 60, has_etag=True))
    assert state == "at-floor"
    assert "either direction" in detail
github-poll-interval-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  assess, floorSeconds, parseMaxAge, verdict,
} from './github-poll-interval-check.mjs';

test('the declared interval wins and is named as the source', () => {
  const [seconds, source] = floorSeconds({ 'X-Poll-Interval': '60' });
  assert.equal(seconds, 60);
  assert.equal(source, 'x-poll-interval');
});

test('header case does not matter', () => {
  assert.equal(floorSeconds({ 'x-poll-interval': '90' })[0], 90);
});

test('cache-control is the fallback before the assumption', () => {
  const [seconds, source] = floorSeconds({ 'Cache-Control': 'public, max-age=45, s-maxage=60' });
  assert.equal(seconds, 45);
  assert.equal(source, 'cache-control max-age');
});

test('a missing header is labelled as an assumption', () => {
  const [seconds, source] = floorSeconds({});
  assert.equal(seconds, 60);
  assert.equal(source, 'documented default');
});

test('junk and zero values do not become the floor', () => {
  assert.equal(floorSeconds({ 'x-poll-interval': 'soon' })[1], 'documented default');
  assert.equal(floorSeconds({ 'x-poll-interval': '0' })[1], 'documented default');
  assert.equal(parseMaxAge('max-age=0'), null);
  assert.equal(parseMaxAge(null), null);
});

test('polling under the floor counts the requests that cannot help', () => {
  const result = assess(5, 60, false);
  assert.equal(result.state, 'under-floor');
  assert.equal(result.polls_per_hour, 720);
  assert.equal(result.allowed_per_hour, 60);
  assert.equal(result.wasted_per_hour, 660);
  assert.equal(result.billable_per_hour, 660);
});

test('an etag makes the same extra polls free', () => {
  const result = assess(5, 60, true);
  assert.equal(result.wasted_per_hour, 660);
  assert.equal(result.billable_per_hour, 0);
});

test('the floor itself is at the floor', () => {
  assert.equal(assess(60, 60, true).state, 'at-floor');
  assert.equal(assess(75, 60, true).state, 'at-floor');
});

test('polling far slower is measured in staleness, not requests', () => {
  const result = assess(600, 60, true);
  assert.equal(result.state, 'over-floor');
  assert.equal(result.wasted_per_hour, 0);
  assert.equal(result.extra_staleness_s, 540);
});

test('a zero interval is clamped rather than dividing by zero', () => {
  assert.equal(assess(0, 60, true).polls_per_hour, 3600);
});

test('extra polls without an etag are a quota finding', () => {
  const [state, detail] = verdict(assess(5, 60, false));
  assert.equal(state, 'burning-quota');
  assert.match(detail, /660 request\(s\)/);
});

test('extra polls with an etag are pointless rather than expensive', () => {
  const [state, detail] = verdict(assess(5, 60, true));
  assert.equal(state, 'free-but-pointless');
  assert.match(detail, /cost no quota/);
});

test('too slow is reported as staleness', () => {
  const [state, detail] = verdict(assess(600, 60, true));
  assert.equal(state, 'slower-than-needed');
  assert.match(detail, /540s/);
});

test('matching the floor has nothing to reclaim', () => {
  const [state, detail] = verdict(assess(60, 60, true));
  assert.equal(state, 'at-floor');
  assert.match(detail, /either direction/);
});

FAQ

What value does x-poll-interval usually have?

Sixty seconds is the common answer for the events endpoints, but treating that as a constant is the mistake the note is about. GitHub raises the value when the service is under load, which means the moment it matters most is the moment a hardcoded interval is most wrong. Read it off each response and use it for the next sleep.

If my extra polls all come back 304, is there anything left to fix?

The quota cost is gone, which is the expensive part, and what remains is real but small: connections, wakeups, log volume and a process that never idles. The stronger argument is that those requests cannot return anything new, because the feed is not regenerated faster than the floor. Aligning to the header costs nothing and removes a whole class of confusing behaviour, like a consumer that appears to poll twelve times faster than it reacts.

Does the events feed return everything that happened?

No, and this is the trap at the slow end. The feed holds a bounded window of recent activity and does not replay beyond it, so an interval far above the floor can miss events outright rather than notice them late. If you need completeness rather than a recent sample, a webhook with a reconciliation pass is the right shape, not a longer poll.

Do the events endpoints support conditional requests?

Yes, and you should use them alongside the interval, not instead of it. They answer with an etag, an unchanged page comes back 304, and a 304 does not count against the primary rate limit. The two mechanisms solve different halves: the ETag makes a repeat request cheap, and x-poll-interval tells you not to make it at all.

Is x-poll-interval only on the events endpoints?

That is where it is documented and where it is reliably present. Other endpoints may not send it, which is why the script labels its fallback as an assumption rather than quietly presenting 60 as fact. If the header is absent, the honest position is that GitHub has not stated a floor for that endpoint and you should be conservative rather than confident.

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.