Skip to content

Diagnostic Slack

ratelimited: the Retry-After header nobody read

The backfill runs beautifully for ninety seconds and then produces a wall of the same line. ratelimited. Sometimes a real HTTP 429, sometimes an HTTP 200 whose body says ok: false. The client sees a failure, does what it does with failures, and tries again immediately, which is the one response guaranteed to make it last longer.

Slack answered that question before you asked it. The wait is in a header, in seconds, on the response you already have.

Read-only token Python and Node.js Tests included
Red 'buy now!' button on a computer keyboard.
Photo by Money Knack on Unsplash
The short answer

Slack rate-limits per method, per workspace, per app, on a rolling one-minute window. When the window is spent it answers with Retry-After, in whole seconds, and the correct behaviour is to sleep for that long and try once more. The two things that go wrong are equally common: clients that never look at the header, and clients that look at it and crash when it is not there.

The script below has two halves. It makes a handful of ordinary read calls and prints the status, the body error and the Retry-After value exactly as Slack sent them, so you can see which shape your workspace produces. Then it replays a log you already have — the refusals your own client recorded, with the wait it actually took — and says for each one whether the header was honoured, undercut, or replaced with a fixed sleep that had nothing to do with the answer.

It does not provoke a limit to demonstrate one. Exhausting a window on purpose is a denial of service against the workspace you were asked to audit.

The problem in plain words

A rate limit is the one API failure that carries its own remedy. Slack computed the window, knows when it reopens, and puts the number of seconds in a response header. Every other error in this section makes you go and find out what to do. This one does not, which is exactly why it is so consistently mishandled: the information arrives somewhere a generic HTTP client is not looking.

What that client sees is a failure, so it applies whatever failure policy it has. Retry immediately, and the next call spends a request out of a window that has not reopened, which on some surfaces extends the penalty. Retry with exponential backoff seeded at one second, and the first four attempts are wasted before the wait is long enough to matter. Retry with a flat sixty-second sleep, and a limit that wanted three seconds costs you a minute, every time, forever. All three of those are written by careful people. None of them read the number.

Then there is the shape problem. Slack sometimes returns a genuine 429 and sometimes returns HTTP 200 with {"ok": false, "error": "ratelimited"}, and a client that switches on the status code catches one of those and treats the other as data. Retry-After is present on both shapes, and is occasionally absent, empty, or something the parser did not expect. Code written as int(response.headers["Retry-After"]) is one missing header away from a KeyError inside the error path, which is the worst place in any program to put an unhandled exception: the process dies during the incident rather than during the test.

Backfill startsninety goodsecondsWindow is spentRetry-After: 12Header not readgeneric failurepathRetryimmediatelyinside the closedwindowJob neverfinishesbackoff blamed
A retry that lands inside a window which has not reopened is still a request. Undercutting the header turns a short wait into a long one, which is why the impatient job finishes after the polite one.

Why it happens

The header is a schedule, not a hint. It is not a suggestion computed from a heuristic on your side; it is Slack reporting when the window it maintains reopens. Any backoff you invent is a guess at a number you were told.

Absent is not zero, and absent is not sixty. When the header is missing the honest move is a documented default — thirty seconds is a reasonable one — recorded as a default rather than as an answer. The failure mode to avoid is a parser that treats a missing header as permission to retry now.

Both response shapes are the same event. A true 429 and an HTTP 200 carrying error: "ratelimited" mean the identical thing and want the identical handling. Any code path that treats one as transport and the other as business logic will handle them differently, and will be right about at most one.

Retrying early is worse than not retrying. A request that arrives inside a closed window is still a request. Undercutting the header converts a wait into a longer wait, which is why a job that "retries aggressively" finishes after a job that sleeps politely.

The strongest repair is to stop writing the client. Both official SDKs already do this correctly — @slack/web-api through its retry configuration, slack_sdk through its built-in retry handlers. A hand-rolled transport layer is the thing under audit here far more often than the rate limit is.

The fix, as a flow

The chain is drawn around one number: Slack computed it, put it in a header, and the client dropped it on the floor. Every step after that is a guess at a value that was already known. The fix branch sorts the header itself, because the parser has more states than present and absent, and four of them are not a number of seconds.

Status, error, headercaptured togetherPresent and numericthe whole scheduleAbsent or emptya default, said out loudNot a number at allno exception in the error pathRetried earlythe outage got longerFixed sleep insteadidle and looking throttled
Absent, empty and unparseable are three different situations and none of them is an answer. Each returns a documented default, labelled as a default, so a log line never reports a guess as a schedule.

