{"repo":"NousResearch/hermes-agent","public_id":"13305","display_label":"Weixin iLink ret=-2 stale-session detection and retry","summary":"These issues and fixes center on the Weixin/WeChat iLink adapter misidentifying stale context_token errors (ret=-2, various errmsg values) as rate limits or unknown errors, and the related work to add proper session-expiry detection, token refresh, retry logic, and backoff behavior.","subsystem":"cron","counts":{"members":29,"open_issues":8,"open_issues_distinct":8,"open_prs":7,"closed_issues":5,"merged_prs":1,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17432},"resolution":{"state":"partially_resolved","by":"pr-17432"},"freshness":{"state":"verified","last_verified_at":"2026-04-29T12:44:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07901477832512312},"fix_edges":[{"issue_number":16411,"pr_number":17432,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":17228,"pr_number":17432,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13305,"title":"[Bug]: weixin send file failed","url":null,"state":"closed"},{"kind":"issue","number":17228,"title":"weixin: cron-initiated push fails with iLink ret=-2 when context_token is stale (not recognized as session-expired)","url":null,"state":"closed"},{"kind":"issue","number":18100,"title":"weixin: ret=-2 with empty errmsg also indicates stale context_token (follow-up to #17228)","url":null,"state":"open"},{"kind":"issue","number":21011,"title":"iLink WeChat sendmessage rate limiting (ret=-2) with no built-in retry or backoff","url":null,"state":"closed"},{"kind":"issue","number":31131,"title":"[Weixin] Messages silently dropped under iLink rate limiting due to insufficient backoff","url":null,"state":"open"},{"kind":"issue","number":35062,"title":"[Weixin] ret=-3 cron push silently fails after tokenless retry (regression from v0.14)","url":null,"state":"open"},{"kind":"issue","number":35713,"title":"weixin: ret=-2 errmsg=\"rate limited\" also indicates stale context_token (follow-up to #18100)","url":null,"state":"open"},{"kind":"issue","number":35949,"title":"Weixin iLink: stale context_token misidentified as rate limit, causing permanent send failure","url":null,"state":"open"},{"kind":"issue","number":48222,"title":"[bug]：weixin channel","url":null,"state":"closed"},{"kind":"issue","number":56814,"title":"WeChat (iLink) context_token auto-refresh for proactive/cron messages","url":null,"state":"open"},{"kind":"issue","number":60116,"title":"hermes send --to weixin permanently fails: iLink sendmessage rate limited; cooldown never expires","url":null,"state":"open"},{"kind":"issue","number":62383,"title":"[Bug]: Weixin iLink cron delivery fails with ret=-2 'rate limited' when context_token is stale","url":null,"state":"open"},{"kind":"issue","number":66928,"title":"[Bug] Cron job delivery to Weixin always fails: iLink rate limited, fallback double-fails","url":null,"state":"closed"},{"kind":"pr","number":14384,"title":"fix: avoid cross-loop session reuse in Weixin direct sends","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a22465e07ab4b71019f711e7a6463f6590c50742","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":17287,"title":"fix: recognize ret=-2 as stale-session signal in Weixin adapter (#17228)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e9b96fd05","number":0},{"kind":"pr","value":"#17432","number":17432},{"kind":"pr","value":"#17228","number":17228}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17432,"title":"fix(weixin): recognize ret=-2 errmsg='unknown error' as stale-session signal (#17228)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","tests/gateway/test_weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -92,6 +92,18 @@\n RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry\n MESSAGE_DEDUP_TTL_SECONDS = 300\n \n+\n+def _is_stale_session_ret(\n+    ret: \"Optional[int]\", errcode: \"Optional[int]\", errmsg: \"Optional[str]\",\n+) -> bool:\n+    \"\"\"True when iLink returns ret=-2 / errcode=-2 with 'unknown error',\n+    which is a stale-session signal (same as errcode=-14) rather than\n+    a genuine rate limit.\"\"\"\n+    if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:\n+        return False\n+    return (errmsg or \"\").lower() == \"unknown error\"\n+\n+\n MEDIA_IMAGE = 1\n MEDIA_VIDEO = 2\n MEDIA_FILE = 3","@@ -1254,7 +1266,8 @@ async def _poll_loop(self) -> None:\n                 ret = response.get(\"ret\", 0)\n                 errcode = response.get(\"errcode\", 0)\n                 if ret not in (0, None) or errcode not in (0, None):\n-                    if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE:\n+                    if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE\n+                            or _is_stale_session_ret(ret, errcode, response.get(\"errmsg\"))):\n                         logger.error(\"[%s] Session expired; pausing for 10 minutes\", self.name)\n                         await asyncio.sleep(600)\n                         consecutive_failures = 0","@@ -1519,6 +1532,7 @@ async def _send_text_chunk(\n                         is_session_expired = (\n                             ret == SESSION_EXPIRED_ERRCODE\n                             or errcode == SESSION_EXPIRED_ERRCODE\n+                            or _is_stale_session_ret(ret, errcode, resp.get(\"errmsg\"))\n                         )\n                         # Session expired — strip token and retry once\n                         if is_session_expired and not retried_without_token and context_token:"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -758,3 +758,33 @@ def test_send_file_sets_voice_metadata_for_silk_payload(\n         assert voice_item[\"encode_type\"] == 6\n         assert voice_item[\"sample_rate\"] == 24000\n         assert voice_item[\"bits_per_sample\"] == 16\n+\n+\n+class TestIsStaleSessionRet:\n+    \"\"\"Regression test for #17228: distinguish stale-session ret=-2 from rate-limit ret=-2.\"\"\"\n+\n+    def test_ret_minus_2_with_unknown_error_is_stale(self):\n+        assert weixin._is_stale_session_ret(-2, None, \"unknown error\") is True\n+\n+    def test_errcode_minus_2_with_unknown_error_is_stale(self):\n+        assert weixin._is_stale_session_ret(None, -2, \"unknown error\") is True\n+\n+    def test_unknown_error_case_insensitive(self):\n+        assert weixin._is_stale_session_ret(-2, None, \"Unknown Error\") is True\n+\n+    def test_ret_minus_2_with_freq_limit_is_not_stale(self):\n+        # Genuine rate limit — must NOT be treated as stale session.\n+        assert weixin._is_stale_session_ret(-2, None, \"freq limit\") is False\n+\n+    def test_ret_minus_2_with_no_errmsg_is_not_stale(self):\n+        assert weixin._is_stale_session_ret(-2, None, None) is False\n+        assert weixin._is_stale_session_ret(-2, None, \"\") is False\n+\n+    def test_errcode_minus_14_is_not_matched_here(self):\n+        # -14 is handled by the separate SESSION_EXPIRED_ERRCODE path; the\n+        # helper only disambiguates -2 from a genuine rate limit.\n+        assert weixin._is_stale_session_ret(-14, None, \"session expired\") is False\n+\n+    def test_success_codes_are_not_stale(self):\n+        assert weixin._is_stale_session_ret(0, 0, \"\") is False\n+        assert weixin._is_stale_session_ret(None, None, \"unknown error\") is False\n"]}]},{"kind":"pr","number":18105,"title":"fix(weixin): treat empty rate-limit message as stale session","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":20797,"title":"fix(gateway): treat weixin ret=-2 with empty errmsg as stale context_token","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18105","number":18105}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":23529,"title":"fix(weixin): propagate asyncio.TimeoutError from _get_updates to prevent zombie connections","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":31132,"title":"fix(weixin): exponential backoff for iLink rate limiting","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#21135","number":21135},{"kind":"pr","value":"#27697","number":27697},{"kind":"pr","value":"#21135","number":21135},{"kind":"pr","value":"#22540","number":22540},{"kind":"pr","value":"#26904","number":26904},{"kind":"pr","value":"#21126","number":21126}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35065,"title":"fix(weixin): retry on session/rate-limit errors in send_weixin_direct","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#35066","number":35066},{"kind":"pr","value":"#35066","number":35066},{"kind":"pr","value":"#35062","number":35062},{"kind":"pr","value":"#32604","number":32604}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35066,"title":"fix(weixin): allow tokenless retry when no cached context_token exists","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#35062","number":35062}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":35283,"title":"fix(weixin): 修复 iLink 消息投递可靠性 — stale session、退避、zombie poll 和 typing 频率限流","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35714,"title":"fix(weixin): treat 'rate limited' errmsg as stale-session signal in _is_stale_session_ret","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#17228","number":17228},{"kind":"pr","value":"#18100","number":18100},{"kind":"pr","value":"#35713","number":35713}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":35983,"title":"  fix(weixin): detect stale context_token when iLink returns \"rate limited\" (#35949)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7c04b5c72a","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":60459,"title":"fix(gateway): reset weixin rate-limit cooldown on next attempt instead of permanent block (#60116)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62386,"title":"fix(platforms): treat ret=-2 as stale session when using context_token","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#35714","number":35714},{"kind":"pr","value":"#35714","number":35714},{"kind":"pr","value":"#35713","number":35713},{"kind":"pr","value":"#62386","number":62386},{"kind":"pr","value":"#62383","number":62383},{"kind":"pr","value":"#35714","number":35714}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66943,"title":"fix(cron): wait out live adapter rate-limit cooldown before standalone fallback (#66928)","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":73908,"title":"fix(weixin): recover from ret=-2 with empty stale-context errors","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35714","number":35714}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}