]> git.aero2k.de Git - urlbot-v3.git/commitdiff
Fix NATS silently dropping messages whose routing key has an empty token master
authorThorsten <mail@aero2k.de>
Sat, 8 Aug 2026 09:58:51 +0000 (11:58 +0200)
committerThorsten <mail@aero2k.de>
Sat, 8 Aug 2026 09:58:51 +0000 (11:58 +0200)
bot.py joins shlex tokens with '.' and keeps any punctuation the body had,
so a message ending in a full stop ("<url> ... ich will das.") yields a
routing key ending in '.' - an empty trailing token. AMQP's topic exchange
routed that to '#' fine, but nats-server accepts the PUB without any error
while "classifier.>" never matches it, so the message vanished with nothing
logged on either side. Leading dots and a typed "..." (its own token) fail
the same way.

Collapse runs of dots and trim the ends in _sanitize_subject(), with a
fallback token for a key that reduces to nothing. Empty tokens carry no
information, and the binding keys plugins use still match the trimmed form.

Symptom that surfaced it: a posted YouTube URL followed by a sentence went
unanswered - and with URLResolver deliberately skipping youtube URLs, no
fallback title appeared either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/distbot/common/broker_nats.py
tests/test_unit/test_broker_nats.py

index cf37b67235cbd85a21d2713d1b4657609085e212..1c260c93f3cabde16a7900c2b0cb271f9e9b5858 100644 (file)
@@ -50,6 +50,11 @@ def _to_bytes(value) -> bytes:
 
 
 _WHITESPACE = re.compile(r"\s+")
 
 
 _WHITESPACE = re.compile(r"\s+")
+_EMPTY_TOKENS = re.compile(r"\.{2,}")
+
+# Used when a routing key sanitizes down to nothing at all (e.g. a message
+# body of just "."), since "classifier." is itself an empty trailing token.
+EMPTY_SUBJECT_TOKEN = "_"
 
 
 def _sanitize_subject(subject: str) -> str:
 
 
 def _sanitize_subject(subject: str) -> str:
@@ -62,7 +67,22 @@ def _sanitize_subject(subject: str) -> str:
     # subscriber. Since '#' is translated to ".*" by routing.matches() and
     # absorbs any character here, substituting whitespace doesn't affect
     # dispatch matching.
     # subscriber. Since '#' is translated to ".*" by routing.matches() and
     # absorbs any character here, substituting whitespace doesn't affect
     # dispatch matching.
-    return _WHITESPACE.sub("_", subject)
+    subject = _WHITESPACE.sub("_", subject)
+
+    # Same class of problem, different NATS rule: an *empty token* makes a
+    # subject undeliverable. bot.py builds routing keys by joining shlex
+    # tokens with '.', and the tokens keep any punctuation the body had, so
+    # an ordinary sentence ending in a full stop ("... ich will das.")
+    # yields "...ich.will.das." - a trailing empty token. AMQP's topic
+    # exchange routes that to '#' happily; nats-server accepts the PUB
+    # without any error but "classifier.>" then never matches it, so the
+    # message vanishes with nothing logged anywhere (confirmed live).
+    # Leading dots and runs of dots (a typed "..." becomes its own token)
+    # fail the same way. Collapsing and trimming them is safe for dispatch:
+    # empty tokens carry no information, and the binding keys plugins
+    # actually use ("nick.dice.#", "#") match the trimmed form just as well.
+    subject = _EMPTY_TOKENS.sub(".", subject).strip(".")
+    return subject or EMPTY_SUBJECT_TOKEN
 
 
 def _stream_name_for(queue: str) -> str:
 
 
 def _stream_name_for(queue: str) -> str:
index 18cd7ab5d7ffdb9c0bac17cd2b4740b5f360bb95..021d8febd65726889cbb04f30792e18b620e563e 100644 (file)
@@ -18,3 +18,23 @@ from distbot.common.broker_nats import _sanitize_subject
 ])
 def test_sanitize_subject_strips_whitespace(subject, expected):
     assert _sanitize_subject(subject) == expected
 ])
 def test_sanitize_subject_strips_whitespace(subject, expected):
     assert _sanitize_subject(subject) == expected
+
+
+@pytest.mark.parametrize("subject,expected", [
+    # An empty token makes a subject undeliverable: nats-server accepts the
+    # PUB without an error, but "classifier.>" never matches it and the
+    # message is dropped silently. The common case is a body ending in a
+    # full stop, which is why a posted URL followed by a sentence went
+    # unanswered with nothing in the logs.
+    ("httpswww.youtube.comwatchvco57sfct-h0.ich.will.das.",
+     "httpswww.youtube.comwatchvco57sfct-h0.ich.will.das"),
+    ("nick.dice.5.", "nick.dice.5"),
+    # a typed "..." becomes a token of its own
+    ("hallo.....welt", "hallo.welt"),
+    (".hallo", "hallo"),
+    ("nick.dice.5", "nick.dice.5"),
+    # a body of just "." leaves nothing to route on
+    (".", "_"),
+])
+def test_sanitize_subject_drops_empty_tokens(subject, expected):
+    assert _sanitize_subject(subject) == expected