Skip to content

Diagnostic LLM APIs

429s while every minute sits under the configured limit

The backfill went out at 09:00 and 429ed for eleven minutes. Somebody pulled the usage report afterwards, and the worst minute in the whole window used a fifth of the organization's input tokens per minute. Nobody believes the graph, so the ticket says rate limit increase required and the increase, when it arrives, changes nothing: the same job trips at the same point next Tuesday, still using a fifth of a limit that is now twice as big.

Read-only key Python and Node.js Tests included
Futuristic circuit board with glowing green data beam.
Photo by Brecht Corbeel on Unsplash
The short answer

Two GETs with an Admin API key. GET /v1/organizations/usage_report/messages?starting_at={T-4h}&bucket_width=1m&limit=240&group_by[]=model for the minute-by-minute shape, and GET /v1/organizations/rate_limits for the numbers those minutes are being measured against.

Then look at two things rather than one. The peak minute against the configured ITPM and OTPM for that model's group, which is the reading everybody already does. And the ramp: the ratio between each minute and the one before it. A tenfold step between adjacent minutes with a peak that never gets past a third of the ceiling is the signature, and it is a signature that no limit increase repairs, because the documented cause is the sharpness of the increase rather than its size.

Two mechanisms produce it and the script names both. Acceleration limits fire on a sharp increase in organization usage, and the published advice is to ramp gradually and keep patterns consistent. Sub-minute enforcement does the rest: 60 requests per minute may be enforced as one request per second, so sixty requests fired in the same second trip a limit that a per-minute counter says was never approached.

Input is measured the way the limiter measures it: uncached_input_tokens plus both cache_creation fields, with cache_read_input_tokens left out because cache reads do not count toward ITPM on current models. And the report carries no request count at all — its result fields are token sums and server tool calls — so the ramp here is measured in tokens, and the script says so rather than implying a request rate it cannot see.

If the peak minute does reach the ceiling, this is not the note. The script says which limiter saturated and hands the reading to the input limiter note or the output one rather than grading a ramp that has an ordinary explanation.

The problem in plain words

Rate limiting on this API is a token bucket that refills continuously, and the published tier tables give you one number per limiter per model. Everybody treats those numbers as the contract: stay under them and you are fine. The trouble is that they describe a maximum sustained rate, and nothing in them describes how fast you are allowed to get there.

Two things enforce the approach rather than the level. Acceleration limits are documented plainly: a sharp increase in an organization's usage can produce 429s on its own, and the remedy given is to ramp gradually and keep usage patterns consistent. And enforcement happens over intervals shorter than a minute, so a limit expressed per minute can be applied per second. A cron that fans out its entire fleet at the top of the hour, a queue worker that drains a backlog as fast as it can, a launch that turns a feature on for everybody at once: all of these produce a shape that is nowhere near the ceiling on average and violently over it for a moment.

What makes it so durable is that the evidence looks like exoneration. You go to the usage data, you find the worst minute, it is comfortably under the limit, and the only remaining explanation seems to be that the limit is wrong or the platform is wrong. So the team asks for more capacity. The increase arrives, the same job runs the same way, and the failure comes back at the same point in the ramp, because the thing that refused it was never the headline number.

There is a third possibility that costs a lot of debugging time when nobody knows about it. New organizations and organizations with limited history may sit in an Evaluation tier whose limits are below the published tables entirely. Reading the documentation table and assuming it applies is then wrong before any of this starts.

Fan out at thetop of the hourcron, backfill orlaunchTraffic jumpsin one minutefifteenfold, fromquietAccelerationlimit firessharp increase,not sizePeak minutestill underusage graph looksfineIncreaserequested andsame failure nextweek
The worst minute in the window uses a fifth of the ceiling, which is why the graph reads as an alibi rather than as evidence.

Why it happens

The finding is a shape between two buckets, not a level in one. Every other rate-limit note in this section grades a value against a ceiling: how close is the peak, which limiter emptied, how much headroom is left. This one fires precisely when that grading comes back clean. The measurement is minute-over-minute change, the threshold is a factor rather than a number, and a workload with a large steady peak scores better here than a small spiky one, which is the opposite of how the other notes rank things.

A saturated limiter has to be excluded first, or the ramp is a coincidence. Steep ramps are extremely common and most of them are harmless. What makes one diagnostic is the combination: a steep step and a peak that never gets near the ceiling. So the script checks saturation before it checks anything else and, if it finds it, stops and names the note that owns that reading. A finding that fires alongside its own ordinary explanation is not a finding.

Input has to be summed the way the limiter counts it. Only uncached input counts toward ITPM: uncached_input_tokens and both cache_creation fields, and not cache_read_input_tokens, on every current model. Summing all input instead inflates every bucket by the cached share, which on a caching workload can be most of it, and turns a comfortable ramp into a false saturation. The one documented exception is Claude Haiku 3.5, where cache reads do count; the script flags that model rather than silently applying the wrong rule to it.

There is no request count on this report, and the honest move is to say so. The messages usage report returns token sums, cache figures and server tool calls. There is no field anywhere in it for the number of requests, which means the sub-minute burst argument — sixty requests in one second against a limit enforced per second — cannot be measured here at all. The script measures the token ramp, reports it as a token ramp, and prints the request-rate question as something to answer from your own client-side counters.

A minute bucket cannot show a one-second burst, and that is exactly why it is the right instrument. The bucket is not being asked to catch the burst. It is being asked to prove that the minute was not the problem, which it does conclusively, and that proof is what redirects the investigation from the size of the limit to the shape of the traffic.

The fix, as a flow

The chain that matters here is a climb rather than a wall. Every box in it is under the published limit, including the one where the 429s start, and that is the whole argument: the tier table describes a level you may sustain and says nothing about how fast you are allowed to reach it. A limit increase moves the wall this traffic never touched.

Adjacent one minute bucketsfactor and peak togetherSteep step, low peakacceleration, not the numberSteep step, high peakpace it and ask for morePeak at the ceilingthe input or output noteLimits under the tier tableevaluation tier, probablyNo step worth the nameshape is not the problem
Saturation is graded first. A steep ramp next to a full limiter has an ordinary explanation and belongs to the limiter notes.

How to fix it

Use an Admin API key and pick a window that contains the incident