How to fix it

Capture three things on every response, not one

Status, body.error, and the Retry-After header, logged together. Most clients keep the first or the second and never the third, which makes the whole question unanswerable after the fact. This is a one-line change in a transport wrapper and it is the change that makes everything below possible.

Look at what your own workspace actually returns

The sweep makes a small number of ordinary read calls, one per method, and prints the shape of each response. Under normal conditions nothing is throttled and the sweep is boring, which is the point: it proves the capture path works before an incident needs it.

Parse the header defensively and label the result

Present and numeric is an answer. Missing, empty, non-numeric, negative, zero or implausibly large are five different situations and none of them is an answer. The parser returns a number in every case and says which case it was, so a log line can distinguish "Slack asked for 3 seconds" from "we defaulted to 30 because nothing was returned".

Replay the refusals you already logged

Feed the script the records your client wrote: the method, the status, the body, the headers, and how long it waited before trying again. It is completely offline, and it is where the finding lives. Nothing has to be reproduced, and nothing has to be provoked.

Treat undercutting and oversleeping as separate bugs

Retrying inside the window is a correctness bug that lengthens the outage. Sleeping four times longer than asked is a throughput bug that makes the job look rate-limited when it is mostly idle. They have different causes and the report keeps them apart.

Delete the backoff and adopt the SDK's

Once the log shows which of the two you have, the repair is usually subtraction rather than addition. The official clients implement this correctly, including the missing-header case, and a hand-written transport layer that gets it wrong is a liability that will be rediscovered by the next person on call.

How to check it worked

Re-run the replay against a log captured after the change. Every throttled record should come back honoured, and the sweep should show the header being captured even on the calls that succeed.

python3 slack_retry_after_audit.py --record retries.json
# sweep    conversations.list   ok               no Retry-After returned, nothing to honour
# sweep    users.list           ok               no Retry-After returned, nothing to honour
# replay   conversations.history  honoured       Slack asked for 12s, the client waited 12.4s
# replay   users.list             honoured       Slack asked for 30s, the client waited 30.1s
# 2 sweep call(s), 2 replayed refusal(s), 0 mishandled

The full code

Three pure functions and one GET helper. retry_after_seconds is the whole note in miniature: it always returns a number and always says where the number came from, so a default is never mistaken for an answer. classify_response collapses the two shapes Slack uses into one state. replay_verdict is entirely offline and reads a log you already have, which is how this script audits rate-limit handling without generating a single rate limit.

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_retry_after_audit.py
"""Audit what this app does with a Slack rate-limit refusal.

Read only, and it never provokes one. Two halves. A small sweep of read methods
records the status, the body error and the Retry-After header exactly as Slack
returned them, so you can see the shape your workspace produces. Then an offline
replay of refusals your own client already logged says, for each, whether the
header was honoured, undercut, or replaced by a sleep unrelated to the answer.

Deliberately exhausting a window to demonstrate that windows exist is a denial
of service against the workspace being audited, so this does not do that.
"""
import argparse
import json
import logging
import math
import os
import sys

import requests

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

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

# What to sleep when Slack did not tell us. Documented rather than clever: the
# number matters far less than the fact that it is recorded as a default and not
# reported as though Slack had asked for it.
DEFAULT_WAIT = 30.0

# A Retry-After longer than this is almost certainly a parsing accident rather
# than an instruction, so it is clamped and flagged instead of obeyed.
CEILING = 600.0

# Waiting less than this fraction of the window is retrying early. Waiting more
# than this multiple of it is a fixed sleep wearing a backoff costume.
EARLY = 0.95
BLIND = 4.0

# Read methods safe to call once each, with the smallest page they will accept.
# One call per method: the sweep is here to prove the capture path works, not to
# find the edge of the window.
SWEEP = (
    ("conversations.list", {"limit": 1, "exclude_archived": "true"}),
    ("users.list", {"limit": 1}),
)


