Send the moderator DM after responding, not before
docker-build-push / build-push (push) Successful in 5s

The first real pre-block in production exposed this. Timeline from the logs:

  00:24:54.53  Mastodon calls (signup form render)
  00:24:55.33  ipapi.is answers (cold lookup, new range)
  00:24:55.89  ip_blocks POST 200  <- block installed, 1.35s in
  00:24:56.47  statuses POST 200   <- moderator DM, 582ms
  00:24:56.53  Mastodon gives up at its 2.0s budget

The block landed comfortably inside the budget and did its job -- the range is
blocked and no account was created from it. But the answer never got back,
because a moderator DM is a whole extra Mastodon round trip sitting on the
synchronous response path.

Nothing the caller acts on depends on that DM. It now goes out as a FastAPI
BackgroundTask, which runs after the response is sent, so a cold lookup
answers in ~1.35s instead of ~1.95s. Only classification and registering the
block have to happen before we answer; telling a human can wait.

reputation_check() takes a notify callable, defaulting to sending immediately
so any non-HTTP caller keeps the old behaviour; the endpoint passes one that
collects and defers. Tests assert the block is registered before the DM goes
out, and that a DM that raises cannot stop a block being installed.
This commit is contained in:
pmb committed 2026-09-13 17:41:48 -07:00
1 parent 37feb109b3
commit 9e4456b7df
2 files changed
+66 -8

No files matched your search

+34
View File
@@ -1036,6 +1036,8 @@ def reputation_api_tests():
main.register_ip_block = (
lambda ip, acct, org, cidr="", comment="":
ipblocks.append((ip, cidr, comment)) or True)
# TestClient runs BackgroundTasks to completion before returning, so the
# deferred moderator DMs are still observable in `dms` after a request.
main.register_email_domain_block = (
lambda domain, acct: domain_blocks.append((domain, acct)) or True)
@@ -1251,6 +1253,38 @@ def reputation_api_tests():
active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]}
assert "203.0.113.0/24" not in active, "an expired block must not be published"
# N. The moderator DM is deferred until after the response. It costs a
# whole Mastodon round trip (582ms measured), and the caller is a live
# signup on a ~2s budget -- keeping it inline blew that budget the
# first time a real bad range showed up in production.
sent_order = []
main.register_ip_block = (
lambda ip, acct, org, cidr="", comment="":
sent_order.append("block") or ipblocks.append((ip, cidr, comment)) or True)
main.dm_moderator = lambda message: sent_order.append("dm") or dms.append(message)
classifications["198.51.103.7"] = ("datacenter", "Late DM Inc", True, "198.51.103.0/24")
dms.clear()
r = ask({"ip": "198.51.103.7", "source": "defer-check"})
assert r.json()["blocked"] is True, r.json()
assert sent_order == ["block", "dm"], \
f"the block must be registered before the DM is sent, got {sent_order}"
assert any("198.51.103.0/24" in d for d in dms), "the DM still goes out, just later"
# ...and the response body never depends on the DM having been sent.
main.dm_moderator = lambda message: (_ for _ in ()).throw(
RuntimeError("moderator DM must not be able to break a lookup"))
classifications["198.51.104.7"] = ("datacenter", "Broken DM Inc", True, "198.51.104.0/24")
try:
r = ask({"ip": "198.51.104.7", "source": "defer-check"})
except RuntimeError:
# TestClient surfaces background-task errors; the important part is
# that the block was still installed and the answer computed first.
pass
assert main.reputation_block_installed("ip", "198.51.104.0/24"), \
"a failing DM must not prevent the block being registered"
main.dm_moderator = lambda message: dms.append(message)
# Email domain blocks don't expire in Mastodon, so they carry none here
# either: recorded with no expiry, they stay installed indefinitely.
main.record_reputation_block("email", "rep-forever.test", "", "")