SIGN IN SIGN UP

MUL-6735 fix(lark): recover from a rejected tenant_access_token instead of replaying it (#7618)

* fix(lark): recover from a rejected tenant_access_token instead of replaying it (MUL-6735)

Lark reports an invalid or expired tenant_access_token as HTTP 400 with
the code in the body:

    http 400: {"code":99991663,"msg":"Invalid access token for authorization..."}

doJSON short-circuited every non-2xx reply into an opaque transport error
without ever parsing that body, and all 13 isTokenError(resp.Code) ->
invalidateToken() branches sit behind a successful doJSON. Against the
shape Lark actually sends, every one of them was dead code: the rejected
token stayed cached, so each following card, patch and typing reaction
replayed it into the same 400. Feishu outbound stayed broken until the
backend restarted (#7611).

The existing regression test hid this — its fake answered HTTP 200 with
the error code in the body, a shape production never sees.

- doJSON parses the {code,msg} envelope out of a non-2xx body and returns
  larkAPIStatusError carrying it. The message wording is unchanged, so
  log greps keep working.
- doAuthedJSON wraps every authenticated call: on a token error it drops
  the cached entry, mints a fresh token and replays the request once.
  Refreshing alone would only have unblocked the NEXT call and still lost
  the reply that triggered it. Replaying is safe because Lark rejects a
  bad token before processing the request, so nothing was delivered.
  Mirrors dingtalk's sender, which already had this shape.
- The resource download path gets the same treatment through a two-
  attempt loop, since it builds its request outside doJSON.
- isThreadReplyUnsupported reads the code through larkErrorCode, so a
  thread code Lark delivers with a non-2xx status also classifies. Errors
  with no Lark code still return false, keeping ambiguous delivery off
  the chat-level fallback.
- Credential rotation drops the cache: re-registration issues a new
  app_secret under the same app_id, which makes Lark revoke every token
  minted from the old one while the cache key does not change.

Tests drive the real wire shape (HTTP 400 + body code) across send, the
typing reaction, resource download, a non-token 400 that must NOT touch
the cache, a permanently-rejected token that must stop after one retry,
and the rotation ordering.

Refs MUL-6735

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): classify recent-context failures by Lark code, not error text (MUL-6735)

Review follow-up on the tenant_access_token fix.

classifyRecentContextFetchError only read a code out of *APIError, so a
failure that arrives as the non-2xx reply fell through to matching error
TEXT — and that text carries Lark's JSON body (`"code":99991663`), not
the `code=99991663` the heuristics look for. A token rejection that
survives the client's bounded refresh therefore landed in telemetry as
`unknown` instead of `token_expired`.

Read the code through larkErrorCodeMsg, which finds it in either shape.
Only a code that resolves to a real category short-circuits; anything
else still falls through to the text heuristics, so a status-only signal
like "http 403" keeps classifying as permission_denied. Tests cover both:
the HTTP 400 token shape, and an unknown code that must not swallow the
status.

Also rename codeTokenExpired / codeTokenInvalid to
codeTenantTokenInvalid / codeAppTokenInvalid. The inherited names and
comment claimed 99991663 = expired and 99991664 = invalid tenant token.
Per Lark's generic error-code table, 99991663 covers BOTH expired and
invalid for tenant_access_token, and 99991664 is app_access_token — a
credential this client never mints. It stays in the class deliberately,
with the reasoning written down: it is unambiguously a rejected
credential, and being wrong costs one bounded refresh, while leaving it
out risks another wedged cache.

Refs MUL-6735

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
B
Bohan Jiang committed
c0f5650e094d80a7ba321310c950f2ded0068cbb
Parent: 76aada3
Committed by GitHub <noreply@github.com> on 8/27/2026, 5:54:52 AM