Skip to content

Diagnostic Twilio

error 14107: an auto-reply loop trips the SMS rate limit

Two of your own numbers found each other. One auto-replies to everything it receives, the other does too, and for about half a minute they had the fastest conversation in the account's history. Then 14107 stopped it: SMS send rate limit exceeded. The rate limit is not the problem. It is the seatbelt, and it did its job.

Read-only key Python and Node.js Tests included
Server nameplates
Photo by Marc PEZIN on Unsplash
The short answer

Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD and filter for error_code 14107 client-side. Twilio caps outbound at 30 messages between the same two numbers in 30 seconds, as a guard against exactly this, so a 14107 means a pair of numbers hit that ceiling.

Then prove it is a loop rather than a burst. The alert's resource_sid gives you a Message SID; GET /2010-04-01/Accounts/{AccountSid}/Messages/{MessageSid}.json gives you the pair, and two queries — ?To=A&From=B and ?To=B&From=A — give you both halves of the conversation. Dense, alternating, repeated bodies is a loop. Dense and one-directional is a send loop, which is a different repair.

The problem in plain words

Every ingredient of this is something a reasonable person built on purpose. An inbound handler that always replies is good manners. A <Redirect> that sends the flow back to a menu is how menus work. A test number that echoes what it receives is a useful test number. The loop is what happens when two of those meet, and no single piece of it looks wrong when you read it.

It also gets found in the worst possible way. Nothing complains during the seconds when the loop is running at full speed, because every one of those messages sends successfully and is billed. The first sign is 14107, which appears once the pair is already thirty messages deep, and which reads as a throughput problem: somebody's first instinct is to ask Twilio to raise the limit. Raising it would have bought a longer loop.

The other shape is quieter and worse. A loop that trades messages just under the ceiling never trips anything at all. It looks like an unusually chatty customer, it bills every segment, and it can run for days, which is why the audit checks the density below the limit as well as the failures above it.

Handler alwaysrepliesgood mannersSecond numberdoes tooa test harnessThey find eachotherone message startsitThirty inthirty secondsthe pair ceilingRejected 14107reads asthroughputeach reply is the next message in
Every message in the loop sends successfully and is billed. The rate limit is the only thing in the system that ever objects.

Why it happens

The limit is between a pair of numbers, not per account. Thirty messages in thirty seconds between the same two numbers is the ceiling, and it exists specifically to stop messaging loops. That scoping is why account-level throughput dashboards show nothing: the account is not busy, one pair of numbers is.

Auto-replies do not know they are talking to a robot. An inbound handler answers whatever arrives. If what arrives is another handler's answer, the conversation is self-sustaining, and neither side has any way to notice. Two Twilio numbers in the same account are the classic pairing, usually because a test harness pointed one at the other.

Request-time rejections often leave no Message row. A send refused with 14107 can be a rejection at the API rather than an undelivered message, so paging the Messages list looking for the error code finds nothing. The Alerts API is the read-only path to those, filtered client-side, and alerts are retained thirty days.

The Messages list has no “between these two numbers” filter. It filters on To and on From independently, so a conversation has to be assembled from two queries and merged. Query one direction only and every loop looks like a one-sided flood, which points at the wrong repair.

The error code names the ceiling, not the cause. 14107 is the same code whether the cause is a reply loop, a retry storm from your own sender, or a genuine burst to one recipient. The three need different fixes, and only the direction pattern in the message history tells them apart.

The fix, as a flow

Density is measured in a sliding window rather than by clock minute, and both directions are fetched, because half a conversation makes a loop look like a flood and points the repair at the wrong place.

Both directions mergedsliding thirty second windowSparse traffican ordinary conversationDense, one directionsend loop, fix the senderDense, both directionshandler answering itselfRepeats under the limitloops on, never trips
The quiet case is the one under the ceiling: nothing fails, nothing stops it, and every segment is billed.

How to fix it

Sweep Alerts for the code, filtering client-side

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=... and keep the entries whose error_code is 14107. There is no error code filter on the request, alerts are capped per request and retained thirty days, so treat the sweep as a sample of a window rather than as a complete history.

Resolve each alert to the pair of numbers involved

The alert's resource_sid is usually a Message SID. GET /2010-04-01/Accounts/{AccountSid}/Messages/{MessageSid}.json gives to and from. Where the alert points at something else, the single-alert fetch GET https://monitor.twilio.com/v1/Alerts/{AlertSid} carries the request variables that the list response leaves out.

