Skip to content

Diagnostic Slack

A Tier 1 method polled as if it were cheap

One call throttles. Not the app — one method, over and over, while everything around it is perfectly healthy. Adding backoff makes the loop slower and does not make it work, because the loop is asking for more than the method has ever been allowed to give.

Slack's limits are per method. There are four tiers and the bottom one allows about one request a minute, which is a number nobody discovers by reading and everybody discovers by polling.

Read-only token Python and Node.js Tests included
Red and white love heart illustration
Photo by Tamanna Rumee on Unsplash
The short answer

Slack assigns each Web API method to a tier: Tier 1 at 1+ request per minute, Tier 2 at 20+, Tier 3 at 50+, Tier 4 at 100+, plus a Special tier for methods whose limit does not fit that shape. Tier 1 is where the expensive, rarely-needed operations live, and it has almost no burst allowance. A cron that runs every thirty seconds against a Tier 1 method is asking for double its entire budget, forever.

The finding is arithmetic and it does not need the API: sixty divided by your polling interval, against the tier printed on the method's reference page. The script below carries a small seeded table of tiers it is confident about, takes the ones it does not know from a schedule you write down, and refuses to guess for the rest. For each offender it prints the substitute — the event to subscribe to, or the cache with the long TTL — because a method on Tier 1 is Slack saying this is not something you poll.

The one thing it will not do is find the tier by calling until something breaks.

The problem in plain words

The tier is documented, on the method's own reference page, in a line most people never read because they arrived at the page looking for the parameters. So the tier gets discovered at runtime instead, and it gets discovered in the most confusing possible way: as a partial failure. Because limits are per method, a Tier 1 method inside an otherwise well-behaved app produces one endpoint that throttles constantly while every other call in the same process succeeds. That does not look like a rate limit. It looks like a broken endpoint.

The response is usually backoff, and backoff is the wrong shape of fix. Backoff makes a temporary overshoot recover; it does not make a permanent one go away. A loop that wants 2 requests a minute from a budget of 1 will spend half its life sleeping no matter how elegantly the sleeping is arranged, and the job's real throughput is fixed at the tier whatever the client does.

What makes it worse is that the polling interval is almost never a considered decision. It is thirty seconds because thirty seconds felt responsive, or because the scheduler's smallest useful unit is a minute and somebody halved it. Nobody chose it against a budget, because nobody knew there was one. So the audit is not really about rate limits at all: it is about writing down every interval in the system next to the number it is spending, which is a thing most integrations have never had on one page.

Interval pickedthirty feltresponsiveTier never readit is on themethod pageOne callthrottleseverything else isfineLooks like abad endpointnot like a limitBackoff addedquieter, notfaster
Backoff recovers a temporary overshoot. A loop asking for twice the budget is overshooting permanently, so the throughput is the tier whatever the client does; the sleeping only makes it quieter.

Why it happens

The tier is a design statement, not a speed bump. Slack put a method on Tier 1 because it is expensive to compute across a workspace. The tier is telling you the method was never intended to be the data path for anything that refreshes. Backing off politely still misses the point.

Per-method limits produce partial failures, which mislead. Everything else works, so the investigation starts at the endpoint and not at the app's traffic. The first useful question is not "why is this call failing" but "what tier is this call on, and how often do we make it".

The arithmetic is offline and the API cannot improve on it. Sixty over the interval, against the documented tier. There is no method that reports your remaining budget — rate-limit posture is inferred from headers on live calls and never queried — so calling more is not a way to learn more.

A tier this audit does not know is reported as unknown. The seeded table is small on purpose and the tiers do change. An unknown method gets a link to the reference page and a request for the number, rather than a plausible guess that would be quoted back in a design review a year from now.

The repair is a different architecture, and it is usually smaller than it sounds. Most Tier 1 and Tier 2 polling exists to notice a change. Slack emits an event for nearly every change worth noticing, and a cached answer refreshed by an event is both cheaper and fresher than a poll. The substitution table in the script is the actual deliverable.

The fix, as a flow

The problem chain is a number nobody chose meeting a number nobody read, and the interesting part is that everything else in the app stays healthy, so the investigation starts at the endpoint instead of at the traffic. The fix branch is a division, and it keeps not knowing the tier as a row of its own rather than folding it into a guess.

Sixty over the intervalagainst the tier floorTier 1 in a loopno sleep makes this fitPast the floorthroughput is the floorTier not known herego read the method pageSpecial tierthe table does not applySubscribed insteadan event, not a timer
The floor is what you may design against and the plus is headroom you are not entitled to. A schedule that only works on the burst allowance fails the first time the app has a busy minute for any other reason.

