Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gateway/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def _classify_dead_from_error_text(error_text: Optional[str]) -> Optional[str]:
# Only a whole-chat not_found means the target is dead — a deleted forum topic
# or an edited-away message must not mark the entire chat (and all of its future
# deliveries) dead. See gateway.dead_targets' documented scope.
if kind == "not_found" and not is_chat_level_not_found(error_text):
if kind == "not_found" and not is_chat_level_not_found(error_text=error_text):
return None
return kind

Expand Down
66 changes: 43 additions & 23 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,26 @@ class SendResult:
)


def _error_blob(exc: Optional[BaseException] = None, error_text: str = "") -> str:
"""Build the lowercased text blob both send-error classifiers match against.

Single source of truth so ``classify_send_error`` and
``is_chat_level_not_found`` can never drift (e.g. one including the
exception class name and the other not) and silently disagree on the same
failure. Includes ``str(exc)`` (when non-empty) and the exception's class
name, plus any explicit ``error_text``.
"""
parts = []
if error_text:
parts.append(error_text)
if exc is not None:
exc_str = str(exc)
if exc_str:
parts.append(exc_str)
parts.append(exc.__class__.__name__)
return " ".join(parts).lower()


def classify_send_error(exc: Optional[BaseException], error_text: str = "") -> str:
"""Map a send exception / error string to a :data:`SEND_ERROR_KINDS` value.

Expand All @@ -1939,13 +1959,7 @@ def classify_send_error(exc: Optional[BaseException], error_text: str = "") -> s
use. Conservative — anything unrecognized returns ``"unknown"`` so callers
never mistake an unclassified failure for a benign one.
"""
parts = []
if error_text:
parts.append(error_text)
if exc is not None:
parts.append(str(exc))
parts.append(exc.__class__.__name__)
blob = " ".join(parts).lower()
blob = _error_blob(exc, error_text)
if not blob.strip():
return "unknown"
if "message_too_long" in blob or "too long" in blob or "message is too long" in blob:
Expand Down Expand Up @@ -1988,7 +2002,7 @@ def classify_send_error(exc: Optional[BaseException], error_text: str = "") -> s
return "unknown"


def is_chat_level_not_found(error_text: str = "", exc: Optional[BaseException] = None) -> bool:
def is_chat_level_not_found(exc: Optional[BaseException] = None, error_text: str = "") -> bool:
"""Whether a ``not_found`` failure means the *whole chat* is gone.

:func:`classify_send_error` collapses chat-level and thread/topic/message-level
Expand All @@ -1997,13 +2011,12 @@ def is_chat_level_not_found(error_text: str = "", exc: Optional[BaseException] =
forum topic or an edited-away message leaves the parent chat reachable. When
both a chat-level and a sub-chat marker are present, the sub-chat reading wins
(conservative: never kill a chat that may still be reachable).

Argument order mirrors :func:`classify_send_error` (``exc`` first) and both
share :func:`_error_blob`, so the two classifiers cannot disagree on the same
failure.
"""
parts = []
if error_text:
parts.append(error_text)
if exc is not None:
parts.append(str(exc))
blob = " ".join(parts).lower()
blob = _error_blob(exc, error_text)
if any(s in blob for s in _SUBCHAT_NOT_FOUND_SUBSTRINGS):
return False
return any(s in blob for s in _CHAT_LEVEL_NOT_FOUND_SUBSTRINGS)
Expand Down Expand Up @@ -3933,15 +3946,22 @@ def register_post_delivery_callback(
_prev = existing_cb
_new = callback

def _chained() -> None:
try:
_prev()
except Exception:
logger.debug("Post-delivery callback failed", exc_info=True)
try:
_new()
except Exception:
logger.debug("Post-delivery callback failed", exc_info=True)
async def _chained() -> None:
# Both _prev and _new may be sync or async. The chained
# wrapper itself must be async because the outer invoker
# (``_handle_message`` etc.) awaits awaitable callbacks; a
# sync wrapper here would call ``_prev()`` / ``_new()`` and
# silently drop any returned coroutine, breaking chained
# async post-delivery hooks (e.g. ``/goal`` continuations).
for _cb in (_prev, _new):
try:
_result = _cb()
if inspect.isawaitable(_result):
await _result
except Exception:
logger.debug(
"Post-delivery callback failed", exc_info=True
)

callback = _chained

Expand Down
22 changes: 19 additions & 3 deletions tests/gateway/test_dead_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,19 +225,19 @@ class TestNotFoundBlastRadius:
def test_is_chat_level_not_found_chat_level(self):
from gateway.platforms.base import is_chat_level_not_found

assert is_chat_level_not_found("Bad Request: chat not found") is True
assert is_chat_level_not_found(error_text="Bad Request: chat not found") is True

@pytest.mark.parametrize("message", _SUBCHAT_NOT_FOUND_MESSAGES)
def test_is_chat_level_not_found_subchat(self, message):
from gateway.platforms.base import is_chat_level_not_found

assert is_chat_level_not_found(message) is False
assert is_chat_level_not_found(error_text=message) is False

def test_subchat_marker_wins_when_both_present(self):
from gateway.platforms.base import is_chat_level_not_found

# Conservative: if a sub-chat marker is present, never kill the whole chat.
assert is_chat_level_not_found("chat not found; message thread not found") is False
assert is_chat_level_not_found(error_text="chat not found; message thread not found") is False

def test_classify_dead_from_error_text_gates_not_found(self):
from gateway.delivery import _classify_dead_from_error_text
Expand All @@ -246,3 +246,19 @@ def test_classify_dead_from_error_text_gates_not_found(self):
assert _classify_dead_from_error_text("Bad Request: chat not found") == "not_found"
assert _classify_dead_from_error_text("Bad Request: message thread not found") is None
assert _classify_dead_from_error_text("httpx.ReadTimeout: connection timed out") is None

def test_error_blob_is_shared_source_of_truth(self):
# Regression guard: classify_send_error and is_chat_level_not_found must
# both derive their match text from the SAME _error_blob helper (which
# includes the exception CLASS NAME), so they can never drift. Before
# this consolidation is_chat_level_not_found built its own blob from
# str(exc) only, omitting the class name classify_send_error included.
from gateway.platforms import base

class TopicDeleted(Exception):
pass

# Empty message: the only signal is the class name — _error_blob keeps it,
# with no stray leading space from an empty str(exc).
assert base._error_blob(TopicDeleted()) == "topicdeleted"
assert base._error_blob(TopicDeleted("boom")) == "boom topicdeleted"
Loading