The usage report is Admin API territory: an Admin API key, an OAuth token with the org:admin scope, or a personal or service account key that is not scoped to a workspace. Minute buckets go up to 1,440 of them, so 24 hours is the maximum window at this granularity. Four hours around the event is usually enough and pages faster.

Read the minute buckets grouped by model

starting_at and ending_at are RFC 3339 and buckets snap to the start of the minute in UTC. group_by[]=model is what makes the comparison possible at all, since limits are per model group. Empty minutes come back as buckets with an empty results list, which is information: a gap is not a missing bucket.

Sum each minute the way the limiter does

Input per minute is uncached_input_tokens plus cache_creation.ephemeral_5m_input_tokens plus cache_creation.ephemeral_1h_input_tokens. Output per minute is output_tokens. Leave cache_read_input_tokens out of the input sum on every current model, and note it separately for Claude Haiku 3.5, where it counts.

Read the configured numbers and check them against the published tier

GET /v1/organizations/rate_limits returns each group's models list and its limits[] of {type, value}. Match the model to its group by exact membership. If a configured value sits below the published Start-tier figure for that model, the organization is probably in the Evaluation tier and the documentation tables do not apply to it — which is worth knowing before anybody reasons from them again.

Grade saturation first, then the ramp

If the peak minute is at or above 85% of a ceiling, name that limiter and stop: the sibling notes own that reading. Otherwise compute the largest ratio between adjacent minutes over a meaningful base, and report a steep step under a low peak as the acceleration signature. The repair is client-side pacing — ramp gradually, spread bursts across the minute, put a queue in front of the fan-out — and the script changes no traffic.

How to check it worked

Re-run after pacing the job. The peak minute should barely move — the same work is being done — while the largest adjacent-minute ratio falls, and the verdict moves from acceleration-suspect to steady. A run that comes back limiter-saturated instead means the pacing worked well enough that you are now genuinely against a ceiling, which is a different note and a real reason to ask for an increase.

python3 anthropic_ramp_acceleration.py --hours 4
# 240 minute bucket(s), 3 model(s), 6 rate limit group(s)
# acceleration-suspect  claude-opus-5
#   peak input   1,940,000/min against ITPM 10,000,000 (19%)
#   peak output    210,000/min against OTPM  2,000,000 (11%)
#   steepest ramp  14.8x between 09:03 and 09:04 (131,000 -> 1,940,000)
#   note: this report carries no request count, so the ramp above is measured
#         in tokens. Sub-minute bursting is invisible at this granularity.
#   repair: ramp gradually and keep usage patterns consistent. A step this
#           steep can 429 on acceleration alone, well under the tier limits.
#   repair: spread the burst across the minute with client-side pacing or a
#           queue in front of the fan-out. A limit of 60 per minute may be
#           enforced as 1 per second.
# limiter-saturated     claude-sonnet-5: output peaked at 1,870,000/min, 94% of
#                       OTPM. That is the output limiter note, not this one.
# below-published-start claude-fable-5: configured ITPM 250,000 is under the
#                       published Start tier figure of 500,000, so this
#                       organization may be in the Evaluation tier.
# 2 finding(s)

The full code