def retry_after_seconds(headers):
    """Read Retry-After defensively. Pure. Returns (seconds, source, detail).

    Always returns a usable number, and always says where that number came from,
    because the difference between "Slack asked for 3 seconds" and "nothing came
    back so we picked 30" is the difference between a schedule and a guess. The
    common one-liner, int(headers["Retry-After"]), raises inside the error path
    when the header is absent, which kills the process during the incident.
    """
    raw = None
    for key, value in (headers or {}).items():
        if str(key).strip().lower() == "retry-after":
            raw = value
            break

    if raw is None:
        return (DEFAULT_WAIT, "absent",
                "no Retry-After came back. Slack usually sends one and sometimes "
                "does not, so this is a default and is reported as one.")

    text = str(raw).strip()
    if not text:
        return (DEFAULT_WAIT, "empty",
                "Retry-After was present and empty, which is indistinguishable "
                "from absent for every purpose except a parser that assumes a "
                "value is there because a key is.")

    try:
        seconds = float(text)
    except ValueError:
        return (DEFAULT_WAIT, "unparseable",
                "Retry-After was %r, which is not a number of seconds. A date "
                "form is legal HTTP and is not what Slack sends; anything that "
                "parses it as an integer without guarding gets an exception "
                "inside the retry path." % text)

    if not math.isfinite(seconds):
        return (DEFAULT_WAIT, "unparseable",
                "Retry-After was %r, which parsed to a non-finite number." % text)
    if seconds < 0:
        return (DEFAULT_WAIT, "negative",
                "Retry-After was negative. Treated as no answer rather than as "
                "permission to retry immediately.")
    if seconds == 0:
        return (1.0, "zero",
                "Retry-After was zero. Retrying instantly on a window that just "
                "closed spends a request inside it, so this waits one second.")
    if seconds > CEILING:
        return (CEILING, "clamped",
                "Retry-After was %.0f seconds, past the %.0f second ceiling this "
                "audit trusts. Clamped, and worth reading the raw response."
                % (seconds, CEILING))

    return (seconds, "header",
            "Slack asked for %.0f second(s) and that is the whole schedule."
            % seconds)


def classify_response(status, body, headers=None):
    """Name what Slack actually returned. Pure. Returns (state, detail).

    A true 429 and an HTTP 200 whose body carries error: "ratelimited" are the
    same event and want the same handling. Code that switches on the status code
    catches one of them and treats the other as data.
    """
    body = body if isinstance(body, dict) else {}
    error = str(body.get("error") or "").strip()
    try:
        code = int(status or 0)
    except (TypeError, ValueError):
        code = 0

    if code == 429:
        return ("throttled-429",
                "a real HTTP 429. The rarer of the two shapes, and the only one "
                "a generic HTTP client notices without being told to.")
    if body.get("ok") is True:
        return ("ok", "the call succeeded, so nothing here is being throttled.")
    if error == "ratelimited":
        return ("throttled-200",
                "HTTP %d with ok: false and error ratelimited. The same event as "
                "a 429, arriving on the success path." % code)
    if error:
        return ("refused",
                "%s is a refusal and it is not a rate limit. Backing off will "
                "not change it." % error)
    return ("unreadable",
            "HTTP %d with no ok field and no error. Log the body verbatim; "
            "something between you and Slack rewrote the response." % code)


def replay_verdict(record):
    """Judge one refusal the client already logged. Pure and offline.

    record: {"method", "status", "body", "headers", "waited"}. `waited` is the
    seconds the client actually slept before its next attempt, or None if it did
    not record one.
    """
    record = record or {}
    method = str(record.get("method") or "?")
    state, detail = classify_response(record.get("status"),
                                      record.get("body"),
                                      record.get("headers"))
    if state in ("ok", "refused", "unreadable"):
        return ("not-a-refusal", state,
                "%s: %s Nothing about backoff is decided here." % (method, detail))

    seconds, source, why = retry_after_seconds(record.get("headers"))
    waited = record.get("waited")
    if waited is None:
        return ("no-wait-recorded", state,
                "%s was throttled and the client logged no wait at all. Either it "
                "retried without pausing, or it raised inside the error path and "
                "never got as far as retrying. %s" % (method, why))

    try:
        waited = float(waited)
    except (TypeError, ValueError):
        return ("no-wait-recorded", state,
                "%s recorded a wait of %r, which is not a duration." % (method, waited))

    if waited < seconds * EARLY:
        return ("retried-early", state,
                "%s waited %.1fs against a window of %.0fs. The retry lands inside "
                "a window that has not reopened, which spends a request and makes "
                "the outage longer than doing nothing. %s" % (method, waited, seconds, why))
    if waited > seconds * BLIND:
        return ("slept-blind", state,
                "%s waited %.1fs against a window of %.0fs. That is a fixed sleep "
                "rather than an answer, and it turns a short throttle into a long "
                "one on every occurrence. %s" % (method, waited, seconds, why))
    return ("honoured", state,
            "%s waited %.1fs against a window of %.0fs. %s"
            % (method, waited, seconds, why))


