# NousResearch/hermes-agent · Weixin iLink ret=-2 stale-session detection and retry

## Open now

- 8 issues, 7 PRs open on GitHub.
- #18100: weixin: ret=-2 with empty errmsg also indicates stale context_token (follow-up to #17228)
- #31131: \[Weixin\] Messages silently dropped under iLink rate limiting due to insufficient backoff
- #35062: \[Weixin\] ret=-3 cron push silently fails after tokenless retry (regression from v0.14)
- #35713: weixin: ret=-2 errmsg="rate limited" also indicates stale context_token (follow-up to #18100)
- #35949: Weixin iLink: stale context_token misidentified as rate limit, causing permanent send failure
- #56814: WeChat (iLink) context_token auto-refresh for proactive/cron messages
- #60116: hermes send --to weixin permanently fails: iLink sendmessage rate limited; cooldown never expires
- #62383: \[Bug\]: Weixin iLink cron delivery fails with ret=-2 'rate limited' when context_token is stale
- PR #23529: fix(weixin): propagate asyncio.TimeoutError from _get_updates to prevent zombie connections
- PR #31132: fix(weixin): exponential backoff for iLink rate limiting
- PR #35065: fix(weixin): retry on session/rate-limit errors in send_weixin_direct
- PR #35066: fix(weixin): allow tokenless retry when no cached context_token exists
- PR #35714: fix(weixin): treat 'rate limited' errmsg as stale-session signal in _is_stale_session_ret
- PR #60459: fix(gateway): reset weixin rate-limit cooldown on next attempt instead of permanent block (#60116)
- PR #62386: fix(platforms): treat ret=-2 as stale session when using context_token

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.

## TL;DR

- 29 member(s): 8 open issue(s), 5 closed issue(s), 7 open PR(s), 1 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #17432. Verify it addresses your case.
- **Recommended:** read PR #17432 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-17432`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #13305: \[Bug\]: weixin send file failed
- #17228: weixin: cron-initiated push fails with iLink ret=-2 when context_token is stale (not recognized as session-expired)
- #21011: iLink WeChat sendmessage rate limiting (ret=-2) with no built-in retry or backoff
- #48222: \[bug\]：weixin channel
- #66928: \[Bug\] Cron job delivery to Weixin always fails: iLink rate limited, fallback double-fails

## Prior & competing PRs

- **#17432: fix(weixin): recognize ret=-2 errmsg='unknown error' as stale-session signal (#17228)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#35283: fix(weixin): 修复 iLink 消息投递可靠性 — stale session、退避、zombie poll 和 typing 频率限流**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#14384: fix: avoid cross-loop session reuse in Weixin direct sends**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a22465e07ab4b71019f711e7a6463f6590c50742`
- **#17287: fix: recognize ret=-2 as stale-session signal in Weixin adapter (#17228)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `e9b96fd05`, `#17432`, `#17228`
- **#18105: fix(weixin): treat empty rate-limit message as stale session**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#20797: fix(gateway): treat weixin ret=-2 with empty errmsg as stale context_token**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#18105`
- **#23529: fix(weixin): propagate asyncio.TimeoutError from _get_updates to prevent zombie connections**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
- **#31132: fix(weixin): exponential backoff for iLink rate limiting**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#21135`, `#27697`, `#21135`, `#22540`, `#26904`, `#21126`
  - files: `docs/bug-reports/webui-full-reply.png`, `docs/bug-reports/weixin-missing-reply.png`, `gateway/platforms/weixin.py`
- **#35065: fix(weixin): retry on session/rate-limit errors in send_weixin_direct**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#35066`, `#35066`, `#35062`, `#32604`
  - files: `gateway/platforms/weixin.py`
- **#35066: fix(weixin): allow tokenless retry when no cached context_token exists**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#35062`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
- **#35714: fix(weixin): treat 'rate limited' errmsg as stale-session signal in _is_stale_session_ret**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#17228`, `#18100`, `#35713`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
- **#35983:   fix(weixin): detect stale context_token when iLink returns "rate limited" (#35949)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `7c04b5c72a`
- **#60459: fix(gateway): reset weixin rate-limit cooldown on next attempt instead of permanent block (#60116)**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/weixin.py`
- **#62386: fix(platforms): treat ret=-2 as stale session when using context_token**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#35714`, `#35714`, `#35713`, `#62386`, `#62383`, `#35714`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
- **#66943: fix(cron): wait out live adapter rate-limit cooldown before standalone fallback (#66928)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`
- **#73908: fix(weixin): recover from ret=-2 with empty stale-context errors**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#35714`

## Fix status

- Merged to main in PR #17432 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-29`.
- Subsystem: `cron`