How to fix it

Write the schedule down before you look at any code

Every place the app calls Slack on a timer: the method, the interval, and how many calls one run makes. Cron entries, scheduler jobs, the loop in the worker, the health check. This is the artifact most integrations have never produced, and half the findings appear while writing it.

Put the documented tier next to each method

The tier is on the method's reference page. The script seeds the ones it is confident about and takes the rest from your schedule file. Where it does not know, it says so and asks for the number rather than inventing one.

Do the division

Sixty divided by the interval, times the calls per run, against the tier's floor. A method on Tier 1 polled every thirty seconds is at two hundred percent of its budget. A method on Tier 4 polled every second is at sixty percent of its. The same interval is fine in one place and impossible in the other, which is the whole reason the tier has to be in the table.

Do not probe to find out

The script will call a method once, if you ask it to and if the method is on its allowlist of reads, purely to confirm the token can reach it. It will not call a method it cannot prove is a read, and it will never loop. Finding a limit by exhausting it spends a real workspace's window on a demonstration, and every other app on that method shares the bill.

Replace the worst offender with an event

Each entry in the table comes with its substitute. Polling users.list to notice a profile change is what user_change exists for. Polling conversations.list to notice a new channel is channel_created. The poll becomes a cold-start backfill and a nightly reconciliation, which together cost less in a day than the loop cost in a minute.

Keep the schedule file in the repository

It is a small JSON file and it is the only place the system's total demand on Slack is written down. Re-run the audit in CI when it changes, and a new poll added at a plausible-looking interval fails the build instead of failing at three in the morning.

How to check it worked

Re-run after the interval changes and the substitutions land. Every line should be within budget, and the tiers the audit does not know should have been filled in.

python3 slack_method_tier_budget.py --schedule slack_schedule.json
# identity   U0APPBOT11 in acme
# conversations.list     within-budget   2.0/min against a tier 2 floor of 20/min
# users.list             within-budget   0.5/min against a tier 2 floor of 20/min
# conversations.members  within-budget   12.0/min against a tier 4 floor of 100/min
# 3 entry(s) checked, 0 over budget, 0 with an unknown tier

The full code

Three pure functions, one small table and one GET method. tier_of is deliberately incomplete and says so: it returns a source alongside the tier, and unknown is a first-class answer rather than a fallback to something plausible. budget_verdict is the division. substitute is the part worth keeping — the event or the cache that replaces each poll — because on Tier 1 the repair is never a shorter sleep.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_method_tier_budget.py
"""Check a Slack app's polling schedule against the documented method tiers.

Read only, and almost entirely offline. Slack limits per method, per workspace,
per app; the tier is printed on each method's reference page; and the finding is
sixty divided by your polling interval against that number. No read method
reports remaining budget, so calling more is not a way to learn more, and this
audit will not find a limit by exhausting one.
"""
import argparse
import json
import logging
import os
import sys

import requests

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

API = "https://slack.com/api/"

DOCS = "https://docs.slack.dev/apis/web-api/rate-limits"

# The documented floor for each tier, in requests per minute. Slack writes them
# as "1+", "20+", "50+", "100+": the floor is what you may design against, and
# the plus is headroom you are not entitled to.
TIER_FLOOR = {1: 1, 2: 20, 3: 50, 4: 100}

# Seeded from the method reference pages, and deliberately not exhaustive. Tiers
# move, new methods arrive, and a table that guesses is worse than a table that
# admits its edges: an invented number here would be quoted back in a design
# review a year from now with this file as the source.
KNOWN_TIERS = {
    "conversations.list": 2,
    "conversations.history": 3,
    "conversations.replies": 3,
    "conversations.info": 3,
    "conversations.members": 4,
    "users.list": 2,
    "users.info": 4,
    "users.conversations": 3,
    "users.lookupByEmail": 3,
    "team.info": 3,
}

# Methods whose limit does not fit the four-number table at all. Naming them
# matters because a reader who cannot find one in the tier list assumes the list
# is incomplete rather than that the method is elsewhere.
SPECIAL = {
    "chat.postMessage": "roughly one message per second per channel, with a "
                        "separate workspace-wide ceiling",
    "chat.update": "the same envelope as chat.postMessage, which is why editing "
                   "one message per token does not stream",
}