def probe(session, method, params):
    """One read call. GET only, and exactly one: no retry, no second attempt."""
    res = session.get(API + method, params=params, timeout=30)
    try:
        body = res.json()
    except ValueError:
        body = {}
    return (res.status_code, body, dict(res.headers))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--record", default="",
                    help="JSON file of refusals the client already logged")
    ap.add_argument("--no-sweep", action="store_true",
                    help="skip the live half and replay the log only")
    args = ap.parse_args()

    mishandled = 0
    sweeps = 0

    if not args.no_sweep:
        token = os.environ.get(args.token_env)
        if not token:
            log.error("set %s, or pass --no-sweep to replay a log offline",
                      args.token_env)
            return 2
        s = requests.Session()
        s.headers.update({"Authorization": "Bearer " + token})

        identity = s.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"))

        for method, params in SWEEP:
            status, body, headers = probe(s, method, params)
            state, detail = classify_response(status, body, headers)
            seconds, source, why = retry_after_seconds(headers)
            sweeps += 1
            if state.startswith("throttled"):
                mishandled += 1
                log.warning("sweep    %-22s %-16s %s", method, state, detail)
                log.warning("  window: %.0fs from the %s. One ordinary read was "
                            "enough to be throttled, so the app's real traffic is "
                            "well past this method's tier", seconds, source)
            elif source == "header":
                log.info("sweep    %-22s %-16s %s", method, state, why)
            else:
                log.info("sweep    %-22s %-16s no Retry-After returned, nothing "
                         "to honour", method, state)

    replayed = 0
    if args.record:
        records = json.loads(open(args.record, encoding="utf-8").read())
        for record in records:
            verdict, state, detail = replay_verdict(record)
            replayed += 1
            if verdict in ("honoured", "not-a-refusal"):
                log.info("replay   %-16s %s", verdict, detail)
                continue
            mishandled += 1
            log.warning("replay   %-16s %s", verdict, detail)
            if verdict == "retried-early":
                log.warning("  repair: sleep for the header before the next attempt, "
                            "not for a number your backoff invented")
            elif verdict == "slept-blind":
                log.warning("  repair: read Retry-After instead of the constant; "
                            "the constant is why this job looks slow while idle")
            else:
                log.warning("  repair: guard the header read. A missing Retry-After "
                            "must produce a documented default, never an exception "
                            "inside the error path")

    log.info("%d sweep call(s), %d replayed refusal(s), %d mishandled",
             sweeps, replayed, mishandled)
    return 1 if mishandled else 0


if __name__ == "__main__":
    sys.exit(main())
slack-retry-after-audit.mjs
/**
 * Audit what this app does with a Slack rate-limit refusal.
 *
 * Read only, and it never provokes one. A small sweep of read methods records
 * the status, the body error and the Retry-After header exactly as Slack
 * returned them. Then an offline replay of refusals the client already logged
 * says whether the header was honoured, undercut, or replaced by a fixed sleep.
 *
 * Deliberately exhausting a window to prove windows exist is a denial of
 * service against the workspace being audited, so this does not do that.
 */

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

const API = 'https://slack.com/api/';

// Documented rather than clever. The number matters far less than the fact that
// it is recorded as a default and not reported as though Slack had asked for it.
const DEFAULT_WAIT = 30;
const CEILING = 600;
const EARLY = 0.95;
const BLIND = 4.0;

const SWEEP = [
  ['conversations.list', { limit: '1', exclude_archived: 'true' }],
  ['users.list', { limit: '1' }],
];

/**
 * Read Retry-After defensively. Pure. Returns [seconds, source, detail].
 * Always returns a usable number and always says where it came from.
 */
