Weixin iLink ret=-2 stale-session detection and retry

NousResearch/hermes-agent · cluster 13305 · cron

Data as of

15 open items. Fix merged in #17432. Check it covers your case.

The short answer

8open issues
7open PRs
29items grouped
Known?
Yes. 29 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #17432. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #17432. That is separate from the fix event.
Still open
8 issues and 7 PRs.

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.

Open issues8

  • Issue#18100weixin: ret=-2 with empty errmsg also indicates stale context_token (follow-up to #17228)
  • Issue#31131[Weixin] Messages silently dropped under iLink rate limiting due to insufficient backoff
  • Issue#35062[Weixin] ret=-3 cron push silently fails after tokenless retry (regression from v0.14)
  • Issue#35713weixin: ret=-2 errmsg="rate limited" also indicates stale context_token (follow-up to #18100)
  • Issue#35949Weixin iLink: stale context_token misidentified as rate limit, causing permanent send failure
  • Issue#56814WeChat (iLink) context_token auto-refresh for proactive/cron messages
  • Issue#60116hermes send --to weixin permanently fails: iLink sendmessage rate limited; cooldown never expires
  • Issue#62383[Bug]: Weixin iLink cron delivery fails with ret=-2 'rate limited' when context_token is stale

Open PRs7

  • PR#23529fix(weixin): propagate asyncio.TimeoutError from _get_updates to prevent zombie connections
  • PR#31132fix(weixin): exponential backoff for iLink rate limiting
  • PR#35065fix(weixin): retry on session/rate-limit errors in send_weixin_direct
  • PR#35066fix(weixin): allow tokenless retry when no cached context_token exists
  • PR#35714fix(weixin): treat 'rate limited' errmsg as stale-session signal in _is_stale_session_ret
  • PR#60459fix(gateway): reset weixin rate-limit cooldown on next attempt instead of permanent block (#60116)
  • PR#62386fix(platforms): treat ret=-2 as stale session when using context_token

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #17432 fix(weixin): recognize ret=-2 errmsg='unknown error' as stale-session signal (#17228)

    merged to main

    files: gateway/platforms/weixin.py, tests/gateway/test_weixin.py

    Show the diff
    • gateway/platforms/weixin.py
      @@ -92,6 +92,18 @@
       RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry
       MESSAGE_DEDUP_TTL_SECONDS = 300
       
      +
      +def _is_stale_session_ret(
      +    ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]",
      +) -> bool:
      +    """True when iLink returns ret=-2 / errcode=-2 with 'unknown error',
      +    which is a stale-session signal (same as errcode=-14) rather than
      +    a genuine rate limit."""
      +    if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
      +        return False
      +    return (errmsg or "").lower() == "unknown error"
      +
      +
       MEDIA_IMAGE = 1
       MEDIA_VIDEO = 2
       MEDIA_FILE = 3
      @@ -1254,7 +1266,8 @@ async def _poll_loop(self) -> None:
                       ret = response.get("ret", 0)
                       errcode = response.get("errcode", 0)
                       if ret not in (0, None) or errcode not in (0, None):
      -                    if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE:
      +                    if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE
      +                            or _is_stale_session_ret(ret, errcode, response.get("errmsg"))):
                               logger.error("[%s] Session expired; pausing for 10 minutes", self.name)
                               await asyncio.sleep(600)
                               consecutive_failures = 0
      @@ -1519,6 +1532,7 @@ async def _send_text_chunk(
                               is_session_expired = (
                                   ret == SESSION_EXPIRED_ERRCODE
                                   or errcode == SESSION_EXPIRED_ERRCODE
      +                            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:
    • tests/gateway/test_weixin.py
      @@ -758,3 +758,33 @@ def test_send_file_sets_voice_metadata_for_silk_payload(
               assert voice_item["encode_type"] == 6
               assert voice_item["sample_rate"] == 24000
               assert voice_item["bits_per_sample"] == 16
      +
      +
      +class TestIsStaleSessionRet:
      +    """Regression test for #17228: distinguish stale-session ret=-2 from rate-limit ret=-2."""
      +
      +    def test_ret_minus_2_with_unknown_error_is_stale(self):
      +        assert weixin._is_stale_session_ret(-2, None, "unknown error") is True
      +
      +    def test_errcode_minus_2_with_unknown_error_is_stale(self):
      +        assert weixin._is_stale_session_ret(None, -2, "unknown error") is True
      +
      +    def test_unknown_error_case_insensitive(self):
      +        assert weixin._is_stale_session_ret(-2, None, "Unknown Error") is True
      +
      +    def test_ret_minus_2_with_freq_limit_is_not_stale(self):
      +        # Genuine rate limit — must NOT be treated as stale session.
      +        assert weixin._is_stale_session_ret(-2, None, "freq limit") is False
      +
      +    def test_ret_minus_2_with_no_errmsg_is_not_stale(self):
      +        assert weixin._is_stale_session_ret(-2, None, None) is False
      +        assert weixin._is_stale_session_ret(-2, None, "") is False
      +
      +    def test_errcode_minus_14_is_not_matched_here(self):
      +        # -14 is handled by the separate SESSION_EXPIRED_ERRCODE path; the
      +        # helper only disambiguates -2 from a genuine rate limit.
      +        assert weixin._is_stale_session_ret(-14, None, "session expired") is False
      +
      +    def test_success_codes_are_not_stale(self):
      +        assert weixin._is_stale_session_ret(0, 0, "") is False
      +        assert weixin._is_stale_session_ret(None, None, "unknown error") is False

8 PRs closed without merging

From the close comments: duplicate 2 · merged via another change 2 · done elsewhere 1 · stale or abandoned 1 · superseded by another PR 1 · other 1

  • #14384 fix: avoid cross-loop session reuse in Weixin direct sends · done elsewhere → a22465e
  • #17287 fix: recognize ret=-2 as stale-session signal in Weixin adapter (#17228) · merged via another change → e9b96fd, #17432, #17228
  • #18105 fix(weixin): treat empty rate-limit message as stale session · stale or abandoned
  • #20797 fix(gateway): treat weixin ret=-2 with empty errmsg as stale context_token · duplicate → #18105
  • #35283 fix(weixin): 修复 iLink 消息投递可靠性 — stale session、退避、zombie poll 和 typing 频率限流 · other
Show 3 more
  • #35983 fix(weixin): detect stale context_token when iLink returns "rate limited" (#35949) · superseded by another PR → 7c04b5c
  • #66943 fix(cron): wait out live adapter rate-limit cooldown before standalone fallback (#66928) · merged via another change
  • #73908 fix(weixin): recover from ret=-2 with empty stale-context errors · duplicate → #35714
Closed issues · 5
  • Closed#13305[Bug]: weixin send file failed
  • Closed#17228weixin: cron-initiated push fails with iLink ret=-2 when context_token is stale (not recognized as session-expired)
  • Closed#21011iLink WeChat sendmessage rate limiting (ret=-2) with no built-in retry or backoff
  • Closed#48222[bug]:weixin channel
  • Closed#66928[Bug] Cron job delivery to Weixin always fails: iLink rate limited, fallback double-fails
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.08: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
cron
Merged PRs
#17432
Closed issues
5
Attempts
8 PRs closed without merging
Fix link checked
29 Apr 2026

For your agent

The same facts, typed.