# What replaces the poll. This is the actual deliverable: a method on a low tier
# is Slack saying the answer is not something you ask for repeatedly.
SUBSTITUTES = {
    "users.list": "subscribe to user_change and keep your own directory; a full "
                  "users.list becomes a cold-start backfill and a nightly "
                  "reconciliation",
    "users.info": "cache per user with a long TTL and invalidate on user_change "
                  "rather than re-reading on every mention",
    "conversations.list": "subscribe to channel_created, channel_rename and "
                          "channel_archive; the inventory changes a few times a "
                          "week and you are asking twice a minute",
    "conversations.members": "subscribe to member_joined_channel and "
                             "member_left_channel and maintain the roster",
    "conversations.history": "subscribe to message.channels and message.groups; "
                             "history then becomes a rare backfill instead of the "
                             "primary data path",
    "team.info": "call it once at boot. A workspace name and icon change about "
                 "never, and nothing downstream needs them fresher than a deploy",
}

# The audit will probe a method only if it is on this list, and only once. A
# schedule file is written by a human and can name anything; calling an arbitrary
# method name because it appeared in a config file is how a read-only tool stops
# being one.
ALLOWED_PROBE = {
    "conversations.list": {"limit": 1, "exclude_archived": "true"},
    "users.list": {"limit": 1},
    "team.info": {},
}

RANK = {"polled-tier-1": 0, "over-budget": 1, "at-the-edge": 2,
        "tier-unknown": 3, "special-tier": 4, "no-interval": 5,
        "within-budget": 6}


def tier_of(method, table=None):
    """The documented tier for a method. Pure. Returns (tier, source).

    source is one of table, special, unknown-admin, unknown, no-method. Only
    "table" carries a number, and that is the point: unknown is an answer here,
    not a fallback to something plausible.
    """
    name = str(method or "").strip()
    table = KNOWN_TIERS if table is None else table
    if not name:
        return (None, "no-method")
    if name in SPECIAL:
        return (None, "special")
    if name in table:
        return (table[name], "table")
    if name.split(".")[0] == "admin":
        return (None, "unknown-admin")
    return (None, "unknown")


def budget_verdict(entry, table=None):
    """One scheduled call against its tier. Pure. Returns (verdict, rate, detail).

    entry: {"method", "interval_seconds", "calls_per_run"?, "tier"?}. A tier in
    the entry wins, because it came from the reference page and this table did
    not.
    """
    entry = entry or {}
    method = str(entry.get("method") or "").strip() or "?"
    try:
        interval = float(entry.get("interval_seconds") or 0)
    except (TypeError, ValueError):
        interval = 0.0
    try:
        per_run = max(1, int(entry.get("calls_per_run") or 1))
    except (TypeError, ValueError):
        per_run = 1

    if interval <= 0:
        return ("no-interval", 0.0,
                "%s has no polling interval, so there is nothing to divide. If it "
                "is called once at boot that is the answer this audit wants to "
                "hear; write it down as such." % method)

    rate = 60.0 / interval * per_run
    tier = entry.get("tier")
    if tier is None:
        tier, source = tier_of(method, table)
    else:
        source = "supplied"
    try:
        tier = int(tier) if tier is not None else None
    except (TypeError, ValueError):
        tier = None

    if tier not in TIER_FLOOR:
        if source == "special":
            return ("special-tier", rate,
                    "%s is on the Special tier: %s. The four-number table does "
                    "not apply and %.1f calls a minute is the wrong unit for it."
                    % (method, SPECIAL.get(method, "a limit of its own"), rate))
        hint = ("the admin family is where the expensive workspace-wide "
                "operations live and is the likeliest place to meet Tier 1"
                if source == "unknown-admin" else
                "this audit does not carry a tier for it")
        return ("tier-unknown", rate,
                "%s is polled at %.1f call(s) a minute and %s. Read the tier off "
                "the method's reference page and put it in the schedule; see %s."
                % (method, rate, hint, DOCS))

    floor = TIER_FLOOR[tier]
    share = rate / floor
    if tier == 1 and rate > floor:
        return ("polled-tier-1", rate,
                "%s is on Tier 1, which is about %d request a minute, and this "
                "schedule asks for %.1f. That is %.0f%% of the entire budget for "
                "the method. Tier 1 is Slack saying this is not something you "
                "poll, and no backoff makes a permanent overshoot temporary."
                % (method, floor, rate, share * 100))
    if share > 1.0:
        return ("over-budget", rate,
                "%s is on Tier %d, a floor of %d a minute, and this schedule asks "
                "for %.1f. Every run past the floor is refused, so the job's real "
                "throughput is %d a minute however the client sleeps."
                % (method, tier, floor, rate, floor))
    if share > 0.8:
        return ("at-the-edge", rate,
                "%s is at %.1f a minute against a Tier %d floor of %d. It works "
                "today and it shares the window with everything else in this app "
                "that calls the same method." % (method, rate, tier, floor))
    return ("within-budget", rate,
            "%.1f/min against a tier %d floor of %d/min" % (rate, tier, floor))


