Skip to content
Open
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/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1567,7 +1567,7 @@ async def _send_text_chunk(
or _is_stale_session_ret(ret, errcode, resp.get("errmsg"))
)
# Session expired β€” strip token and retry once
if is_session_expired and not retried_without_token and context_token:
if is_session_expired and not retried_without_token:
retried_without_token = True
context_token = None
self._token_store._cache.pop(
Expand Down
64 changes: 64 additions & 0 deletions tests/gateway/test_weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,70 @@ def test_send_message_rejects_empty_text(self):
)


class TestWeixinTokenlessRetryWithoutCachedToken:
"""Regression: tokenless retry must fire even when context_token is None.

Issue #35062: cron-initiated pushes to long-inactive chats fail because
the ``and context_token`` guard prevents the retry branch from executing
when no cached token exists. iLink accepts tokenless sends as a degraded
fallback, so the guard should only check ``not retried_without_token``.
"""

@patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
def test_tokenless_retry_fires_when_context_token_is_none(self, send_mock):
"""Session-expired + no cached token β†’ retry fires, then succeeds."""
adapter = _make_adapter()
adapter._send_session = object()
adapter._token = "test-token"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current _is_stale_session_ret() only treats ret/errcode=-2 with errmsg='unknown error' as stale; ret=-3 falls through to the error path even after this guard removal. Please use the supported -2 stale-session fixture here (the separate errcode=-14 test already covers the other recognized branch).

adapter._base_url = "https://weixin.example.com"
# No cached context_token for this chat
adapter._token_store._cache = {}

# First call: stale session (ret=-3, errmsg=unknown error)
# Second call: success
send_mock.side_effect = [
{"ret": -3, "errmsg": "unknown error"},
{"ret": 0},
]

# Should NOT raise β€” retry fires and second call succeeds
asyncio.run(adapter._send_text_chunk(
chat_id="wxid_test",
chunk="hello",
context_token=None,
client_id="cid",
))

# Two calls: initial attempt + tokenless retry
assert send_mock.await_count == 2
# Second call should have context_token=None (already was, but confirm)
second_call_kwargs = send_mock.await_args_list[1].kwargs
assert second_call_kwargs.get("context_token") is None

@patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
def test_tokenless_retry_fires_for_errcode_minus_14(self, send_mock):
"""errcode=-14 (classic session expired) + no cached token β†’ retry fires."""
adapter = _make_adapter()
adapter._send_session = object()
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._token_store._cache = {}

send_mock.side_effect = [
{"errcode": -14},
{"ret": 0},
]

asyncio.run(adapter._send_text_chunk(
chat_id="wxid_test",
chunk="hello",
context_token=None,
client_id="cid",
))

assert send_mock.await_count == 2


class TestWeixinStreamingCursorSuppression:
"""WeChat doesn't support message editing β€” cursor must be suppressed."""

Expand Down
Loading