export function retryAfterSeconds(headers) {
  let raw = null;
  for (const [key, value] of Object.entries(headers ?? {})) {
    if (String(key).trim().toLowerCase() === 'retry-after') { raw = value; break; }
  }

  if (raw === null || raw === undefined) {
    return [DEFAULT_WAIT, 'absent',
      'no Retry-After came back. Slack usually sends one and sometimes does ' +
      'not, so this is a default and is reported as one.'];
  }

  const text = String(raw).trim();
  if (!text) {
    return [DEFAULT_WAIT, 'empty',
      'Retry-After was present and empty, which is indistinguishable from ' +
      'absent for every purpose except a parser that assumes a value is there ' +
      'because a key is.'];
  }

  // Number('') is 0 and Number('12abc') is NaN, so the emptiness check above has
  // to come first or an empty header silently becomes an instant retry.
  const seconds = Number(text);
  if (!Number.isFinite(seconds)) {
    return [DEFAULT_WAIT, 'unparseable',
      `Retry-After was ${JSON.stringify(text)}, which is not a number of ` +
      'seconds. A date form is legal HTTP and is not what Slack sends; ' +
      'anything that parses it without guarding throws inside the retry path.'];
  }
  if (seconds < 0) {
    return [DEFAULT_WAIT, 'negative',
      'Retry-After was negative. Treated as no answer rather than as ' +
      'permission to retry immediately.'];
  }
  if (seconds === 0) {
    return [1, 'zero',
      'Retry-After was zero. Retrying instantly on a window that just closed ' +
      'spends a request inside it, so this waits one second.'];
  }
  if (seconds > CEILING) {
    return [CEILING, 'clamped',
      `Retry-After was ${seconds.toFixed(0)} seconds, past the ${CEILING} ` +
      'second ceiling this audit trusts. Clamped, and worth reading the raw ' +
      'response.'];
  }

  return [seconds, 'header',
    `Slack asked for ${seconds.toFixed(0)} second(s) and that is the whole schedule.`];
}

/**
 * Name what Slack actually returned. Pure. Returns [state, detail].
 * A true 429 and an HTTP 200 carrying error: "ratelimited" are the same event.
 */
export function classifyResponse(status, body, headers = null) {
  const doc = (body && typeof body === 'object') ? body : {};
  const error = String(doc.error ?? '').trim();
  const code = Number.isFinite(Number(status)) ? Number(status) : 0;

  if (code === 429) {
    return ['throttled-429',
      'a real HTTP 429. The rarer of the two shapes, and the only one a ' +
      'generic HTTP client notices without being told to.'];
  }
  if (doc.ok === true) {
    return ['ok', 'the call succeeded, so nothing here is being throttled.'];
  }
  if (error === 'ratelimited') {
    return ['throttled-200',
      `HTTP ${code} with ok: false and error ratelimited. The same event as a ` +
      '429, arriving on the success path.'];
  }
  if (error) {
    return ['refused',
      `${error} is a refusal and it is not a rate limit. Backing off will not ` +
      'change it.'];
  }
  return ['unreadable',
    `HTTP ${code} with no ok field and no error. Log the body verbatim; ` +
    'something between you and Slack rewrote the response.'];
}

/**
 * Judge one refusal the client already logged. Pure and offline.
 * record: { method, status, body, headers, waited }.
 */
export function replayVerdict(record) {
  const doc = record ?? {};
  const method = String(doc.method ?? '?');
  const [state, detail] = classifyResponse(doc.status, doc.body, doc.headers);
  if (state === 'ok' || state === 'refused' || state === 'unreadable') {
    return ['not-a-refusal', state,
      `${method}: ${detail} Nothing about backoff is decided here.`];
  }

  const [seconds, , why] = retryAfterSeconds(doc.headers);
  if (doc.waited === null || doc.waited === undefined) {
    return ['no-wait-recorded', state,
      `${method} was throttled and the client logged no wait at all. Either it ` +
      'retried without pausing, or it threw inside the error path and never ' +
      `got as far as retrying. ${why}`];
  }

  const waited = Number(doc.waited);
  if (!Number.isFinite(waited)) {
    return ['no-wait-recorded', state,
      `${method} recorded a wait of ${JSON.stringify(doc.waited)}, which is ` +
      'not a duration.'];
  }

  if (waited < seconds * EARLY) {
    return ['retried-early', state,
      `${method} waited ${waited.toFixed(1)}s against a window of ` +
      `${seconds.toFixed(0)}s. The retry lands inside a window that has not ` +
      'reopened, which spends a request and makes the outage longer than doing ' +
      `nothing. ${why}`];
  }
  if (waited > seconds * BLIND) {
    return ['slept-blind', state,
      `${method} waited ${waited.toFixed(1)}s against a window of ` +
      `${seconds.toFixed(0)}s. That is a fixed sleep rather than an answer, and ` +
      `it turns a short throttle into a long one on every occurrence. ${why}`];
  }
  return ['honoured', state,
    `${method} waited ${waited.toFixed(1)}s against a window of ` +
    `${seconds.toFixed(0)}s. ${why}`];
}