The network part is two GETs; everything that decides anything is pure. uncached_input, which sums a bucket the way ITPM counts it and leaves cache reads out; series, which folds the paged buckets into one ordered list of minutes per model and keeps empty minutes as zeros rather than dropping them; ramp_factors, which computes adjacent-minute ratios only over a base big enough to mean something, so a jump from 12 tokens to 900 is not reported as a 75x ramp; peak and share; group_for_model, which resolves a model id to its limiter group by exact membership; below_published_start, which compares configured numbers against the published Start tier and is the Evaluation-tier check; and verdict, which tests saturation before it tests the ramp and hands saturated models to their own notes by name.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
anthropic_ramp_acceleration.py
"""Find 429s caused by the ramp rather than by the limit.

Read only. Two GETs with an Admin API key:

  GET /v1/organizations/usage_report/messages?bucket_width=1m&group_by[]=model
  GET /v1/organizations/rate_limits

The finding is a shape between two adjacent minutes, not a level in one. A steep
step whose peak never approaches the configured ceiling is the acceleration
signature: a sharp increase in usage can produce 429s on its own, and limits
expressed per minute can be enforced over shorter intervals.

Saturation is graded first. A peak that does reach the ceiling has an ordinary
explanation and belongs to the ITPM or OTPM notes, and this script says so
instead of reporting a ramp next to it.

Input is summed the way the limiter counts it: uncached input plus both cache
creation figures, and not cache reads. This report has no request count of any
kind, so the ramp is measured in tokens and reported as such.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

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

API = "https://api.anthropic.com/v1"
ANTHROPIC_VERSION = "2023-06-01"

# The published Start tier figures, as (ITPM, OTPM), used only to spot an
# organization whose configured numbers sit below the documented floor, which
# is what the Evaluation tier looks like from the outside. This is a
# documentation table and documentation tables move; it is printed as "below
# the published Start tier", never as a claim about what your tier is.
START_TIER = {
    "claude-fable-5": (500_000, 100_000),
    "claude-haiku-3-5": (100_000, 20_000),
}
START_TIER_DEFAULT = (2_000_000, 400_000)

# Claude Haiku 3.5 counts cache reads toward ITPM. Every other current model
# does not. Applying one rule to both is how a caching workload gets reported
# as saturated when it is nowhere near its limit.
COUNTS_CACHE_READS = ("claude-haiku-3-5",)

SATURATED = 0.85
QUIET = 0.60

FINDINGS = ("acceleration-suspect", "ramp-near-ceiling", "below-published-start")


def num(value):
    """A float, or 0.0. Pure."""
    try:
        if value is None or isinstance(value, bool):
            return 0.0
        return float(value)
    except (TypeError, ValueError):
        return 0.0


def cache_creation(result):
    """Both cache creation figures, summed. Pure.

    They are separate fields for the 5 minute and 1 hour entries and both count
    toward the input limiter, so a reader that knows about only one of them
    undercounts every cached workload.
    """
    block = (result or {}).get("cache_creation") or {}
    return (num(block.get("ephemeral_5m_input_tokens"))
            + num(block.get("ephemeral_1h_input_tokens")))


def uncached_input(result):
    """The input tokens that count toward ITPM. Pure.

    uncached_input_tokens plus cache creation. cache_read_input_tokens is
    deliberately absent: it does not count toward the input limiter on current
    models, and including it inflates a cached workload's every bucket.
    """
    return num((result or {}).get("uncached_input_tokens")) + cache_creation(result)


def series(pages, model_key="model"):
    """{model: [(starting_at, input_tokens, output_tokens, cache_read)]}. Pure.

    Ordered by bucket start. A bucket with an empty results list contributes
    nothing, which keeps a gap in traffic visible as a gap rather than being
    silently closed up by the next minute that had data.
    """
    out = {}
    for page in pages or []:
        for bucket in ((page or {}).get("data") or []):
            start = str((bucket or {}).get("starting_at") or "")
            for result in ((bucket or {}).get("results") or []):
                model = str((result or {}).get(model_key) or "(ungrouped)")
                out.setdefault(model, []).append(
                    (start, uncached_input(result),
                     num((result or {}).get("output_tokens")),
                     num((result or {}).get("cache_read_input_tokens"))))
    for rows in out.values():
        rows.sort(key=lambda r: r[0])
    return out


def peak(rows, index):
    """(starting_at, value) for the largest bucket. Pure. ("", 0.0) if empty."""
    best = ("", 0.0)
    for row in rows or []:
        if row[index] > best[1]:
            best = (row[0], row[index])
    return best


def share(value, limit):
    """value / limit, or None when the limit is unknown. Pure."""
    if not limit or limit <= 0:
        return None
    return float(value) / float(limit)


def ramp_factors(rows, index, min_base=10_000.0):
    """[(prev_start, start, factor, prev, current)] between adjacent minutes.

    Pure, largest factor first. Ratios are computed only where the earlier
    minute is above min_base, because 12 tokens followed by 900 is a 75x ratio
    and means nothing at all. A rise from a genuine standing start is real but
    it is not what this measures, so it is left out rather than dominating.
    """
    out = []
    rows = list(rows or [])
    for i in range(1, len(rows)):
        prev = rows[i - 1][index]
        current = rows[i][index]
        if prev < min_base or current <= prev:
            continue
        out.append((rows[i - 1][0], rows[i][0], current / prev, prev, current))
    out.sort(key=lambda r: (-r[2], r[1]))
    return out


def group_for_model(groups, model):
    """{limiter_type: value} for the group that contains this model. Pure.

    Membership is exact: every model id and alias that counts against a group is
    listed on it, so a prefix match would only ever be a way to get the wrong
    group for a model the API already told you about.
    """
    for entry in groups or []:
        models = [str(m) for m in ((entry or {}).get("models") or [])]
        if str(model) in models:
            out = {}
            for row in ((entry or {}).get("limits") or []):
                ltype = str((row or {}).get("type") or "")
                if ltype:
                    out[ltype] = num((row or {}).get("value"))
            return out
    return {}


def below_published_start(model, limits):
    """[(limiter, configured, published_start)] below the documented floor. Pure."""
    itpm_floor, otpm_floor = START_TIER.get(str(model), START_TIER_DEFAULT)
    out = []
    pairs = (("input_tokens_per_minute", itpm_floor),
             ("output_tokens_per_minute", otpm_floor))
    for ltype, floor in pairs:
        configured = (limits or {}).get(ltype)
        if configured and 0 < configured < floor:
            out.append((ltype, configured, floor))
    return out


def verdict(rows, limits, model, ramp_threshold=3.0):
    """Classify one model's window. Pure. Returns (state, detail, facts).

    Saturation is answered first and handed to the note that owns it. A ramp
    reported next to a saturated limiter would be a coincidence dressed up as a
    cause.
    """
    rows = list(rows or [])
    limits = limits or {}
    facts = {
        "peak_in": peak(rows, 1),
        "peak_out": peak(rows, 2),
        "itpm": limits.get("input_tokens_per_minute"),
        "otpm": limits.get("output_tokens_per_minute"),
        "ramps": ramp_factors(rows, 1) + ramp_factors(rows, 2),
        "cache_read_counts": str(model) in COUNTS_CACHE_READS,
    }
    facts["ramps"].sort(key=lambda r: -r[2])
    in_share = share(facts["peak_in"][1], facts["itpm"])
    out_share = share(facts["peak_out"][1], facts["otpm"])
    facts["in_share"] = in_share
    facts["out_share"] = out_share

    if not rows or (facts["peak_in"][1] <= 0 and facts["peak_out"][1] <= 0):
        return ("no-traffic", "no usage in this window", facts)
    if in_share is not None and in_share >= SATURATED:
        return ("limiter-saturated",
                "input peaked at %s/min, %.0f%% of ITPM. That is the input "
                "limiter note, not this one."
                % (fmt(facts["peak_in"][1]), in_share * 100), facts)
    if out_share is not None and out_share >= SATURATED:
        return ("limiter-saturated",
                "output peaked at %s/min, %.0f%% of OTPM. That is the output "
                "limiter note, not this one."
                % (fmt(facts["peak_out"][1]), out_share * 100), facts)

    steepest = facts["ramps"][0][2] if facts["ramps"] else 0.0
    if steepest < ramp_threshold:
        return ("steady",
                "no adjacent minute rose by %.1fx or more" % ramp_threshold, facts)
    quiet = [s for s in (in_share, out_share) if s is not None]
    if quiet and max(quiet) <= QUIET:
        return ("acceleration-suspect",
                "a %.1fx step between adjacent minutes with every peak under "
                "%.0f%% of its ceiling" % (steepest, QUIET * 100), facts)
    return ("ramp-near-ceiling",
            "a %.1fx step between adjacent minutes, and the peak is already "
            "past %.0f%% of a ceiling. Pace it and ask for the increase."
            % (steepest, QUIET * 100), facts)


def fmt(value):
    """Thousands separators. Pure."""
    return "{:,}".format(int(round(num(value))))


def repair_lines(state, facts=None):
    """The repair for one verdict. Pure. Printed, never performed."""
    facts = facts or {}
    if state == "acceleration-suspect":
        lines = ["ramp gradually and keep usage patterns consistent. A step this "
                 "steep can 429 on acceleration alone, well under the tier limits, "
                 "and a limit increase does not change it.",
                 "spread the burst across the minute with client-side pacing or a "
                 "queue in front of the fan-out. A limit of 60 per minute may be "
                 "enforced as 1 per second, so the shape inside the minute matters."]
        if facts.get("cache_read_counts"):
            lines.append("this model counts cache reads toward the input limiter, "
                         "unlike the others. Add cache_read_input_tokens back "
                         "before comparing its peak against ITPM.")
        return lines
    if state == "ramp-near-ceiling":
        return ["pace the ramp and request the increase. Both are true here: the "
                "step is steep enough to trip acceleration and the peak is close "
                "enough that a bigger ceiling would also help."]
    if state == "limiter-saturated":
        return ["this one really is the headline number. Read the input or output "
                "limiter note for the reading that fits, rather than pacing "
                "traffic that is genuinely at its ceiling."]
    if state == "below-published-start":
        return ["configured limits below the published Start tier usually mean an "
                "Evaluation tier organization, where the documentation tables do "
                "not apply. Stop reasoning from the tables and read "
                "/v1/organizations/rate_limits instead.",
                "Evaluation limits rise automatically as the organization builds "
                "usage history, so this is a reason to pace traffic rather than a "
                "reason to file anything."]
    return []


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=90)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Anthropic: the usage report and the rate limits "
                         "endpoint need an Admin API credential, not a workspace "
                         "key" % r.status_code)
    r.raise_for_status()
    return r.json()


def pages(session, path, **params):
    params = dict(params)
    for _ in range(50):
        page = get(session, path, **params)
        yield page
        nxt = page.get("next_page")
        if not nxt:
            return
        params["page"] = nxt


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--hours", type=float, default=4.0,
                    help="window to read, in hours (max 24 at minute buckets)")
    ap.add_argument("--ramp", type=float, default=3.0,
                    help="adjacent-minute factor that counts as a steep step")
    args = ap.parse_args()

    key = os.environ.get("ANTHROPIC_ADMIN_KEY")
    if not key:
        log.error("set ANTHROPIC_ADMIN_KEY to an Admin API key or another "
                  "organization scoped read credential")
        return 2
    hours = max(0.1, min(24.0, args.hours))

    s = requests.Session()
    s.headers.update({"x-api-key": key,
                      "anthropic-version": ANTHROPIC_VERSION,
                      "User-Agent": "anthropic-ramp-acceleration/1.0"})

    now = dt.datetime.now(dt.timezone.utc).replace(second=0, microsecond=0)
    start = now - dt.timedelta(hours=hours)
    stamp = "%Y-%m-%dT%H:%M:%SZ"

    buckets = list(pages(s, "/organizations/usage_report/messages",
                         starting_at=start.strftime(stamp),
                         ending_at=now.strftime(stamp),
                         bucket_width="1m", limit=1440,
                         **{"group_by[]": "model"}))
    groups = []
    for page in pages(s, "/organizations/rate_limits"):
        groups.extend(page.get("data") or [])

    by_model = series(buckets)
    minutes = sum(len(page.get("data") or []) for page in buckets)
    log.info("%d minute bucket(s), %d model(s), %d rate limit group(s)",
             minutes, len(by_model), len(groups))

    findings = 0
    for model in sorted(by_model):
        rows = by_model[model]
        limits = group_for_model(groups, model)
        state, detail, facts = verdict(rows, limits, model, args.ramp)
        emit = log.warning if state in FINDINGS else log.info
        emit("%-21s %s: %s", state, model, detail)

        if state in ("acceleration-suspect", "ramp-near-ceiling", "steady"):
            emit("  peak input   %s/min against ITPM %s (%s)",
                 fmt(facts["peak_in"][1]), fmt(facts["itpm"] or 0),
                 "unknown" if facts["in_share"] is None
                 else "%.0f%%" % (facts["in_share"] * 100))
            emit("  peak output  %s/min against OTPM %s (%s)",
                 fmt(facts["peak_out"][1]), fmt(facts["otpm"] or 0),
                 "unknown" if facts["out_share"] is None
                 else "%.0f%%" % (facts["out_share"] * 100))
            if facts["ramps"]:
                prev_at, at, factor, prev, current = facts["ramps"][0]
                emit("  steepest ramp %.1fx between %s and %s (%s -> %s)",
                     factor, prev_at[11:16] or prev_at, at[11:16] or at,
                     fmt(prev), fmt(current))
            emit("  note: this report carries no request count, so the ramp above "
                 "is measured in tokens. Sub-minute bursting is invisible here.")

        for line in repair_lines(state, facts):
            emit("  repair: %s", line)
        if state in FINDINGS:
            findings += 1

        for ltype, configured, floor in below_published_start(model, limits):
            log.warning("%-21s %s: configured %s is %s, under the published Start "
                        "tier figure of %s", "below-published-start", model, ltype,
                        fmt(configured), fmt(floor))
            for line in repair_lines("below-published-start"):
                log.warning("  repair: %s", line)
            findings += 1

    log.info("%d finding(s)", findings)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
anthropic-ramp-acceleration.mjs
/**
 * Find 429s caused by the ramp rather than by the limit.
 *
 * Read only. Two GETs with an Admin API key: the messages usage report at
 * minute granularity grouped by model, and the configured rate limits.
 *
 * The finding is a shape between two adjacent minutes, not a level in one: a
 * steep step whose peak never approaches the ceiling. Saturation is graded
 * first and handed to the ITPM and OTPM notes, because a ramp reported next to
 * a saturated limiter is a coincidence dressed up as a cause.
 *
 * Input is summed the way the limiter counts it. This report has no request
 * count of any kind, so the ramp is measured in tokens and reported as such.
 */
const API = 'https://api.anthropic.com/v1';
const ANTHROPIC_VERSION = '2023-06-01';

export const START_TIER = {
  'claude-fable-5': [500000, 100000],
  'claude-haiku-3-5': [100000, 20000],
};
const START_TIER_DEFAULT = [2000000, 400000];
const COUNTS_CACHE_READS = new Set(['claude-haiku-3-5']);

const SATURATED = 0.85;
const QUIET = 0.60;
const FINDINGS = new Set(['acceleration-suspect', 'ramp-near-ceiling',
                          'below-published-start']);

/** A number, or 0. Pure. */
export function num(value) {
  if (value === null || value === undefined || typeof value === 'boolean') return 0;
  const n = Number(value);
  return Number.isFinite(n) ? n : 0;
}

/** Both cache creation figures, summed. Pure. */
export function cacheCreation(result) {
  const block = result?.cache_creation ?? {};
  return num(block.ephemeral_5m_input_tokens) + num(block.ephemeral_1h_input_tokens);
}

/** The input tokens that count toward ITPM. Pure. Cache reads excluded. */
export function uncachedInput(result) {
  return num(result?.uncached_input_tokens) + cacheCreation(result);
}

/** {model: [[startingAt, input, output, cacheRead]]}. Pure, ordered. */
export function series(pages, modelKey = 'model') {
  const out = {};
  for (const page of pages ?? []) {
    for (const bucket of page?.data ?? []) {
      const start = String(bucket?.starting_at ?? '');
      for (const result of bucket?.results ?? []) {
        const model = String(result?.[modelKey] ?? '(ungrouped)');
        (out[model] ??= []).push([start, uncachedInput(result),
                                  num(result?.output_tokens),
                                  num(result?.cache_read_input_tokens)]);
      }
    }
  }
  for (const rows of Object.values(out)) rows.sort((a, b) => a[0].localeCompare(b[0]));
  return out;
}

/** [startingAt, value] for the largest bucket. Pure. */
export function peak(rows, index) {
  let best = ['', 0];
  for (const row of rows ?? []) if (row[index] > best[1]) best = [row[0], row[index]];
  return best;
}

/** value / limit, or null when the limit is unknown. Pure. */
export function share(value, limit) {
  if (!limit || limit <= 0) return null;
  return value / limit;
}

/** [[prevStart, start, factor, prev, current]], largest factor first. Pure. */
export function rampFactors(rows, index, minBase = 10000) {
  const out = [];
  const list = rows ?? [];
  for (let i = 1; i < list.length; i += 1) {
    const prev = list[i - 1][index];
    const current = list[i][index];
    if (prev < minBase || current <= prev) continue;
    out.push([list[i - 1][0], list[i][0], current / prev, prev, current]);
  }
  out.sort((a, b) => (b[2] - a[2]) || a[1].localeCompare(b[1]));
  return out;
}

/** {limiterType: value} for the group containing this model. Pure. Exact match. */
export function groupForModel(groups, model) {
  for (const entry of groups ?? []) {
    const models = (entry?.models ?? []).map(String);
    if (models.includes(String(model))) {
      const out = {};
      for (const row of entry?.limits ?? []) {
        const ltype = String(row?.type ?? '');
        if (ltype) out[ltype] = num(row?.value);
      }
      return out;
    }
  }
  return {};
}

/** [[limiter, configured, publishedStart]] below the documented floor. Pure. */
export function belowPublishedStart(model, limits) {
  const [itpmFloor, otpmFloor] = START_TIER[String(model)] ?? START_TIER_DEFAULT;
  const out = [];
  for (const [ltype, floor] of [['input_tokens_per_minute', itpmFloor],
                                ['output_tokens_per_minute', otpmFloor]]) {
    const configured = limits?.[ltype];
    if (configured && configured > 0 && configured < floor) {
      out.push([ltype, configured, floor]);
    }
  }
  return out;
}

/** Thousands separators. Pure. */
export function fmt(value) {
  return Math.round(num(value)).toLocaleString('en-US');
}

/** Classify one model's window. Pure. Returns [state, detail, facts]. */
export function verdict(rows, limits, model, rampThreshold = 3.0) {
  const list = rows ?? [];
  const lim = limits ?? {};
  const facts = {
    peakIn: peak(list, 1),
    peakOut: peak(list, 2),
    itpm: lim.input_tokens_per_minute,
    otpm: lim.output_tokens_per_minute,
    ramps: [...rampFactors(list, 1), ...rampFactors(list, 2)].sort((a, b) => b[2] - a[2]),
    cacheReadCounts: COUNTS_CACHE_READS.has(String(model)),
  };
  facts.inShare = share(facts.peakIn[1], facts.itpm);
  facts.outShare = share(facts.peakOut[1], facts.otpm);

  if (list.length === 0 || (facts.peakIn[1] <= 0 && facts.peakOut[1] <= 0)) {
    return ['no-traffic', 'no usage in this window', facts];
  }
  if (facts.inShare !== null && facts.inShare >= SATURATED) {
    return ['limiter-saturated',
            `input peaked at ${fmt(facts.peakIn[1])}/min, `
            + `${Math.round(facts.inShare * 100)}% of ITPM. That is the input `
            + 'limiter note, not this one.', facts];
  }
  if (facts.outShare !== null && facts.outShare >= SATURATED) {
    return ['limiter-saturated',
            `output peaked at ${fmt(facts.peakOut[1])}/min, `
            + `${Math.round(facts.outShare * 100)}% of OTPM. That is the output `
            + 'limiter note, not this one.', facts];
  }

  const steepest = facts.ramps.length ? facts.ramps[0][2] : 0;
  if (steepest < rampThreshold) {
    return ['steady', `no adjacent minute rose by ${rampThreshold.toFixed(1)}x or more`,
            facts];
  }
  const shares = [facts.inShare, facts.outShare].filter((s) => s !== null);
  if (shares.length && Math.max(...shares) <= QUIET) {
    return ['acceleration-suspect',
            `a ${steepest.toFixed(1)}x step between adjacent minutes with every `
            + `peak under ${Math.round(QUIET * 100)}% of its ceiling`, facts];
  }
  return ['ramp-near-ceiling',
          `a ${steepest.toFixed(1)}x step between adjacent minutes, and the peak is `
          + `already past ${Math.round(QUIET * 100)}% of a ceiling. Pace it and ask `
          + 'for the increase.', facts];
}

/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, facts) {
  if (state === 'acceleration-suspect') {
    const lines = ['ramp gradually and keep usage patterns consistent. A step this '
      + 'steep can 429 on acceleration alone, well under the tier limits, and a '
      + 'limit increase does not change it.',
      'spread the burst across the minute with client-side pacing or a queue in '
      + 'front of the fan-out. A limit of 60 per minute may be enforced as 1 per '
      + 'second, so the shape inside the minute matters.'];
    if (facts?.cacheReadCounts) {
      lines.push('this model counts cache reads toward the input limiter, unlike '
        + 'the others. Add cache_read_input_tokens back before comparing its peak '
        + 'against ITPM.');
    }
    return lines;
  }
  if (state === 'ramp-near-ceiling') {
    return ['pace the ramp and request the increase. Both are true here: the step '
      + 'is steep enough to trip acceleration and the peak is close enough that a '
      + 'bigger ceiling would also help.'];
  }
  if (state === 'limiter-saturated') {
    return ['this one really is the headline number. Read the input or output '
      + 'limiter note for the reading that fits, rather than pacing traffic that '
      + 'is genuinely at its ceiling.'];
  }
  if (state === 'below-published-start') {
    return ['configured limits below the published Start tier usually mean an '
      + 'Evaluation tier organization, where the documentation tables do not '
      + 'apply. Stop reasoning from the tables and read /v1/organizations/'
      + 'rate_limits instead.',
      'Evaluation limits rise automatically as the organization builds usage '
      + 'history, so this is a reason to pace traffic rather than a reason to '
      + 'file anything.'];
  }
  return [];
}

async function read(key, path, params) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params ?? {})) url.searchParams.append(k, String(v));
  const r = await fetch(url, {
    headers: { 'x-api-key': key, 'anthropic-version': ANTHROPIC_VERSION,
               'User-Agent': 'anthropic-ramp-acceleration/1.0' },
  });
  if (r.status === 401 || r.status === 403) {
    throw new Error(`${r.status} from Anthropic: the usage report and the rate `
                    + 'limits endpoint need an Admin API credential');
  }
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

async function pages(key, path, params) {
  const out = [];
  const q = { ...(params ?? {}) };
  for (let i = 0; i < 50; i += 1) {
    const page = await read(key, path, q);
    out.push(page);
    if (!page.next_page) break;
    q.page = page.next_page;
  }
  return out;
}

async function main() {
  const key = process.env.ANTHROPIC_ADMIN_KEY;
  if (!key) {
    console.error('set ANTHROPIC_ADMIN_KEY to an Admin API key or another '
                  + 'organization scoped read credential');
    process.exitCode = 2;
    return;
  }
  const hours = Math.max(0.1, Math.min(24, Number(process.env.HOURS ?? 4)));
  const rampThreshold = Number(process.env.RAMP ?? 3);

  const now = new Date();
  now.setUTCSeconds(0, 0);
  const start = new Date(now.getTime() - hours * 3600 * 1000);
  const stamp = (d) => `${d.toISOString().slice(0, 19)}Z`;

  const buckets = await pages(key, '/organizations/usage_report/messages', {
    starting_at: stamp(start), ending_at: stamp(now),
    bucket_width: '1m', limit: 1440, 'group_by[]': 'model',
  });
  const groups = (await pages(key, '/organizations/rate_limits'))
    .flatMap((p) => p.data ?? []);

  const byModel = series(buckets);
  const minutes = buckets.reduce((n, p) => n + (p.data ?? []).length, 0);
  console.log(`${minutes} minute bucket(s), ${Object.keys(byModel).length} model(s), `
              + `${groups.length} rate limit group(s)`);

  let findings = 0;
  for (const model of Object.keys(byModel).sort()) {
    const limits = groupForModel(groups, model);
    const [state, detail, facts] = verdict(byModel[model], limits, model, rampThreshold);
    console.log(`${state.padEnd(21)} ${model}: ${detail}`);

    if (['acceleration-suspect', 'ramp-near-ceiling', 'steady'].includes(state)) {
      const pct = (s) => (s === null ? 'unknown' : `${Math.round(s * 100)}%`);
      console.log(`  peak input   ${fmt(facts.peakIn[1])}/min against ITPM `
                  + `${fmt(facts.itpm ?? 0)} (${pct(facts.inShare)})`);
      console.log(`  peak output  ${fmt(facts.peakOut[1])}/min against OTPM `
                  + `${fmt(facts.otpm ?? 0)} (${pct(facts.outShare)})`);
      if (facts.ramps.length) {
        const [prevAt, at, factor, prev, current] = facts.ramps[0];
        console.log(`  steepest ramp ${factor.toFixed(1)}x between `
                    + `${prevAt.slice(11, 16) || prevAt} and ${at.slice(11, 16) || at} `
                    + `(${fmt(prev)} -> ${fmt(current)})`);
      }
      console.log('  note: this report carries no request count, so the ramp above '
                  + 'is measured in tokens. Sub-minute bursting is invisible here.');
    }

    for (const line of repairLines(state, facts)) console.log(`  repair: ${line}`);
    if (FINDINGS.has(state)) findings += 1;

    for (const [ltype, configured, floor] of belowPublishedStart(model, limits)) {
      console.log(`${'below-published-start'.padEnd(21)} ${model}: configured `
                  + `${ltype} is ${fmt(configured)}, under the published Start tier `
                  + `figure of ${fmt(floor)}`);
      for (const line of repairLines('below-published-start')) {
        console.log(`  repair: ${line}`);
      }
      findings += 1;
    }
  }

  console.log(`${findings} finding(s)`);
  process.exitCode = findings ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) await main();

Add a test

The first test builds the shape the note is about — a flat run of minutes, one enormous step, and a peak at a fifth of the ceiling — and asserts it comes back as acceleration-suspect with the step reported rather than the level. The second is the guard that keeps this note honest: the identical ramp with a peak at 94% of OTPM must come back as limiter-saturated and hand the reading to the output note by name. Then the input sum, which must include both cache creation fields and exclude cache reads; the ramp floor, which must refuse to call 12 tokens followed by 900 a 75x ramp; the model-to-group resolution, which is exact membership and not a prefix; and the Evaluation tier check against the published Start figures.

test_anthropic_ramp_acceleration.py
from anthropic_ramp_acceleration import (below_published_start, cache_creation,
                                         group_for_model, peak, ramp_factors,
                                         repair_lines, series, share,
                                         uncached_input, verdict)

LIMITS = {"requests_per_minute": 4_000,
          "input_tokens_per_minute": 10_000_000,
          "output_tokens_per_minute": 2_000_000}


def bucket(minute, model="claude-opus-5", uncached=0, out=0, read=0,
           create_5m=0, create_1h=0):
    return {"starting_at": "2026-08-31T09:%02d:00Z" % minute,
            "ending_at": "2026-08-31T09:%02d:00Z" % (minute + 1),
            "results": [{"model": model,
                         "uncached_input_tokens": uncached,
                         "output_tokens": out,
                         "cache_read_input_tokens": read,
                         "cache_creation": {
                             "ephemeral_5m_input_tokens": create_5m,
                             "ephemeral_1h_input_tokens": create_1h}}]}


def page(buckets):
    return [{"data": buckets, "has_more": False, "next_page": None}]


def test_a_steep_step_under_a_low_ceiling_is_the_finding():
    # The note. Four quiet minutes, one fifteenfold step, and a peak that never
    # gets past a fifth of the input limiter.
    rows = series(page([bucket(m, uncached=130_000, out=14_000) for m in range(4)]
                       + [bucket(4, uncached=1_940_000, out=140_000)]))
    state, detail, facts = verdict(rows["claude-opus-5"], LIMITS, "claude-opus-5")
    assert state == "acceleration-suspect"
    assert "step between adjacent minutes" in detail
    assert facts["peak_in"] == ("2026-08-31T09:04:00Z", 1_940_000.0)
    assert 0.19 < facts["in_share"] < 0.20
    assert round(facts["ramps"][0][2], 1) == 14.9
    assert any("ramp gradually" in line for line in repair_lines(state, facts))
    assert any("1 per second" in line for line in repair_lines(state, facts))


def test_the_same_ramp_against_a_saturated_limiter_is_the_other_note():
    # The guard. Without this the note fires on every busy workload and takes
    # the credit for a finding that belongs to the output limiter note.
    rows = series(page([bucket(m, out=120_000) for m in range(4)]
                       + [bucket(4, out=1_870_000)]))
    state, detail, _ = verdict(rows["claude-opus-5"], LIMITS, "claude-opus-5")
    assert state == "limiter-saturated"
    assert "output limiter note, not this one" in detail
    assert any("really is the headline number" in line
               for line in repair_lines(state))


def test_input_is_summed_the_way_the_limiter_counts_it():
    result = {"uncached_input_tokens": 1_000, "cache_read_input_tokens": 900_000,
              "cache_creation": {"ephemeral_5m_input_tokens": 400,
                                 "ephemeral_1h_input_tokens": 600}}
    assert cache_creation(result) == 1_000.0
    # 900,000 cache reads are excluded: they do not count toward ITPM.
    assert uncached_input(result) == 2_000.0
    assert uncached_input({}) == 0.0 and uncached_input(None) == 0.0
    rows = series(page([bucket(0, uncached=1_000, read=900_000, create_5m=400,
                               create_1h=600)]))
    assert rows["claude-opus-5"][0][1] == 2_000.0
    assert rows["claude-opus-5"][0][3] == 900_000.0


def test_a_ramp_off_a_trivial_base_is_not_a_ramp():
    rows = [("09:00", 12.0, 0.0, 0.0), ("09:01", 900.0, 0.0, 0.0)]
    assert ramp_factors(rows, 1) == []
    big = [("09:00", 100_000.0, 0.0, 0.0), ("09:01", 400_000.0, 0.0, 0.0),
           ("09:02", 200_000.0, 0.0, 0.0)]
    factors = ramp_factors(big, 1)
    assert len(factors) == 1 and factors[0][2] == 4.0
    assert peak(big, 1) == ("09:01", 400_000.0)
    assert peak([], 1) == ("", 0.0)
    assert share(10, 0) is None and share(10, None) is None


def test_a_model_resolves_to_its_group_by_exact_membership():
    groups = [{"group_type": "model_group",
               "models": ["claude-opus-4-5", "claude-opus-4-8"],
               "limits": [{"type": "input_tokens_per_minute", "value": 10_000_000}]},
              {"group_type": "batch", "models": None,
               "limits": [{"type": "enqueued_batch_requests", "value": 500_000}]}]
    assert group_for_model(groups, "claude-opus-4-8") == {
        "input_tokens_per_minute": 10_000_000.0}
    # A prefix match would hand claude-opus-5 the 4.x group's numbers, which is
    # a different bucket entirely.
    assert group_for_model(groups, "claude-opus-5") == {}
    assert group_for_model(None, "claude-opus-5") == {}


def test_configured_limits_under_the_published_start_tier_are_reported():
    assert below_published_start("claude-fable-5",
                                 {"input_tokens_per_minute": 250_000}) == [
        ("input_tokens_per_minute", 250_000, 500_000)]
    assert below_published_start("claude-opus-5",
                                 {"input_tokens_per_minute": 10_000_000}) == []
    assert below_published_start("claude-opus-5", {}) == []
    assert any("Evaluation tier" in line
               for line in repair_lines("below-published-start"))


def test_an_empty_window_is_not_a_finding():
    state, detail, _ = verdict([], LIMITS, "claude-opus-5")
    assert state == "no-traffic" and "no usage" in detail
    assert verdict(None, None, None)[0] == "no-traffic"
    assert series(None) == {} and repair_lines("steady") == []
    steady = series(page([bucket(m, uncached=100_000) for m in range(3)]))
    assert verdict(steady["claude-opus-5"], LIMITS, "claude-opus-5")[0] == "steady"
anthropic-ramp-acceleration.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { belowPublishedStart, cacheCreation, groupForModel, peak, rampFactors,
         repairLines, series, share, uncachedInput, verdict }
  from './anthropic-ramp-acceleration.mjs';

const LIMITS = { requests_per_minute: 4000, input_tokens_per_minute: 10000000,
                 output_tokens_per_minute: 2000000 };

const pad = (n) => String(n).padStart(2, '0');

const bucket = (minute, { model = 'claude-opus-5', uncached = 0, out = 0, read = 0,
                          create5m = 0, create1h = 0 } = {}) => ({
  starting_at: `2026-08-31T09:${pad(minute)}:00Z`,
  ending_at: `2026-08-31T09:${pad(minute + 1)}:00Z`,
  results: [{ model, uncached_input_tokens: uncached, output_tokens: out,
              cache_read_input_tokens: read,
              cache_creation: { ephemeral_5m_input_tokens: create5m,
                                ephemeral_1h_input_tokens: create1h } }],
});

const page = (buckets) => [{ data: buckets, has_more: false, next_page: null }];

test('a steep step under a low ceiling is the finding', () => {
  const quiet = [0, 1, 2, 3].map((m) => bucket(m, { uncached: 130000, out: 14000 }));
  const rows = series(page([...quiet, bucket(4, { uncached: 1940000, out: 140000 })]));
  const [state, detail, facts] = verdict(rows['claude-opus-5'], LIMITS, 'claude-opus-5');
  assert.equal(state, 'acceleration-suspect');
  assert.match(detail, /step between adjacent minutes/);
  assert.deepEqual(facts.peakIn, ['2026-08-31T09:04:00Z', 1940000]);
  assert.ok(facts.inShare > 0.19 && facts.inShare < 0.20);
  assert.equal(Number(facts.ramps[0][2].toFixed(1)), 14.9);
  assert.ok(repairLines(state, facts).some((l) => l.includes('ramp gradually')));
  assert.ok(repairLines(state, facts).some((l) => l.includes('1 per second')));
});

test('the same ramp against a saturated limiter is the other note', () => {
  const quiet = [0, 1, 2, 3].map((m) => bucket(m, { out: 120000 }));
  const rows = series(page([...quiet, bucket(4, { out: 1870000 })]));
  const [state, detail] = verdict(rows['claude-opus-5'], LIMITS, 'claude-opus-5');
  assert.equal(state, 'limiter-saturated');
  assert.match(detail, /output limiter note, not this one/);
  assert.ok(repairLines(state).some((l) => l.includes('really is the headline number')));
});

test('input is summed the way the limiter counts it', () => {
  const result = { uncached_input_tokens: 1000, cache_read_input_tokens: 900000,
                   cache_creation: { ephemeral_5m_input_tokens: 400,
                                     ephemeral_1h_input_tokens: 600 } };
  assert.equal(cacheCreation(result), 1000);
  assert.equal(uncachedInput(result), 2000);
  assert.equal(uncachedInput({}), 0);
  assert.equal(uncachedInput(null), 0);
  const rows = series(page([bucket(0, { uncached: 1000, read: 900000,
                                        create5m: 400, create1h: 600 })]));
  assert.equal(rows['claude-opus-5'][0][1], 2000);
  assert.equal(rows['claude-opus-5'][0][3], 900000);
});

test('a ramp off a trivial base is not a ramp', () => {
  assert.deepEqual(rampFactors([['09:00', 12, 0, 0], ['09:01', 900, 0, 0]], 1), []);
  const big = [['09:00', 100000, 0, 0], ['09:01', 400000, 0, 0],
               ['09:02', 200000, 0, 0]];
  const factors = rampFactors(big, 1);
  assert.equal(factors.length, 1);
  assert.equal(factors[0][2], 4);
  assert.deepEqual(peak(big, 1), ['09:01', 400000]);
  assert.deepEqual(peak([], 1), ['', 0]);
  assert.equal(share(10, 0), null);
  assert.equal(share(10, null), null);
});

test('a model resolves to its group by exact membership', () => {
  const groups = [
    { group_type: 'model_group', models: ['claude-opus-4-5', 'claude-opus-4-8'],
      limits: [{ type: 'input_tokens_per_minute', value: 10000000 }] },
    { group_type: 'batch', models: null,
      limits: [{ type: 'enqueued_batch_requests', value: 500000 }] },
  ];
  assert.deepEqual(groupForModel(groups, 'claude-opus-4-8'),
                   { input_tokens_per_minute: 10000000 });
  assert.deepEqual(groupForModel(groups, 'claude-opus-5'), {});
  assert.deepEqual(groupForModel(null, 'claude-opus-5'), {});
});

test('configured limits under the published Start tier are reported', () => {
  assert.deepEqual(belowPublishedStart('claude-fable-5',
                                       { input_tokens_per_minute: 250000 }),
                   [['input_tokens_per_minute', 250000, 500000]]);
  assert.deepEqual(belowPublishedStart('claude-opus-5',
                                       { input_tokens_per_minute: 10000000 }), []);
  assert.deepEqual(belowPublishedStart('claude-opus-5', {}), []);
  assert.ok(repairLines('below-published-start').some((l) => l.includes('Evaluation tier')));
});

test('an empty window is not a finding', () => {
  const [state, detail] = verdict([], LIMITS, 'claude-opus-5');
  assert.equal(state, 'no-traffic');
  assert.match(detail, /no usage/);
  assert.equal(verdict(null, null, null)[0], 'no-traffic');
  assert.deepEqual(series(null), {});
  assert.deepEqual(repairLines('steady'), []);
  const steady = series(page([0, 1, 2].map((m) => bucket(m, { uncached: 100000 }))));
  assert.equal(verdict(steady['claude-opus-5'], LIMITS, 'claude-opus-5')[0], 'steady');
});

FAQ

How is this different from the ITPM and OTPM notes?

Those two fire when a minute reaches its ceiling and tell you which limiter emptied. This one fires when no minute gets anywhere near a ceiling and 429s happened anyway. They are opposite readings of the same buckets, which is exactly why the script grades saturation first: if the peak is at or above 85% of a limit, it prints which limiter that is, names the note that owns the reading, and does not report a ramp. A finding that fires alongside its own ordinary explanation is not a finding.

Why is the ramp measured in tokens rather than requests?

Because the messages usage report has no request count. Its result fields are uncached input tokens, the two cache creation figures, cache reads, output tokens and server tool calls, plus whatever you grouped by. There is no field for the number of requests anywhere in it, so a per-minute request rate cannot be reconstructed on this side at all. The script measures the token ramp, says in its own output that it is a token ramp, and leaves the request-rate question to your client-side counters.

If a minute bucket cannot see a one-second burst, what is it proving?

That the minute was not the problem. The bucket is not being asked to catch the burst; it is being asked to eliminate the explanation everybody reaches for first. Once the peak minute is demonstrably at a fifth of the ceiling, the remaining candidates are acceleration on a sharp increase and enforcement over an interval shorter than a minute, and both are repaired the same way: pace the ramp and spread the burst. Neither is repaired by a bigger number.

What is the Evaluation tier and why does the script check for it?

New organizations, and organizations with limited usage history, can start with limits below the standard published tables while account history is established. If that is you, every argument that begins with the tier table is wrong from its first line. The script compares the numbers the API actually reports against the published Start tier figures and flags anything below them, which is the only read-only signal available for it. Those limits rise automatically as usage history accumulates, so it is a reason to pace rather than a reason to file a ticket.

The report says data lands within about five minutes. Does that matter here?

Yes, at the edges. The most recent minute or two may still be filling, so a run made immediately after an incident can show an artificially low final bucket and a downward step that is an artefact rather than traffic. The script only reports upward steps, which sidesteps most of it, but the honest habit is to wait a few minutes before reading the window and to poll no more than once a minute for sustained use.

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.