def substitute(method):
    """What to do instead of polling this. Pure. Returns text or None."""
    return SUBSTITUTES.get(str(method or "").strip())


def probe_once(session, method):
    """One call, to confirm the token can reach the method. GET only.

    Refuses anything not on the allowlist. A schedule file is written by a human
    and can name any method at all; calling whatever appeared in a config file
    is how a read-only tool quietly stops being one.
    """
    params = ALLOWED_PROBE.get(method)
    if params is None:
        return (None, "not probed: %s is not on this audit's list of methods it "
                      "can prove are reads" % method)
    body = session.get(API + method, params=params, timeout=30).json()
    if body.get("ok") is True:
        return (True, "reachable, one call spent")
    return (False, "answered ok: false, error=%s" % body.get("error"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--schedule", required=True,
                    help="JSON list of {method, interval_seconds, calls_per_run?, tier?}")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--probe", action="store_true",
                    help="make one call per allowlisted method to confirm reachability")
    args = ap.parse_args()

    entries = json.loads(open(args.schedule, encoding="utf-8").read())

    session = None
    if args.probe:
        token = os.environ.get(args.token_env)
        if not token:
            log.error("set %s, or drop --probe: the arithmetic is offline",
                      args.token_env)
            return 2
        session = requests.Session()
        session.headers.update({"Authorization": "Bearer " + token})
        identity = session.get(API + "auth.test", timeout=30).json()
        if identity.get("ok") is not True:
            log.error("auth.test answered ok: false, error=%s", identity.get("error"))
            return 2
        log.info("identity   %s in %s", identity.get("user_id"), identity.get("team"))

    judged = [(budget_verdict(e), e) for e in entries]
    judged.sort(key=lambda pair: RANK.get(pair[0][0], 9))

    over = unknown = 0
    for (verdict, rate, detail), entry in judged:
        method = str(entry.get("method") or "?")
        if verdict == "within-budget":
            log.info("%-22s %-15s %s", method, verdict, detail)
            continue
        if verdict == "tier-unknown":
            unknown += 1
            log.warning("%-22s %-15s %s", method, verdict, detail)
        elif verdict in ("no-interval", "special-tier"):
            log.info("%-22s %-15s %s", method, verdict, detail)
        else:
            over += 1
            log.warning("%-22s %-15s %s", method, verdict, detail)
            instead = substitute(method)
            if instead:
                log.warning("  repair: %s", instead)
            else:
                log.warning("  repair: move it out of the loop. Call it once at "
                            "boot, cache the answer with a long TTL, and refresh "
                            "it from an event rather than from a timer")

        if args.probe and session is not None:
            ok, note = probe_once(session, method)
            log.info("  probe: %s", note if ok is not False else "unreachable, " + note)

    log.info("%d entry(s) checked, %d over budget, %d with an unknown tier",
             len(entries), over, unknown)
    return 1 if (over or unknown) else 0


if __name__ == "__main__":
    sys.exit(main())
slack-method-tier-budget.mjs
/**
 * Check a Slack app's polling schedule against the documented method tiers.
 *
 * Read only, and almost entirely offline. Slack limits per method, per
 * workspace, per app; the tier is printed on each method's reference page; and
 * the finding is sixty divided by your polling interval against that number. No
 * read method reports remaining budget, so calling more is not a way to learn
 * more, and this audit will not find a limit by exhausting one.
 */

import { readFile } from 'node:fs/promises';

const API = 'https://slack.com/api/';
const DOCS = 'https://docs.slack.dev/apis/web-api/rate-limits';

// Slack writes the tiers as "1+", "20+", "50+", "100+": the floor is what you
// may design against, and the plus is headroom you are not entitled to.
const TIER_FLOOR = new Map([[1, 1], [2, 20], [3, 50], [4, 100]]);

// Seeded from the method reference pages, and deliberately not exhaustive. An
// invented number here would be quoted back in a design review a year from now
// with this file as the source.
const KNOWN_TIERS = new Map([
  ['conversations.list', 2],
  ['conversations.history', 3],
  ['conversations.replies', 3],
  ['conversations.info', 3],
  ['conversations.members', 4],
  ['users.list', 2],
  ['users.info', 4],
  ['users.conversations', 3],
  ['users.lookupByEmail', 3],
  ['team.info', 3],
]);