/** One read call. GET only, and exactly one: no retry, no second attempt. */
async function probe(token, method, params) {
  const qs = new URLSearchParams(params);
  const res = await fetch(`${API}${method}?${qs}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  let body = {};
  try { body = await res.json(); } catch { body = {}; }
  return [res.status, body, Object.fromEntries(res.headers.entries())];
}

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 tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const recordFile = arg(args, '--record', '');
  const noSweep = args.includes('--no-sweep');

  let mishandled = 0;
  let sweeps = 0;

  if (!noSweep) {
    const token = process.env[tokenEnv];
    if (!token) {
      console.error(`set ${tokenEnv}, or pass --no-sweep to replay a log offline`);
      process.exitCode = 2;
      return;
    }

    const [, identity] = await probe(token, 'auth.test', {});
    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}`);

    for (const [method, params] of SWEEP) {
      const [status, body, headers] = await probe(token, method, params);
      const [state, detail] = classifyResponse(status, body, headers);
      const [seconds, source, why] = retryAfterSeconds(headers);
      sweeps += 1;
      if (state.startsWith('throttled')) {
        mishandled += 1;
        console.warn(`sweep    ${method.padEnd(22)} ${state.padEnd(16)} ${detail}`);
        console.warn(`  window: ${seconds.toFixed(0)}s from the ${source}. One ` +
          'ordinary read was enough to be throttled, so the app\'s real traffic ' +
          'is well past this method\'s tier');
      } else if (source === 'header') {
        console.log(`sweep    ${method.padEnd(22)} ${state.padEnd(16)} ${why}`);
      } else {
        console.log(`sweep    ${method.padEnd(22)} ${state.padEnd(16)} no ` +
          'Retry-After returned, nothing to honour');
      }
    }
  }

  let replayed = 0;
  if (recordFile) {
    const records = JSON.parse(await readFile(recordFile, 'utf8'));
    for (const record of records) {
      const [verdict, , detail] = replayVerdict(record);
      replayed += 1;
      if (verdict === 'honoured' || verdict === 'not-a-refusal') {
        console.log(`replay   ${verdict.padEnd(16)} ${detail}`);
        continue;
      }
      mishandled += 1;
      console.warn(`replay   ${verdict.padEnd(16)} ${detail}`);
      if (verdict === 'retried-early') {
        console.warn('  repair: sleep for the header before the next attempt, not ' +
          'for a number your backoff invented');
      } else if (verdict === 'slept-blind') {
        console.warn('  repair: read Retry-After instead of the constant; the ' +
          'constant is why this job looks slow while idle');
      } else {
        console.warn('  repair: guard the header read. A missing Retry-After must ' +
          'produce a documented default, never an exception inside the error path');
      }
    }
  }

  console.log(`${sweeps} sweep call(s), ${replayed} replayed refusal(s), ` +
    `${mishandled} mishandled`);
  process.exitCode = mishandled ? 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 token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The fixtures are headers rather than workspaces, because the header is the whole subject. Six of the tests pin cases that are not a number of seconds — absent, empty, an HTTP date, negative, zero, implausibly large — and every one of them returns a usable wait with a label saying it was a default. Two pin that a real 429 and an HTTP 200 carrying ratelimited reach the same state. The replay tests pin the three ways a client gets it wrong and the one way it does not.

test_slack_retry_after_audit.py
from slack_retry_after_audit import (classify_response, replay_verdict,
                                       retry_after_seconds)

THROTTLED = {"ok": False, "error": "ratelimited"}


def test_a_numeric_header_is_the_schedule():
    seconds, source, detail = retry_after_seconds({"Retry-After": "12"})
    assert seconds == 12.0
    assert source == "header"
    assert "12" in detail


def test_the_header_name_is_matched_without_regard_to_case():
    assert retry_after_seconds({"retry-after": "7"})[0] == 7.0
    assert retry_after_seconds({"RETRY-AFTER": "7"})[0] == 7.0
    assert retry_after_seconds({" Retry-After ": "7"})[0] == 7.0


def test_an_absent_header_returns_a_default_that_says_it_is_a_default():
    seconds, source, _ = retry_after_seconds({})
    assert seconds == 30.0
    assert source == "absent"
    assert retry_after_seconds(None)[1] == "absent"


