Skip to content

Commit 6fb0652

Browse files
wshallwshallwshallwshall
andauthored
feat(transports): give inbound message pacing a voice, so an opt-in ceiling can be tuned (BACKLOG #290) (#1229)
Message-rate pacing ships OFF by the 2026-08-11 ruling, on the ground that a safe number can only come from a site's own feed profile. That posture needs the operator to be able to watch a number engage, and pacing was silent by construction: it never drops, NAKs, refuses or errors, so a paced interface was indistinguishable from a slow one and nothing was written anywhere. _MessagePacer now tallies each APPLIED read delay and reports it at WARNING, at most once per 60s. Instrumented at the two places a wait is acted on -- pace() for the stream pair and deficit() for the listener pair -- never in charge(), which both route through and which deficit() consults on every read; counting there would tally one outstanding debt once per consult. Carries the connection name through from Source.name so a report can be traced to a feed. Metadata only: name, count, duration, configured rate. No default changes and nothing paces differently. Co-authored-by: wshallwshall <mefordev@messagefoundry.org>
1 parent 9fbbc39 commit 6fb0652

5 files changed

Lines changed: 183 additions & 10 deletions

File tree

messagefoundry/transports/http_listener.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,9 @@ def __init__(self, config: Source) -> None:
470470
#: pace()/settle() for the same reason — there is no next read on this connection to settle
471471
#: against. Distinct from the injected `intake_rate_limiter`, which REFUSES failed auth
472472
#: attempts with a 429; this one only ever waits.
473-
self._pacer = _MessagePacer.for_rate(self.max_messages_per_second, self.message_burst)
473+
self._pacer = _MessagePacer.for_rate(
474+
self.max_messages_per_second, self.message_burst, name=config.name or ""
475+
)
474476
# Per-connection peer-IP allowlist (Tier 4): refuse a non-listed peer at accept (fail-closed).
475477
# Absent/empty = no restriction. Mirrors MLLPSource.
476478
sa = s.get("source_ip_allowlist")

messagefoundry/transports/mllp.py

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@
116116
#: mechanism exists and an operator opts in with their own number. The cell stays `partial` on the
117117
#: shipped default and the record says why; that is the honest outcome, not a disappointing one.
118118
DEFAULT_MAX_MESSAGES_PER_SECOND: float | None = None
119+
120+
#: Seconds between operator-facing pacing reports on ONE pacer (BACKLOG #290). Pacing is silent by
121+
#: construction — it never drops, NAKs or errors — so without a report an operator cannot tell a
122+
#: paced interface from a slow one. A pacer in deficit is consulted on every read, so the report is
123+
#: throttled to this window; see :meth:`_MessagePacer._note_paced`.
124+
_PACING_REPORT_SECONDS = 60.0
119125
# On stop()/reload, established clients are closed and their handlers given this long to finish an
120126
# in-flight commit before the connection tasks are cancelled — bounds shutdown so a peer holding a
121127
# connection open can't hang it (review H-2).
@@ -1395,16 +1401,36 @@ class _MessagePacer:
13951401
and drives it with :meth:`deficit` / :meth:`charge`. There is no flag and no branch in here.
13961402
"""
13971403

1398-
__slots__ = ("_capacity", "_last", "_pending_wait", "_rate", "_tokens")
1399-
1400-
def __init__(self, rate: float, burst: float, *, now: float) -> None:
1404+
__slots__ = (
1405+
"_capacity",
1406+
"_last",
1407+
"_name",
1408+
"_paced_count",
1409+
"_paced_seconds",
1410+
"_pending_wait",
1411+
"_rate",
1412+
"_report_at",
1413+
"_tokens",
1414+
)
1415+
1416+
def __init__(self, rate: float, burst: float, *, now: float, name: str = "") -> None:
14011417
self._rate = rate
14021418
self._capacity = max(burst, 1.0)
14031419
self._tokens = self._capacity
14041420
self._last = now
14051421
#: Debt owed before the next read, in seconds. PRIVATE, and settled only through pace() —
14061422
#: a consult-then-clear a caller performs by hand is an invariant restated once per loop.
14071423
self._pending_wait = 0.0
1424+
#: The declaring inbound's name, carried only so a pacing report can NAME the connection an
1425+
#: operator has to go and look at (the :attr:`Source.name` precedent). "" when unwired.
1426+
self._name = name
1427+
#: Applied-delay tally since the last report, reset by each report.
1428+
self._paced_count = 0
1429+
self._paced_seconds = 0.0
1430+
#: Next monotonic stamp a report may be emitted at. Starts at ``now`` so the FIRST time
1431+
#: pacing engages is reported immediately — that transition is the event an operator most
1432+
#: needs, and holding it back for a window would hide it behind the throttle.
1433+
self._report_at = now
14081434

14091435
def charge(self, messages: int, *, now: float) -> float:
14101436
"""Charge ``messages`` and return the seconds to wait before reading again (0.0 if none).
@@ -1428,6 +1454,7 @@ async def pace(self) -> None:
14281454
"""
14291455
if (wait := self._pending_wait) > 0.0:
14301456
self._pending_wait = 0.0
1457+
self._note_paced(wait)
14311458
await asyncio.sleep(wait)
14321459

14331460
def settle(self, messages: int) -> None:
@@ -1446,16 +1473,61 @@ def deficit(self, *, now: float) -> float:
14461473
connections has to recompute it at read time instead. Delegates to :meth:`charge` rather
14471474
than re-deriving the arithmetic, so the two can never disagree.
14481475
"""
1449-
return self.charge(0, now=now)
1476+
owed = self.charge(0, now=now)
1477+
if owed > 0.0:
1478+
self._note_paced(owed, now=now)
1479+
return owed
1480+
1481+
def _note_paced(self, seconds: float, *, now: float | None = None) -> None:
1482+
"""Tally one APPLIED read delay and report it to the operator, throttled.
1483+
1484+
Called from the two places a wait is actually acted on — :meth:`pace` for the stream pair and
1485+
:meth:`deficit` for the listener pair — never from :meth:`charge`, which both of those route
1486+
through and which ``deficit`` consults on every read. Counting in ``charge`` would tally the
1487+
same outstanding debt once per consult and report a number that is not a count of anything.
1488+
1489+
**Why pacing needs a voice at all.** The control is otherwise entirely invisible: it never
1490+
drops, NAKs, refuses or errors, so a paced interface looks to the operator exactly like a
1491+
slow one, and nothing is written anywhere. The 2026-08-11 ruling ships
1492+
:data:`DEFAULT_MAX_MESSAGES_PER_SECOND` OFF *because* a safe number can only come from a
1493+
site's own feed profile — and a site cannot tune a number it has no way to watch engage.
1494+
Reporting is the half that makes the opt-in posture usable; it changes no default and paces
1495+
nothing differently.
1496+
1497+
Throttled to one line per :data:`_PACING_REPORT_SECONDS` because a pacer that is in deficit
1498+
is consulted on every read, and an unthrottled line would restate one fact thousands of
1499+
times. WARNING rather than INFO: a clinical interface being held back is an operator-facing
1500+
condition, not routine chatter.
1501+
1502+
**Metadata only.** The connection name, a count, a duration and the configured rate — never a
1503+
frame, a peer address, or a byte of the body being paced (PHI.md; CLAUDE.md section 9).
1504+
"""
1505+
self._paced_count += 1
1506+
self._paced_seconds += seconds
1507+
stamp = time.monotonic() if now is None else now
1508+
if stamp < self._report_at:
1509+
return
1510+
logger.warning(
1511+
"inbound message pacing engaged on %s: %d read delay(s) totalling %.3fs "
1512+
"(max_messages_per_second=%g). The sender is being held back, not refused — no message "
1513+
"is dropped. Raise the rate if this feed is legitimate.",
1514+
self._name or "<unnamed inbound>",
1515+
self._paced_count,
1516+
self._paced_seconds,
1517+
self._rate,
1518+
)
1519+
self._paced_count = 0
1520+
self._paced_seconds = 0.0
1521+
self._report_at = stamp + _PACING_REPORT_SECONDS
14501522

14511523
@classmethod
1452-
def for_rate(cls, rate: float | None, burst: float) -> _MessagePacer | None:
1524+
def for_rate(cls, rate: float | None, burst: float, *, name: str = "") -> _MessagePacer | None:
14531525
"""Build a pacer, or ``None`` when no rate is configured — the shipped default.
14541526
14551527
The single place the off-default is honoured, so the four intakes that pace cannot drift
14561528
apart on what "unset" means. See :data:`DEFAULT_MAX_MESSAGES_PER_SECOND` for why off.
14571529
"""
1458-
return cls(rate, burst, now=time.monotonic()) if rate else None
1530+
return cls(rate, burst, now=time.monotonic(), name=name) if rate else None
14591531

14601532

14611533
def _pacing_settings(settings: Mapping[str, Any]) -> tuple[float | None, float]:
@@ -1494,6 +1566,8 @@ def __init__(self, config: Source) -> None:
14941566
# Message-rate pacing. Absent -> OFF, unlike the caps above; see _pacing_settings and
14951567
# DEFAULT_MAX_MESSAGES_PER_SECOND for why that deviation is deliberate and ruled.
14961568
self.max_messages_per_second, self.message_burst = _pacing_settings(s)
1569+
# Carried only so a pacing report can name this connection (BACKLOG #290).
1570+
self._pacing_name = config.name or ""
14971571
# Per-connection peer-IP allowlist (Tier 4 operability): when set, a connecting peer whose IP
14981572
# is not listed is refused at accept time. Absent/empty = no restriction.
14991573
sa = s.get("source_ip_allowlist")
@@ -1629,7 +1703,9 @@ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamW
16291703
await self._emit_event("established", peer_host=peer_host)
16301704
try:
16311705
decoder = MLLPDecoder(max_frame_bytes=self.max_frame_bytes)
1632-
pacer = _MessagePacer.for_rate(self.max_messages_per_second, self.message_burst)
1706+
pacer = _MessagePacer.for_rate(
1707+
self.max_messages_per_second, self.message_burst, name=self._pacing_name
1708+
)
16331709
while True:
16341710
# ASVS 2.4.1 / 15.2.2 — the wait is BEFORE the read, never around the handler.
16351711
if pacer is not None:

messagefoundry/transports/tcp.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ def __init__(self, config: Source) -> None:
450450
# port changed REACHABILITY (raw TCP had no rate control in any configuration), never the
451451
# default -- a stock raw-TCP inbound still has no rate bound.
452452
self.max_messages_per_second, self.message_burst = _pacing_settings(s)
453+
# Carried only so a pacing report can name this connection (BACKLOG #290).
454+
self._pacing_name = config.name or ""
453455
# Per-connection peer-IP allowlist (Tier 4 operability): refuse a non-listed peer at accept.
454456
# Absent/empty = no restriction. Mirrors MLLPSource.
455457
sa = s.get("source_ip_allowlist")
@@ -568,7 +570,9 @@ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamW
568570
await self._emit_event("established", peer_host=peer_host)
569571
try:
570572
decoder = self.codec.decoder(max_frame_bytes=self.max_frame_bytes)
571-
pacer = _MessagePacer.for_rate(self.max_messages_per_second, self.message_burst)
573+
pacer = _MessagePacer.for_rate(
574+
self.max_messages_per_second, self.message_burst, name=self._pacing_name
575+
)
572576
while True:
573577
# ASVS 2.4.1 / 15.2.2 — the wait is BEFORE the read, never around the handler.
574578
if pacer is not None:

messagefoundry/transports/x12.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ def __init__(self, config: Source) -> None:
501501
# token per INTERCHANGE, which is what this connector's frame is. The port changed
502502
# REACHABILITY, never the default -- a stock X12 inbound still has no rate bound.
503503
self.max_messages_per_second, self.message_burst = _pacing_settings(s)
504+
# Carried only so a pacing report can name this connection (BACKLOG #290).
505+
self._pacing_name = config.name or ""
504506
# Per-connection peer-IP allowlist (Tier 4 operability): refuse a non-listed peer at accept.
505507
# Absent/empty = no restriction. Mirrors TcpSource/MLLPSource.
506508
sa = s.get("source_ip_allowlist")
@@ -592,7 +594,9 @@ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamW
592594
self._active += 1
593595
try:
594596
decoder = X12FrameReader(max_interchange_bytes=self.max_interchange_bytes)
595-
pacer = _MessagePacer.for_rate(self.max_messages_per_second, self.message_burst)
597+
pacer = _MessagePacer.for_rate(
598+
self.max_messages_per_second, self.message_burst, name=self._pacing_name
599+
)
596600
while True:
597601
# ASVS 2.4.1 / 15.2.2 — the wait is BEFORE the read, never around the handler.
598602
if pacer is not None:

tests/test_mllp_message_pacing.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from __future__ import annotations
2020

2121
import asyncio
22+
import logging
23+
import time
2224

2325
import pytest
2426
from _ingress_pace_probe import install_ingress_pace_probe, stream_debt_seconds
@@ -239,3 +241,88 @@ def test_the_shipped_default_constant_is_still_off() -> None:
239241
"""`DEFAULT_MAX_MESSAGES_PER_SECOND` is what the connector falls back to when the key is absent.
240242
If it ever becomes non-None, exposing the keys would have silently turned pacing on for everyone."""
241243
assert DEFAULT_MAX_MESSAGES_PER_SECOND is None
244+
245+
246+
# --- BACKLOG #290: pacing has to be observable, or its opt-in posture cannot be tuned -------------
247+
#
248+
# The 2026-08-11 ruling ships DEFAULT_MAX_MESSAGES_PER_SECOND OFF because a safe number can only come
249+
# from a site's own feed profile. That posture only works if an operator who sets a number can watch
250+
# it engage -- and pacing is silent by construction (it never drops, NAKs, refuses or errors), so a
251+
# paced interface is indistinguishable from a slow one. These pin the report, not the pacing.
252+
253+
254+
def test_pacing_reports_itself_on_a_stream_intake(caplog: pytest.LogCaptureFixture) -> None:
255+
"""The pace()/settle() pair -- MLLP, raw TCP and X12 -- reports the delay it applies.
256+
257+
Fails without the change: `_note_paced` does not exist, so `pace()` sleeps in silence and nothing
258+
reaches the log at any level.
259+
"""
260+
pacer = _MessagePacer(1000.0, 1.0, now=time.monotonic(), name="IB_ACME_ADT")
261+
pacer.settle(5) # 5 messages against a burst of 1 -> 4 tokens of debt, 4ms at 1000/s
262+
with caplog.at_level(logging.WARNING, logger="messagefoundry.transports.mllp"):
263+
asyncio.run(pacer.pace())
264+
assert "IB_ACME_ADT" in caplog.text
265+
assert "pacing engaged" in caplog.text
266+
# The operator has to be told this is a hold, not a loss -- the whole point of the control.
267+
assert "not refused" in caplog.text
268+
269+
270+
def test_pacing_reports_itself_on_a_listener_scoped_intake(
271+
caplog: pytest.LogCaptureFixture,
272+
) -> None:
273+
"""The deficit()/charge() pair -- the HTTP intake's one listener-wide bucket -- reports too.
274+
275+
Driven on a fully synthetic clock, so it pins the report rather than any wall-clock timing.
276+
"""
277+
pacer = _MessagePacer(1.0, 1.0, now=0.0, name="IB_ACME_HTTP")
278+
pacer.charge(5, now=0.0) # 5 messages against a burst of 1 -> 4s of debt at 1/s
279+
with caplog.at_level(logging.WARNING, logger="messagefoundry.transports.mllp"):
280+
owed = pacer.deficit(now=0.0)
281+
assert owed > 0.0
282+
assert "IB_ACME_HTTP" in caplog.text
283+
284+
285+
def test_a_pacer_that_is_not_engaging_says_nothing(caplog: pytest.LogCaptureFixture) -> None:
286+
"""NEGATIVE CONTROL for the two tests above. Without this, a report emitted unconditionally --
287+
on every read of every paced connection, whether or not the bucket is in deficit -- would pass
288+
both of them while flooding the log of a connection that is under its rate and fine."""
289+
pacer = _MessagePacer(1000.0, 1000.0, now=0.0, name="IB_QUIET")
290+
with caplog.at_level(logging.WARNING, logger="messagefoundry.transports.mllp"):
291+
assert pacer.deficit(now=0.0) == 0.0 # well inside the burst -> nothing owed
292+
asyncio.run(pacer.pace()) # no debt held -> no sleep, no report
293+
assert caplog.text == ""
294+
295+
296+
def test_the_report_is_throttled_to_one_line_per_window(
297+
caplog: pytest.LogCaptureFixture,
298+
) -> None:
299+
"""A pacer in deficit is consulted on EVERY read, so an unthrottled line would restate one fact
300+
thousands of times and bury the log. Two applied delays inside one window produce one line."""
301+
pacer = _MessagePacer(1.0, 1.0, now=0.0, name="IB_BUSY")
302+
pacer.charge(5, now=0.0)
303+
with caplog.at_level(logging.WARNING, logger="messagefoundry.transports.mllp"):
304+
first = pacer.deficit(now=0.0) # stamp == _report_at -> reports
305+
second = pacer.deficit(now=1.0) # inside the window -> tallied, silent
306+
assert first > 0.0 and second > 0.0
307+
assert len([r for r in caplog.records if "pacing engaged" in r.getMessage()]) == 1
308+
309+
310+
def test_the_report_names_the_connection_the_source_was_wired_with() -> None:
311+
"""The name is not decoration: a pacing report an operator cannot trace to a connection tells
312+
them a feed somewhere is being held back and nothing about which one. Pins the wiring from
313+
`Source.name` through the source to the pacer, which is the half a pacer-only test cannot see."""
314+
src = _source(max_messages_per_second=5.0)
315+
assert src._pacing_name == "IB_TEST"
316+
pacer = _MessagePacer.for_rate(
317+
src.max_messages_per_second, src.message_burst, name=src._pacing_name
318+
)
319+
assert pacer is not None
320+
assert pacer._name == "IB_TEST"
321+
322+
323+
def test_for_rate_still_returns_none_when_pacing_is_off() -> None:
324+
"""POSITIVE CONTROL on the test above. If `for_rate` had stopped honouring the off-default while
325+
gaining its `name` argument, every pacing test in this file would still pass and pacing would
326+
have been silently turned on for every inbound."""
327+
assert _MessagePacer.for_rate(None, 1.0, name="IB_TEST") is None
328+
assert _MessagePacer.for_rate(0, 1.0, name="IB_TEST") is None

0 commit comments

Comments
 (0)