const SPECIAL = new Map([
  ['chat.postMessage', 'roughly one message per second per channel, with a ' +
    'separate workspace-wide ceiling'],
  ['chat.update', 'the same envelope as chat.postMessage, which is why editing ' +
    'one message per token does not stream'],
]);

// What replaces the poll. This is the actual deliverable.
const SUBSTITUTES = new Map([
  ['users.list', 'subscribe to user_change and keep your own directory; a full ' +
    'users.list becomes a cold-start backfill and a nightly reconciliation'],
  ['users.info', 'cache per user with a long TTL and invalidate on user_change ' +
    'rather than re-reading on every mention'],
  ['conversations.list', 'subscribe to channel_created, channel_rename and ' +
    'channel_archive; the inventory changes a few times a week and you are ' +
    'asking twice a minute'],
  ['conversations.members', 'subscribe to member_joined_channel and ' +
    'member_left_channel and maintain the roster'],
  ['conversations.history', 'subscribe to message.channels and message.groups; ' +
    'history then becomes a rare backfill instead of the primary data path'],
  ['team.info', 'call it once at boot. A workspace name and icon change about ' +
    'never, and nothing downstream needs them fresher than a deploy'],
]);

// The audit will probe a method only if it is on this list, and only once.
const ALLOWED_PROBE = new Map([
  ['conversations.list', { limit: '1', exclude_archived: 'true' }],
  ['users.list', { limit: '1' }],
  ['team.info', {}],
]);

const RANK = new Map([
  ['polled-tier-1', 0], ['over-budget', 1], ['at-the-edge', 2],
  ['tier-unknown', 3], ['special-tier', 4], ['no-interval', 5],
  ['within-budget', 6],
]);

/**
 * The documented tier for a method. Pure. Returns [tier, source].
 * Only "table" carries a number: unknown is an answer here, not a fallback to
 * something plausible.
 */
export function tierOf(method, table = KNOWN_TIERS) {
  const name = String(method ?? '').trim();
  if (!name) return [null, 'no-method'];
  if (SPECIAL.has(name)) return [null, 'special'];
  if (table.has(name)) return [table.get(name), 'table'];
  if (name.split('.')[0] === 'admin') return [null, 'unknown-admin'];
  return [null, 'unknown'];
}

/**
 * One scheduled call against its tier. Pure. Returns [verdict, rate, detail].
 * A tier in the entry wins, because it came from the reference page.
 */
export function budgetVerdict(entry, table = KNOWN_TIERS) {
  const doc = entry ?? {};
  const method = String(doc.method ?? '').trim() || '?';
  const interval = Number(doc.interval_seconds ?? 0);
  const perRun = Math.max(1, Math.trunc(Number(doc.calls_per_run ?? 1) || 1));

  if (!Number.isFinite(interval) || interval <= 0) {
    return ['no-interval', 0,
      `${method} has no polling interval, so there is nothing to divide. If it ` +
      'is called once at boot that is the answer this audit wants to hear; ' +
      'write it down as such.'];
  }

  const rate = (60 / interval) * perRun;
  let tier = doc.tier ?? null;
  let source = 'supplied';
  if (tier === null || tier === undefined) [tier, source] = tierOf(method, table);
  tier = Number.isFinite(Number(tier)) ? Math.trunc(Number(tier)) : null;

  if (!TIER_FLOOR.has(tier)) {
    if (source === 'special') {
      const note = SPECIAL.get(method) ?? 'a limit of its own';
      return ['special-tier', rate,
        `${method} is on the Special tier: ${note}. The four-number table does ` +
        `not apply and ${rate.toFixed(1)} calls a minute is the wrong unit for it.`];
    }
    const hint = source === 'unknown-admin'
      ? 'the admin family is where the expensive workspace-wide operations live ' +
        'and is the likeliest place to meet Tier 1'
      : 'this audit does not carry a tier for it';
    return ['tier-unknown', rate,
      `${method} is polled at ${rate.toFixed(1)} call(s) a minute and ${hint}. ` +
      `Read the tier off the method's reference page and put it in the ` +
      `schedule; see ${DOCS}.`];
  }

  const floor = TIER_FLOOR.get(tier);
  const share = rate / floor;
  if (tier === 1 && rate > floor) {
    return ['polled-tier-1', rate,
      `${method} is on Tier 1, which is about ${floor} request a minute, and ` +
      `this schedule asks for ${rate.toFixed(1)}. That is ` +
      `${(share * 100).toFixed(0)}% of the entire budget for the method. Tier 1 ` +
      'is Slack saying this is not something you poll, and no backoff makes a ' +
      'permanent overshoot temporary.'];
  }
  if (share > 1.0) {
    return ['over-budget', rate,
      `${method} is on Tier ${tier}, a floor of ${floor} a minute, and this ` +
      `schedule asks for ${rate.toFixed(1)}. Every run past the floor is ` +
      `refused, so the job's real throughput is ${floor} a minute however the ` +
      'client sleeps.'];
  }
  if (share > 0.8) {
    return ['at-the-edge', rate,
      `${method} is at ${rate.toFixed(1)} a minute against a Tier ${tier} floor ` +
      `of ${floor}. It works today and it shares the window with everything else ` +
      'in this app that calls the same method.'];
  }
  return ['within-budget', rate,
    `${rate.toFixed(1)}/min against a tier ${tier} floor of ${floor}/min`];
}