def test_an_empty_header_is_not_permission_to_retry_now():
    seconds, source, _ = retry_after_seconds({"Retry-After": "  "})
    assert seconds == 30.0
    assert source == "empty"


def test_an_http_date_is_reported_rather_than_crashed_on():
    seconds, source, _ = retry_after_seconds({"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"})
    assert seconds == 30.0
    assert source == "unparseable"


def test_zero_waits_a_second_and_negative_falls_back():
    assert retry_after_seconds({"Retry-After": "0"}) [:2] == (1.0, "zero")
    assert retry_after_seconds({"Retry-After": "-5"})[:2] == (30.0, "negative")


def test_an_implausible_window_is_clamped_rather_than_obeyed():
    seconds, source, _ = retry_after_seconds({"Retry-After": "86400"})
    assert seconds == 600.0
    assert source == "clamped"


def test_both_shapes_of_the_same_event_reach_a_throttled_state():
    assert classify_response(429, {}, {})[0] == "throttled-429"
    assert classify_response(200, THROTTLED, {})[0] == "throttled-200"


def test_a_refusal_that_is_not_a_rate_limit_is_not_dressed_up_as_one():
    state, detail = classify_response(200, {"ok": False, "error": "not_in_channel"})
    assert state == "refused"
    assert "not a rate limit" in detail
    assert classify_response(200, {"ok": True})[0] == "ok"
    assert classify_response(502, {})[0] == "unreadable"


def test_honouring_the_header_is_the_only_clean_verdict():
    verdict, state, _ = replay_verdict({"method": "users.list", "status": 429,
                                        "body": {}, "headers": {"Retry-After": "30"},
                                        "waited": 30.2})
    assert verdict == "honoured"
    assert state == "throttled-429"


def test_retrying_inside_the_window_is_its_own_finding():
    verdict, _, detail = replay_verdict({"method": "conversations.history",
                                         "status": 200, "body": THROTTLED,
                                         "headers": {"Retry-After": "60"},
                                         "waited": 1.0})
    assert verdict == "retried-early"
    assert "has not reopened" in detail


def test_a_fixed_sleep_is_a_different_finding_from_an_early_retry():
    verdict, _, _ = replay_verdict({"method": "users.list", "status": 429,
                                    "body": {}, "headers": {"Retry-After": "3"},
                                    "waited": 60.0})
    assert verdict == "slept-blind"


def test_a_missing_wait_reads_as_a_client_that_never_got_that_far():
    verdict, _, _ = replay_verdict({"method": "users.list", "status": 429,
                                    "body": {}, "headers": {}, "waited": None})
    assert verdict == "no-wait-recorded"
    assert replay_verdict({"method": "u", "status": 429, "body": {},
                           "headers": {}, "waited": "soon"})[0] == "no-wait-recorded"


def test_a_successful_call_in_the_log_is_left_alone():
    verdict, _, _ = replay_verdict({"method": "auth.test", "status": 200,
                                    "body": {"ok": True}, "headers": {}, "waited": 0})
    assert verdict == "not-a-refusal"
slack-retry-after-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyResponse, replayVerdict, retryAfterSeconds }
  from './slack-retry-after-audit.mjs';

const THROTTLED = { ok: false, error: 'ratelimited' };

test('a numeric header is the schedule', () => {
  const [seconds, source, detail] = retryAfterSeconds({ 'Retry-After': '12' });
  assert.equal(seconds, 12);
  assert.equal(source, 'header');
  assert.match(detail, /12/);
});

test('the header name is matched without regard to case', () => {
  assert.equal(retryAfterSeconds({ 'retry-after': '7' })[0], 7);
  assert.equal(retryAfterSeconds({ 'RETRY-AFTER': '7' })[0], 7);
  assert.equal(retryAfterSeconds({ ' Retry-After ': '7' })[0], 7);
});

test('an absent header returns a default that says it is a default', () => {
  const [seconds, source] = retryAfterSeconds({});
  assert.equal(seconds, 30);
  assert.equal(source, 'absent');
  assert.equal(retryAfterSeconds(null)[1], 'absent');
});

test('an empty header is not permission to retry now', () => {
  const [seconds, source] = retryAfterSeconds({ 'Retry-After': '  ' });
  assert.equal(seconds, 30);
  assert.equal(source, 'empty');
});