Assemble the conversation from both directions

Two queries per pair: Messages.json?To=A&From=B and ?To=B&From=A, both bounded with DateSent>=. Merge them and sort by time. The list cannot express “between these two numbers”, and half a conversation is exactly the half that makes a loop look like a flood.

Measure density in a sliding window, not per minute

Count the largest number of messages falling inside any thirty second span rather than bucketing by clock minute. A loop that starts at 12:00:45 puts fifteen messages in one bucket and fifteen in the next, and a per-minute count reports it as ordinary traffic.

Separate a reply loop from a one-way burst

Both directions present, with repeated bodies, is the auto-reply loop; the repair is in the inbound handler. All one direction is a send loop or a retry storm in your own code; the repair is in the sender. Twilio's direction field distinguishes outbound-reply, generated by TwiML answering an inbound message, from outbound-api, which your code asked for.

Fix the handler, then audit the TwiML that made it possible

Add loop detection to the inbound handler — dedupe on the peer plus the body inside a short window, and refuse to reply to your own numbers — then walk every <Message> action URL and every <Redirect> target for cycles. The rate limit is the safety net; the handler is the thing that should have stopped.

How to check it worked

Re-run the script over the same window. No pair should classify as a loop, and the echo count should be zero.

python3 twilio_reply_loop_audit.py --days 3
# 2 pair(s) examined from 41 alert(s), 0 looping

The full code

Alerts for the sample, one message fetch per alert to name the pair, then two paginated queries per pair — all GETs on an API Key with read access. The two pure functions are the whole diagnosis: the sliding window that measures density honestly, and the classifier that decides whether what you are looking at is a conversation eating itself or your own sender misbehaving.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_reply_loop_audit.py
"""Report pairs of numbers whose traffic is an SMS reply loop.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can send messages and
spend money.
"""
import argparse
import collections
import datetime as dt
import email.utils
import logging
import os
import sys

import requests

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

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"

# Twilio's guard against messaging loops: 30 messages between the same two
# numbers in 30 seconds. The limit is the symptom, the loop is the bug.
LOOP_WINDOW = 30
LOOP_LIMIT = 30
ECHO_REPEATS = 4

RATE_LIMIT_ERROR = "14107"


def to_epoch(value):
    """RFC 2822 timestamp to epoch seconds, or None. Parsed rather than sliced:
    a fixed length slice of that format reads the same for every message."""
    if not value:
        return None
    try:
        return email.utils.parsedate_to_datetime(str(value)).timestamp()
    except (TypeError, ValueError):
        return None


def densest_window(stamps, window=LOOP_WINDOW):
    """Largest number of timestamps falling inside any `window` second span.

    Pure. A sliding window rather than clock buckets, because a loop starting at
    12:00:45 splits evenly across two minute buckets and disappears.
    """
    xs = sorted(s for s in stamps if s is not None)
    best = start = 0
    for i, t in enumerate(xs):
        while t - xs[start] > window:
            start += 1
        best = max(best, i - start + 1)
    return best


def classify_pair(messages, window=LOOP_WINDOW, limit=LOOP_LIMIT,
                  echo_repeats=ECHO_REPEATS):
    """Classify the traffic between one pair of numbers.

    `messages` is both directions merged: dicts with `direction`, `body` and
    `at`, the last being epoch seconds. Pure, so the density rule and the
    direction rule can be tested without a network. Returns (state, detail).
    """
    messages = messages or []
    if not messages:
        return ("quiet", "no messages between this pair in the window.")

    peak = densest_window([m.get("at") for m in messages], window)
    directions = [str(m.get("direction") or "") for m in messages]
    inbound = any(d.startswith("inbound") for d in directions)
    outbound = any(d.startswith("outbound") for d in directions)
    auto = any(d == "outbound-reply" for d in directions)
    bodies = collections.Counter(str(m.get("body") or "").strip()
                                 for m in messages if str(m.get("body") or "").strip())
    repeats = max(bodies.values()) if bodies else 0

    handwriting = (" Some of these are direction outbound-reply, which means "
                   "TwiML generated them in answer to an inbound message: that "
                   "is the loop's own handwriting." if auto else "")

    if peak >= limit and inbound and outbound:
        return ("reply-loop",
                "%d messages inside %d seconds, in both directions, with one "
                "body repeated %d times. That is the ceiling 14107 enforces, "
                "and the repair is in the inbound handler.%s"
                % (peak, window, repeats, handwriting))

    if peak >= limit:
        return ("one-way-burst",
                "%d messages inside %d seconds and all in one direction: a send "
                "loop or a retry storm in your own code, not a reply loop. Same "
                "error code, different repair." % (peak, window))

    if inbound and outbound and repeats >= echo_repeats:
        return ("echo",
                "one body repeated %d times in both directions, peaking at %d "
                "messages inside %d seconds. Under the limit, so nothing has "
                "failed and nothing will stop it either.%s"
                % (repeats, peak, window, handwriting))

    return ("normal",
            "%d message(s), peaking at %d inside %d seconds." % (len(messages), peak, window))


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
                         "API key belongs to that account with read access"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def rate_limit_alerts(session, days, max_alerts):
    """Alerts carrying 14107. There is no error code filter on the request, so
    the sweep is filtered here; alerts are retained 30 days."""
    since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
    url = "%s/Alerts" % MONITOR
    params = {"LogLevel": "error", "StartDate": since, "PageSize": 100}
    out = []
    while url and len(out) < max_alerts:
        page = get(session, url, **params)
        for alert in page.get("alerts", []):
            if str(alert.get("error_code") or "") == RATE_LIMIT_ERROR:
                out.append(alert)
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out[:max_alerts]


