fix(wecom-callback): retry send with fresh token on errcode 40001/42001
When WeCom returns errcode=40001 (invalid credential) or 42001 (token expired), send() was returning a failure without evicting the bad token from _access_tokens. All subsequent sends then kept using the same invalid cached token until its TTL naturally expired (~7200s). Fix: on the first token-rejection errcode, evict the cache entry and retry once with a freshly fetched token. Non-token errcodes fail immediately as before. If the refreshed token also fails, the error is returned without looping further. Adds four regression tests covering: successful retry on 40001, successful retry on 42001, no retry on unrelated errcode, and clean failure when the refresh does not help.
This commit is contained in:
@@ -187,7 +187,6 @@ class WecomCallbackAdapter(BasePlatformAdapter):
|
|||||||
app = self._resolve_app_for_chat(chat_id)
|
app = self._resolve_app_for_chat(chat_id)
|
||||||
touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id
|
touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id
|
||||||
try:
|
try:
|
||||||
token = await self._get_access_token(app)
|
|
||||||
payload = {
|
payload = {
|
||||||
"touser": touser,
|
"touser": touser,
|
||||||
"msgtype": "text",
|
"msgtype": "text",
|
||||||
@@ -195,18 +194,31 @@ class WecomCallbackAdapter(BasePlatformAdapter):
|
|||||||
"text": {"content": content[:2048]},
|
"text": {"content": content[:2048]},
|
||||||
"safe": 0,
|
"safe": 0,
|
||||||
}
|
}
|
||||||
resp = await self._http_client.post(
|
for _attempt in range(2):
|
||||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
|
token = await self._get_access_token(app)
|
||||||
json=payload,
|
resp = await self._http_client.post(
|
||||||
)
|
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
|
||||||
data = resp.json()
|
json=payload,
|
||||||
if data.get("errcode") != 0:
|
)
|
||||||
return SendResult(success=False, error=str(data))
|
data = resp.json()
|
||||||
return SendResult(
|
errcode = data.get("errcode")
|
||||||
success=True,
|
if errcode in {40001, 42001} and _attempt == 0:
|
||||||
message_id=str(data.get("msgid", "")),
|
# WeCom rejected the token — evict the cached entry so
|
||||||
raw_response=data,
|
# the next _get_access_token call forces a fresh fetch.
|
||||||
)
|
logger.warning(
|
||||||
|
"[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing",
|
||||||
|
app.get("name", "default"), errcode,
|
||||||
|
)
|
||||||
|
self._access_tokens.pop(app["name"], None)
|
||||||
|
continue
|
||||||
|
if errcode != 0:
|
||||||
|
return SendResult(success=False, error=str(data))
|
||||||
|
return SendResult(
|
||||||
|
success=True,
|
||||||
|
message_id=str(data.get("msgid", "")),
|
||||||
|
raw_response=data,
|
||||||
|
)
|
||||||
|
return SendResult(success=False, error="send failed after token refresh")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return SendResult(success=False, error=str(exc))
|
return SendResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,130 @@ class TestWecomCallbackRouting:
|
|||||||
assert calls["json"]["agentid"] == 1001
|
assert calls["json"]["agentid"] == 1001
|
||||||
|
|
||||||
|
|
||||||
|
class TestWecomCallbackSendTokenRefresh:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_retries_with_fresh_token_on_errcode_40001(self):
|
||||||
|
"""errcode=40001 must evict the cached token, refresh, and retry once."""
|
||||||
|
adapter = WecomCallbackAdapter(_config())
|
||||||
|
adapter._access_tokens["test-app"] = {"token": "stale", "expires_at": 9999999999}
|
||||||
|
adapter._user_app_map["ww1234567890:alice"] = "test-app"
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"errcode": 40001, "errmsg": "invalid credential"},
|
||||||
|
{"errcode": 0, "msgid": "msg-ok"},
|
||||||
|
]
|
||||||
|
post_calls = []
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def post(self, url, json=None, **kw):
|
||||||
|
post_calls.append(url)
|
||||||
|
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return responses[len(post_calls) - 1]
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def get(self, url, params=None, **kw):
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return {"errcode": 0, "access_token": "fresh", "expires_in": 7200}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
adapter._http_client = FakeClient()
|
||||||
|
result = await adapter.send("ww1234567890:alice", "hello")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.message_id == "msg-ok"
|
||||||
|
assert len(post_calls) == 2
|
||||||
|
assert "fresh" in post_calls[1]
|
||||||
|
assert adapter._access_tokens["test-app"]["token"] == "fresh"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_retries_with_fresh_token_on_errcode_42001(self):
|
||||||
|
"""errcode=42001 (token expired) must also trigger the refresh-retry path."""
|
||||||
|
adapter = WecomCallbackAdapter(_config())
|
||||||
|
adapter._access_tokens["test-app"] = {"token": "expired", "expires_at": 9999999999}
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"errcode": 42001, "errmsg": "access_token expired"},
|
||||||
|
{"errcode": 0, "msgid": "msg-42"},
|
||||||
|
]
|
||||||
|
post_calls = []
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def post(self, url, json=None, **kw):
|
||||||
|
post_calls.append(url)
|
||||||
|
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return responses[len(post_calls) - 1]
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def get(self, url, params=None, **kw):
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return {"errcode": 0, "access_token": "renewed", "expires_in": 7200}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
adapter._http_client = FakeClient()
|
||||||
|
result = await adapter.send("alice", "hello")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert len(post_calls) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_does_not_retry_on_non_token_errcode(self):
|
||||||
|
"""Errors unrelated to token validity must fail immediately without retrying."""
|
||||||
|
adapter = WecomCallbackAdapter(_config())
|
||||||
|
adapter._access_tokens["test-app"] = {"token": "good", "expires_at": 9999999999}
|
||||||
|
|
||||||
|
post_calls = []
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def post(self, url, json=None, **kw):
|
||||||
|
post_calls.append(url)
|
||||||
|
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return {"errcode": 60020, "errmsg": "not allow to access"}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
adapter._http_client = FakeClient()
|
||||||
|
result = await adapter.send("alice", "hello")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert len(post_calls) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_fails_cleanly_when_retry_also_fails(self):
|
||||||
|
"""If the refreshed token is also rejected, return failure without looping further."""
|
||||||
|
adapter = WecomCallbackAdapter(_config())
|
||||||
|
adapter._access_tokens["test-app"] = {"token": "bad1", "expires_at": 9999999999}
|
||||||
|
|
||||||
|
post_calls = []
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def post(self, url, json=None, **kw):
|
||||||
|
post_calls.append(url)
|
||||||
|
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return {"errcode": 42001, "errmsg": "access_token expired"}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def get(self, url, params=None, **kw):
|
||||||
|
class R:
|
||||||
|
def json(inner):
|
||||||
|
return {"errcode": 0, "access_token": "bad2", "expires_in": 7200}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
adapter._http_client = FakeClient()
|
||||||
|
result = await adapter.send("alice", "hello")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert len(post_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
class TestWecomCallbackPollLoop:
|
class TestWecomCallbackPollLoop:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_loop_dispatches_handle_message(self, monkeypatch):
|
async def test_poll_loop_dispatches_handle_message(self, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user