test('an http date is reported rather than thrown on', () => {
  const [seconds, source] =
    retryAfterSeconds({ 'Retry-After': 'Wed, 21 Oct 2026 07:28:00 GMT' });
  assert.equal(seconds, 30);
  assert.equal(source, 'unparseable');
});

test('zero waits a second and negative falls back', () => {
  assert.deepEqual(retryAfterSeconds({ 'Retry-After': '0' }).slice(0, 2), [1, 'zero']);
  assert.deepEqual(retryAfterSeconds({ 'Retry-After': '-5' }).slice(0, 2),
    [30, 'negative']);
});

test('an implausible window is clamped rather than obeyed', () => {
  const [seconds, source] = retryAfterSeconds({ 'Retry-After': '86400' });
  assert.equal(seconds, 600);
  assert.equal(source, 'clamped');
});

test('both shapes of the same event reach a throttled state', () => {
  assert.equal(classifyResponse(429, {}, {})[0], 'throttled-429');
  assert.equal(classifyResponse(200, THROTTLED, {})[0], 'throttled-200');
});

test('a refusal that is not a rate limit is not dressed up as one', () => {
  const [state, detail] = classifyResponse(200, { ok: false, error: 'not_in_channel' });
  assert.equal(state, 'refused');
  assert.match(detail, /not a rate limit/);
  assert.equal(classifyResponse(200, { ok: true })[0], 'ok');
  assert.equal(classifyResponse(502, {})[0], 'unreadable');
});

test('honouring the header is the only clean verdict', () => {
  const [verdict, state] = replayVerdict({
    method: 'users.list', status: 429, body: {},
    headers: { 'Retry-After': '30' }, waited: 30.2,
  });
  assert.equal(verdict, 'honoured');
  assert.equal(state, 'throttled-429');
});

test('retrying inside the window is its own finding', () => {
  const [verdict, , detail] = replayVerdict({
    method: 'conversations.history', status: 200, body: THROTTLED,
    headers: { 'Retry-After': '60' }, waited: 1,
  });
  assert.equal(verdict, 'retried-early');
  assert.match(detail, /has not reopened/);
});

test('a fixed sleep is a different finding from an early retry', () => {
  const [verdict] = replayVerdict({
    method: 'users.list', status: 429, body: {},
    headers: { 'Retry-After': '3' }, waited: 60,
  });
  assert.equal(verdict, 'slept-blind');
});

test('a missing wait reads as a client that never got that far', () => {
  assert.equal(replayVerdict({
    method: 'users.list', status: 429, body: {}, headers: {}, waited: null,
  })[0], 'no-wait-recorded');
  assert.equal(replayVerdict({
    method: 'u', status: 429, body: {}, headers: {}, waited: 'soon',
  })[0], 'no-wait-recorded');
});

test('a successful call in the log is left alone', () => {
  assert.equal(replayVerdict({
    method: 'auth.test', status: 200, body: { ok: true }, headers: {}, waited: 0,
  })[0], 'not-a-refusal');
});

FAQ

Is Retry-After always present on a Slack rate-limit response?

Usually, not always. Slack documents it and sends it on both the 429 and the HTTP 200 shape, but a client has to survive its absence: an empty value, a proxy that stripped it, or a response that never carried one. The rule this script encodes is that a missing header produces a documented default recorded as a default, never an exception and never an instant retry.

Should I use exponential backoff as well?

Only as the fallback when there is no header. Exponential backoff is a guess at a number that has been given to you, and its usual failure is being too impatient for the first few attempts and too patient afterwards. Sleep for Retry-After, cap the number of attempts, and add a small amount of jitter so several processes do not all wake at once.

Why does the same event sometimes arrive as 429 and sometimes as HTTP 200?

Slack answers almost everything with HTTP 200 and puts the failure in the body, and rate limiting is one of the few places it sometimes uses a real status code instead. Both shapes mean the identical thing. The practical consequence is that a client which only checks response.ok, or only checks status == 429, is handling half of them.

Can the script prove my backoff works by forcing a rate limit?

It could, and it will not. Exhausting a window on purpose spends a real workspace's quota on a demonstration, and every other app in that workspace using the same method shares the consequence. The header is present on responses Slack already returns, and the log of past refusals is more evidence than a provoked one would be.

We use the official SDK. Is there anything left to check?

The retry behaviour, yes: both official clients handle this correctly, and both let you configure it away. Check that the retry policy is actually enabled rather than set to none, and check any place your code calls fetch or requests directly instead of going through the client, because that is where the hand-rolled path usually survives.

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.