def pair_from_alert(session, account, alert):
    """The two numbers behind one alert, via its Message resource.

    The list response does not carry request variables; only the single-alert
    fetch does. Resolving through the message SID keeps this to one small GET.
    """
    sid = str(alert.get("resource_sid") or "")
    if not sid.startswith(("SM", "MM")):
        return None
    msg = get(session, "%s/Accounts/%s/Messages/%s.json" % (BASE, account, sid))
    a, b = msg.get("from"), msg.get("to")
    return (a, b) if a and b else None


def conversation(session, account, a, b, days, max_messages):
    """Both halves of a conversation, merged and sorted.

    The Messages list filters To and From independently and cannot express
    "between these two numbers", so this is two queries. Half a conversation is
    exactly the half that makes a loop look like a flood.
    """
    since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
    rows = []
    for sender, recipient in ((a, b), (b, a)):
        url = "%s/Accounts/%s/Messages.json" % (BASE, account)
        params = {"From": sender, "To": recipient, "DateSent>": since, "PageSize": 1000}
        while url and len(rows) < max_messages:
            page = get(session, url, **params)
            for m in page.get("messages", []):
                rows.append({"direction": m.get("direction"), "body": m.get("body"),
                             "at": to_epoch(m.get("date_created") or m.get("date_sent"))})
            nxt = page.get("next_page_uri")
            url, params = (HOST + nxt) if nxt else None, {}
    rows.sort(key=lambda m: m["at"] or 0)
    return rows


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=3,
                    help="window in days; alerts are retained 30")
    ap.add_argument("--max-alerts", type=int, default=200,
                    help="stop after this many 14107 alerts")
    ap.add_argument("--max-messages", type=int, default=4000,
                    help="stop paging a pair's history after this many rows")
    args = ap.parse_args()

    account = os.environ.get("TWILIO_ACCOUNT_SID")
    key = os.environ.get("TWILIO_API_KEY")
    secret = os.environ.get("TWILIO_API_SECRET")
    if not (account and key and secret):
        log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
                  "(an API Key with read access, not the auth token)")
        return 2

    session = requests.Session()
    session.auth = (key, secret)

    alerts = rate_limit_alerts(session, args.days, args.max_alerts)
    if not alerts:
        log.info("no %s alerts in the last %d day(s)", RATE_LIMIT_ERROR, args.days)
        return 0

    pairs = set()
    for alert in alerts:
        pair = pair_from_alert(session, account, alert)
        if pair:
            pairs.add(tuple(sorted(pair)))
        else:
            log.warning("alert %s does not point at a message; fetch %s/Alerts/%s "
                        "for its request variables", alert.get("sid"), MONITOR,
                        alert.get("sid"))

    bad = 0
    for a, b in sorted(pairs):
        rows = conversation(session, account, a, b, args.days, args.max_messages)
        state, detail = classify_pair(rows)
        line = "%-14s %s <-> %s  %s" % (state, a, b, detail)
        if state in ("normal", "quiet"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  repair: dedupe on peer plus body inside a short window in "
                    "the inbound handler, refuse to reply to your own numbers, "
                    "and audit every <Message> action URL and <Redirect> target "
                    "for cycles. Raising the rate limit buys a longer loop.")

    log.info("%d pair(s) examined from %d alert(s), %d looping",
             len(pairs), len(alerts), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-reply-loop-audit.mjs
/**
 * Report pairs of numbers whose traffic is an SMS reply loop.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed.
 */
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const MONITOR = 'https://monitor.twilio.com/v1';

// Twilio's guard against messaging loops: 30 messages between the same two
// numbers in 30 seconds. The limit is the symptom, the loop is the bug.
const LOOP_WINDOW = 30;
const LOOP_LIMIT = 30;
const ECHO_REPEATS = 4;

const RATE_LIMIT_ERROR = '14107';

/** RFC 2822 timestamp to epoch seconds, or null. */
export function toEpoch(value) {
  const ms = Date.parse(value ?? '');
  return Number.isNaN(ms) ? null : ms / 1000;
}

/**
 * Largest number of timestamps falling inside any `window` second span. Pure. A
 * sliding window rather than clock buckets, because a loop starting at 12:00:45
 * splits evenly across two minute buckets and disappears.
 */
export function densestWindow(stamps, window = LOOP_WINDOW) {
  const xs = stamps.filter((s) => s !== null && s !== undefined).sort((p, q) => p - q);
  let best = 0;
  let start = 0;
  for (let i = 0; i < xs.length; i += 1) {
    while (xs[i] - xs[start] > window) start += 1;
    best = Math.max(best, i - start + 1);
  }
  return best;
}

/**
 * Classify the traffic between one pair of numbers. `messages` is both
 * directions merged, each with direction, body and `at` in epoch seconds. Pure.
 * Returns [state, detail].
 */
export function classifyPair(messages, window = LOOP_WINDOW, limit = LOOP_LIMIT,
                             echoRepeats = ECHO_REPEATS) {
  const rows = messages ?? [];
  if (!rows.length) return ['quiet', 'no messages between this pair in the window.'];

  const peak = densestWindow(rows.map((m) => m.at), window);
  const directions = rows.map((m) => String(m.direction ?? ''));
  const inbound = directions.some((d) => d.startsWith('inbound'));
  const outbound = directions.some((d) => d.startsWith('outbound'));
  const auto = directions.some((d) => d === 'outbound-reply');

  const bodies = new Map();
  for (const m of rows) {
    const body = String(m.body ?? '').trim();
    if (body) bodies.set(body, (bodies.get(body) ?? 0) + 1);
  }
  const repeats = bodies.size ? Math.max(...bodies.values()) : 0;

  const handwriting = auto
    ? ' Some of these are direction outbound-reply, which means TwiML generated ' +
      "them in answer to an inbound message: that is the loop's own handwriting."
    : '';

  if (peak >= limit && inbound && outbound) {
    return ['reply-loop',
      `${peak} messages inside ${window} seconds, in both directions, with one ` +
      `body repeated ${repeats} times. That is the ceiling 14107 enforces, and ` +
      `the repair is in the inbound handler.${handwriting}`];
  }

  if (peak >= limit) {
    return ['one-way-burst',
      `${peak} messages inside ${window} seconds and all in one direction: a ` +
      'send loop or a retry storm in your own code, not a reply loop. Same ' +
      'error code, different repair.'];
  }

  if (inbound && outbound && repeats >= echoRepeats) {
    return ['echo',
      `one body repeated ${repeats} times in both directions, peaking at ${peak} ` +
      `messages inside ${window} seconds. Under the limit, so nothing has ` +
      `failed and nothing will stop it either.${handwriting}`];
  }

  return ['normal',
    `${rows.length} message(s), peaking at ${peak} inside ${window} seconds.`];
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

async function get(auth, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const res = await fetch(u, { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
                    'that the API key belongs to that account with read access');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

async function rateLimitAlerts(auth, days, maxAlerts) {
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  let url = `${MONITOR}/Alerts`;
  let params = { LogLevel: 'error', StartDate: since, PageSize: 100 };
  const out = [];
  while (url && out.length < maxAlerts) {
    const page = await get(auth, url, params);
    for (const alert of page.alerts ?? []) {
      if (String(alert.error_code ?? '') === RATE_LIMIT_ERROR) out.push(alert);
    }
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, maxAlerts);
}

async function pairFromAlert(auth, account, alert) {
  const sid = String(alert.resource_sid ?? '');
  if (!sid.startsWith('SM') && !sid.startsWith('MM')) return null;
  const msg = await get(auth, `${BASE}/Accounts/${account}/Messages/${sid}.json`);
  return msg.from && msg.to ? [msg.from, msg.to] : null;
}

/** Both halves of a conversation. The list filters To and From independently. */
async function conversation(auth, account, a, b, days, maxMessages) {
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  const rows = [];
  for (const [sender, recipient] of [[a, b], [b, a]]) {
    let url = `${BASE}/Accounts/${account}/Messages.json`;
    let params = { From: sender, To: recipient, 'DateSent>': since, PageSize: 1000 };
    while (url && rows.length < maxMessages) {
      const page = await get(auth, url, params);
      for (const m of page.messages ?? []) {
        rows.push({ direction: m.direction, body: m.body,
                    at: toEpoch(m.date_created ?? m.date_sent) });
      }
      url = page.next_page_uri ? HOST + page.next_page_uri : null;
      params = {};
    }
  }
  rows.sort((p, q) => (p.at ?? 0) - (q.at ?? 0));
  return rows;
}

function flagValue(name, fallback) {
  const i = process.argv.indexOf(name);
  return i === -1 ? fallback : Number(process.argv[i + 1]);
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const days = flagValue('--days', 3);

  const alerts = await rateLimitAlerts(auth, days, flagValue('--max-alerts', 200));
  if (!alerts.length) {
    console.log(`no ${RATE_LIMIT_ERROR} alerts in the last ${days} day(s)`);
    return;
  }

  const pairs = new Set();
  for (const alert of alerts) {
    const pair = await pairFromAlert(auth, account, alert);
    if (pair) pairs.add([...pair].sort().join('|'));
    else {
      console.warn(`alert ${alert.sid} does not point at a message; fetch ` +
                   `${MONITOR}/Alerts/${alert.sid} for its request variables`);
    }
  }

  let bad = 0;
  for (const joined of [...pairs].sort()) {
    const [a, b] = joined.split('|');
    const rows = await conversation(auth, account, a, b, days,
                                    flagValue('--max-messages', 4000));
    const [state, detail] = classifyPair(rows);
    const line = `${state.padEnd(14)} ${a} <-> ${b}  ${detail}`;
    if (state === 'normal' || state === 'quiet') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn('  repair: dedupe on peer plus body inside a short window in the ' +
                 'inbound handler, refuse to reply to your own numbers, and audit ' +
                 'every <Message> action URL and <Redirect> target for cycles. ' +
                 'Raising the rate limit buys a longer loop.');
  }

  console.log(`${pairs.size} pair(s) examined from ${alerts.length} alert(s), ${bad} looping`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The window test is the one that matters most: thirty messages straddling a minute boundary have to count as thirty, because a per-minute bucket would report fifteen and fifteen and call it quiet. After that the direction rule, which decides where the repair goes — both directions is a handler answering itself, one direction is your own sender misbehaving — and the echo case, a loop running just under the ceiling that never trips anything at all.

test_twilio_reply_loop_audit.py
from twilio_reply_loop_audit import classify_pair, densest_window


def pair_traffic(count, start=0.0, step=0.8, alternating=True, body="Thanks!"):
    """A dense exchange between two numbers, one message every `step` seconds."""
    rows = []
    for i in range(count):
        direction = ("inbound" if i % 2 else "outbound-reply") if alternating else "outbound-api"
        rows.append({"direction": direction, "body": body, "at": start + i * step})
    return rows


def test_a_window_straddling_a_minute_boundary_is_still_one_burst():
    # 30 messages from 12:00:45 to 12:01:15. Clock buckets would see 15 and 15.
    stamps = [45.0 + i for i in range(30)]
    assert densest_window(stamps, 30) == 30


def test_sparse_traffic_has_a_low_peak():
    assert densest_window([0, 60, 120, 180], 30) == 1


def test_an_alternating_burst_at_the_ceiling_is_a_reply_loop():
    state, detail = classify_pair(pair_traffic(34))
    assert state == "reply-loop"
    assert "both directions" in detail
    assert "outbound-reply" in detail


def test_a_one_directional_burst_is_not_a_reply_loop():
    state, detail = classify_pair(pair_traffic(34, alternating=False))
    assert state == "one-way-burst"
    assert "retry storm" in detail


def test_a_loop_running_under_the_limit_is_still_reported():
    # Nothing has failed, nothing will stop it, and it bills every segment.
    state, detail = classify_pair(pair_traffic(8, step=3.0))
    assert state == "echo"
    assert "Under the limit" in detail


def test_ordinary_conversation_is_left_alone():
    rows = [{"direction": "inbound", "body": "hi", "at": 0.0},
            {"direction": "outbound-reply", "body": "hello", "at": 40.0},
            {"direction": "inbound", "body": "thanks", "at": 200.0}]
    assert classify_pair(rows)[0] == "normal"


def test_an_empty_history_is_its_own_state():
    assert classify_pair([])[0] == "quiet"


def test_missing_timestamps_do_not_crash_the_window():
    rows = [{"direction": "inbound", "body": "hi", "at": None},
            {"direction": "outbound-reply", "body": "hi", "at": 1.0}]
    assert classify_pair(rows)[0] == "normal"
twilio-reply-loop-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyPair, densestWindow } from './twilio-reply-loop-audit.mjs';

/** A dense exchange between two numbers, one message every `step` seconds. */
function pairTraffic(count, { start = 0, step = 0.8, alternating = true,
                              body = 'Thanks!' } = {}) {
  const rows = [];
  for (let i = 0; i < count; i += 1) {
    const direction = alternating
      ? (i % 2 ? 'inbound' : 'outbound-reply')
      : 'outbound-api';
    rows.push({ direction, body, at: start + i * step });
  }
  return rows;
}

test('a window straddling a minute boundary is still one burst', () => {
  const stamps = Array.from({ length: 30 }, (_, i) => 45 + i);
  assert.equal(densestWindow(stamps, 30), 30);
});

test('sparse traffic has a low peak', () => {
  assert.equal(densestWindow([0, 60, 120, 180], 30), 1);
});

test('an alternating burst at the ceiling is a reply loop', () => {
  const [state, detail] = classifyPair(pairTraffic(34));
  assert.equal(state, 'reply-loop');
  assert.match(detail, /both directions/);
  assert.match(detail, /outbound-reply/);
});

test('a one directional burst is not a reply loop', () => {
  const [state, detail] = classifyPair(pairTraffic(34, { alternating: false }));
  assert.equal(state, 'one-way-burst');
  assert.match(detail, /retry storm/);
});

test('a loop running under the limit is still reported', () => {
  const [state, detail] = classifyPair(pairTraffic(8, { step: 3 }));
  assert.equal(state, 'echo');
  assert.match(detail, /Under the limit/);
});

test('ordinary conversation is left alone', () => {
  const rows = [{ direction: 'inbound', body: 'hi', at: 0 },
                { direction: 'outbound-reply', body: 'hello', at: 40 },
                { direction: 'inbound', body: 'thanks', at: 200 }];
  assert.equal(classifyPair(rows)[0], 'normal');
});

test('an empty history is its own state', () => {
  assert.equal(classifyPair([])[0], 'quiet');
});

test('missing timestamps do not crash the window', () => {
  const rows = [{ direction: 'inbound', body: 'hi', at: null },
                { direction: 'outbound-reply', body: 'hi', at: 1 }];
  assert.equal(classifyPair(rows)[0], 'normal');
});

FAQ

What exactly is the limit behind 14107?

Thirty messages between the same two numbers in thirty seconds. It is a guard against messaging loops rather than a throughput quota, which is why it is scoped to a pair and why account-level dashboards show nothing while it is being hit.

Can the limit be raised?

That is the wrong request, and it is the first one everybody makes. The limit is what stopped a conversation that had no other way to end; raising it buys a longer loop and a larger bill. The thing to change is the handler that answers its own answers.

How do two Twilio numbers end up texting each other?

Usually a test. One number is pointed at a handler that always replies, another is used to poke it, and both end up in the same account with auto-replies enabled. A <Redirect> cycle inside one flow does the same thing with a single number and a single peer.

Why not just search the Messages list for 14107?

Because a request-time rejection frequently leaves no Message row at all, and the Messages list has no error code filter in any case. The Alerts API holds those, filtered client-side, with thirty days of retention and a cap per request, so treat the sweep as a sample of a window.

Why does the script query the messages twice per pair?

The Messages list filters To and From independently and cannot express traffic between two numbers, so each direction is its own query. With only one of them, a loop looks like a one-sided flood and the repair gets pointed at the sender instead of at the handler.

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.