/** What to do instead of polling this. Pure. Returns text or null. */
export function substitute(method) {
  return SUBSTITUTES.get(String(method ?? '').trim()) ?? null;
}

/** One call, to confirm the token can reach the method. GET only. */
async function probeOnce(token, method) {
  const params = ALLOWED_PROBE.get(method);
  if (params === undefined) {
    return [null, `not probed: ${method} is not on this audit's list of methods ` +
      'it can prove are reads'];
  }
  const qs = new URLSearchParams(params);
  const res = await fetch(`${API}${method}?${qs}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const body = await res.json();
  if (body.ok === true) return [true, 'reachable, one call spent'];
  return [false, `answered ok: false, error=${body.error}`];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function main() {
  const args = process.argv.slice(2);
  const schedule = arg(args, '--schedule', '');
  if (!schedule) {
    console.error('usage: --schedule slack_schedule.json [--probe] ' +
      '[--token-env SLACK_BOT_TOKEN]');
    process.exitCode = 2;
    return;
  }
  const entries = JSON.parse(await readFile(schedule, 'utf8'));

  let token = null;
  if (args.includes('--probe')) {
    const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
    token = process.env[tokenEnv];
    if (!token) {
      console.error(`set ${tokenEnv}, or drop --probe: the arithmetic is offline`);
      process.exitCode = 2;
      return;
    }
    const res = await fetch(`${API}auth.test`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const identity = await res.json();
    if (identity.ok !== true) {
      console.error(`auth.test answered ok: false, error=${identity.error}`);
      process.exitCode = 2;
      return;
    }
    console.log(`identity   ${identity.user_id} in ${identity.team}`);
  }

  const judged = entries.map((e) => [budgetVerdict(e), e]);
  judged.sort((a, b) => (RANK.get(a[0][0]) ?? 9) - (RANK.get(b[0][0]) ?? 9));

  let over = 0;
  let unknown = 0;
  for (const [[verdict, , detail], entry] of judged) {
    const method = String(entry.method ?? '?');
    if (verdict === 'within-budget') {
      console.log(`${method.padEnd(22)} ${verdict.padEnd(15)} ${detail}`);
      continue;
    }
    if (verdict === 'tier-unknown') {
      unknown += 1;
      console.warn(`${method.padEnd(22)} ${verdict.padEnd(15)} ${detail}`);
    } else if (verdict === 'no-interval' || verdict === 'special-tier') {
      console.log(`${method.padEnd(22)} ${verdict.padEnd(15)} ${detail}`);
    } else {
      over += 1;
      console.warn(`${method.padEnd(22)} ${verdict.padEnd(15)} ${detail}`);
      const instead = substitute(method);
      console.warn(instead
        ? `  repair: ${instead}`
        : '  repair: move it out of the loop. Call it once at boot, cache the ' +
          'answer with a long TTL, and refresh it from an event rather than ' +
          'from a timer');
    }

    if (token) {
      const [ok, note] = await probeOnce(token, method);
      console.log(`  probe: ${ok === false ? `unreachable, ${note}` : note}`);
    }
  }

  console.log(`${entries.length} entry(s) checked, ${over} over budget, ` +
    `${unknown} with an unknown tier`);
  process.exitCode = (over || unknown) ? 1 : 0;
}

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

Add a test

The fixtures are schedule entries, not API responses, because the finding is arithmetic and the arithmetic is the thing worth pinning. The important tests are the ones about not knowing: an unfamiliar method returns tier-unknown with a pointer to the reference page rather than a plausible number, an admin.* method says which neighbourhood it is in without claiming a tier, and a tier supplied in the schedule overrides the seeded table because it came from the documentation and the table did not.

test_slack_method_tier_budget.py
from slack_method_tier_budget import budget_verdict, substitute, tier_of


def test_the_seeded_table_answers_for_the_methods_it_carries():
    assert tier_of("conversations.list") == (2, "table")
    assert tier_of("conversations.members") == (4, "table")
    assert tier_of("users.info") == (4, "table")


def test_an_unfamiliar_method_is_unknown_rather_than_guessed():
    assert tier_of("dnd.info") == (None, "unknown")
    assert tier_of("") == (None, "no-method")
    assert tier_of(None) == (None, "no-method")


def test_the_admin_family_is_named_without_claiming_a_number():
    tier, source = tier_of("admin.teams.list")
    assert tier is None
    assert source == "unknown-admin"


def test_the_special_tier_is_a_separate_answer_from_not_knowing():
    assert tier_of("chat.postMessage") == (None, "special")
    verdict, _, detail = budget_verdict({"method": "chat.postMessage",
                                         "interval_seconds": 1})
    assert verdict == "special-tier"
    assert "per channel" in detail


def test_a_tier_1_method_in_a_loop_is_the_headline_finding():
    verdict, rate, detail = budget_verdict({"method": "admin.teams.list",
                                            "interval_seconds": 30, "tier": 1})
    assert verdict == "polled-tier-1"
    assert rate == 2.0
    assert "200%" in detail
    assert "no backoff" in detail


def test_a_supplied_tier_beats_the_seeded_table():
    # The schedule says the tier moved; the reference page is the source and
    # this table is not.
    verdict, _, _ = budget_verdict({"method": "conversations.list",
                                    "interval_seconds": 30, "tier": 1})
    assert verdict == "polled-tier-1"


def test_the_same_interval_is_fine_on_a_higher_tier():
    verdict, rate, _ = budget_verdict({"method": "conversations.members",
                                       "interval_seconds": 1})
    assert verdict == "within-budget"
    assert rate == 60.0


def test_calls_per_run_multiplies_the_rate():
    verdict, rate, _ = budget_verdict({"method": "users.list",
                                       "interval_seconds": 60, "calls_per_run": 40})
    assert rate == 40.0
    assert verdict == "over-budget"


def test_the_floor_itself_is_the_edge_and_not_yet_over():
    verdict, rate, _ = budget_verdict({"method": "users.list",
                                       "interval_seconds": 3})
    assert rate == 20.0
    assert verdict == "at-the-edge"
    verdict, rate, _ = budget_verdict({"method": "users.list",
                                       "interval_seconds": 2.5})
    assert rate == 24.0
    assert verdict == "over-budget"


def test_an_unknown_tier_asks_for_the_number_instead_of_inventing_one():
    verdict, _, detail = budget_verdict({"method": "dnd.info",
                                         "interval_seconds": 10})
    assert verdict == "tier-unknown"
    assert "reference page" in detail
    assert "docs.slack.dev" in detail


def test_no_interval_is_a_good_answer_not_a_missing_one():
    verdict, rate, detail = budget_verdict({"method": "team.info"})
    assert verdict == "no-interval"
    assert rate == 0.0
    assert "once at boot" in detail
    assert budget_verdict({"method": "team.info",
                           "interval_seconds": "soon"})[0] == "no-interval"


def test_every_poll_worth_flagging_has_something_to_replace_it_with():
    assert "user_change" in substitute("users.list")
    assert "channel_created" in substitute("conversations.list")
    assert "member_joined_channel" in substitute("conversations.members")
    assert substitute("dnd.info") is None
slack-method-tier-budget.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { budgetVerdict, substitute, tierOf }
  from './slack-method-tier-budget.mjs';

test('the seeded table answers for the methods it carries', () => {
  assert.deepEqual(tierOf('conversations.list'), [2, 'table']);
  assert.deepEqual(tierOf('conversations.members'), [4, 'table']);
  assert.deepEqual(tierOf('users.info'), [4, 'table']);
});

test('an unfamiliar method is unknown rather than guessed', () => {
  assert.deepEqual(tierOf('dnd.info'), [null, 'unknown']);
  assert.deepEqual(tierOf(''), [null, 'no-method']);
  assert.deepEqual(tierOf(null), [null, 'no-method']);
});

test('the admin family is named without claiming a number', () => {
  const [tier, source] = tierOf('admin.teams.list');
  assert.equal(tier, null);
  assert.equal(source, 'unknown-admin');
});

test('the special tier is a separate answer from not knowing', () => {
  assert.deepEqual(tierOf('chat.postMessage'), [null, 'special']);
  const [verdict, , detail] = budgetVerdict({
    method: 'chat.postMessage', interval_seconds: 1,
  });
  assert.equal(verdict, 'special-tier');
  assert.match(detail, /per channel/);
});

test('a tier 1 method in a loop is the headline finding', () => {
  const [verdict, rate, detail] = budgetVerdict({
    method: 'admin.teams.list', interval_seconds: 30, tier: 1,
  });
  assert.equal(verdict, 'polled-tier-1');
  assert.equal(rate, 2);
  assert.match(detail, /200%/);
  assert.match(detail, /no backoff/);
});

test('a supplied tier beats the seeded table', () => {
  assert.equal(budgetVerdict({
    method: 'conversations.list', interval_seconds: 30, tier: 1,
  })[0], 'polled-tier-1');
});

test('the same interval is fine on a higher tier', () => {
  const [verdict, rate] = budgetVerdict({
    method: 'conversations.members', interval_seconds: 1,
  });
  assert.equal(verdict, 'within-budget');
  assert.equal(rate, 60);
});

test('calls per run multiplies the rate', () => {
  const [verdict, rate] = budgetVerdict({
    method: 'users.list', interval_seconds: 60, calls_per_run: 40,
  });
  assert.equal(rate, 40);
  assert.equal(verdict, 'over-budget');
});

test('the floor itself is the edge and not yet over', () => {
  const [edge, rate] = budgetVerdict({ method: 'users.list', interval_seconds: 3 });
  assert.equal(rate, 20);
  assert.equal(edge, 'at-the-edge');
  const [over, faster] = budgetVerdict({ method: 'users.list', interval_seconds: 2.5 });
  assert.equal(faster, 24);
  assert.equal(over, 'over-budget');
});

test('an unknown tier asks for the number instead of inventing one', () => {
  const [verdict, , detail] = budgetVerdict({ method: 'dnd.info', interval_seconds: 10 });
  assert.equal(verdict, 'tier-unknown');
  assert.match(detail, /reference page/);
  assert.match(detail, /docs\.slack\.dev/);
});

test('no interval is a good answer not a missing one', () => {
  const [verdict, rate, detail] = budgetVerdict({ method: 'team.info' });
  assert.equal(verdict, 'no-interval');
  assert.equal(rate, 0);
  assert.match(detail, /once at boot/);
  assert.equal(budgetVerdict({ method: 'team.info', interval_seconds: 'soon' })[0],
    'no-interval');
});

test('every poll worth flagging has something to replace it with', () => {
  assert.match(substitute('users.list'), /user_change/);
  assert.match(substitute('conversations.list'), /channel_created/);
  assert.match(substitute('conversations.members'), /member_joined_channel/);
  assert.equal(substitute('dnd.info'), null);
});

FAQ

How do I find out which tier a method is on?

The method's own reference page states it, in a line near the top: Tier 3: 50+ per minute, and so on. There is no API that returns it and no header that reports your remaining budget. The script seeds a small table for the methods it is confident about and asks you to fill in the rest from the documentation rather than guessing, because tiers do change.

Is a tier the exact number of calls I get?

It is the floor. Slack writes them as 1+, 20+, 50+ and 100+ per minute, and allows short bursts above the floor. Design against the floor: the plus is headroom you are not entitled to and cannot measure. If your schedule only works because of the burst allowance, it will start failing the first time the app has a busy minute for any other reason.

Will exponential backoff fix a Tier 1 method in a loop?

No. Backoff recovers from a temporary overshoot. A loop that wants two requests a minute out of a budget of one is overshooting permanently, so the job's throughput is fixed at the tier no matter how the client sleeps. The polite backoff simply makes the failure quieter, which is worse, because it stops anyone from noticing the design problem.

Why does the script not just call each method to see what happens?

Because the only way to learn a limit by calling is to exhaust it, and exhausting a window spends a real workspace's quota on a demonstration that every other app on the same method pays for. With --probe it makes exactly one call per method, only for methods it can prove are reads, and never in a loop. The tier is documented; the arithmetic is offline.

The audit says tier-unknown for most of our methods. Is that useful?

It is the useful state. It names precisely which reference pages somebody has to open, and it turns an unbounded question into a short list. The alternative is a table full of plausible numbers that nobody checked, which is exactly how an audit becomes the source of the wrong answer it was written to prevent.

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.