fix(feishu): invalidate cached token on auth error to enable retry recovery

The Lark SDK v3 has a bug where its built-in token retry loop does not
clear stale tokens from cache when the server returns error 99991663
(tenant_access_token invalid). This causes all API calls to fail until
the token naturally expires (~2 hours).

Add a custom tokenCache (implementing larkcore.Cache) with an
InvalidateAll() method. On any API response with code 99991663, clear
the cache so the next application-level retry fetches a fresh token.
This commit is contained in:
青柠 2026-03-10 19:43:00 +08:00
parent 86018abbd9
commit 41a0eac642

View file

@ -13,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3" lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@ -29,11 +30,17 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked
// tenant_access_token. The Lark SDK's built-in retry does not clear its cache
// on this error, so we do it ourselves.
const errCodeTenantTokenInvalid = 99991663
type FeishuChannel struct { type FeishuChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.FeishuConfig config config.FeishuConfig
client *lark.Client client *lark.Client
wsClient *larkws.Client wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
botOpenID atomic.Value // stores string; populated lazily for @mention detection botOpenID atomic.Value // stores string; populated lazily for @mention detection
@ -47,10 +54,12 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
channels.WithReasoningChannelID(cfg.ReasoningChannelID), channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
tc := newTokenCache()
ch := &FeishuChannel{ ch := &FeishuChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret), tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithTokenCache(tc)),
} }
ch.SetOwner(ch) ch.SetOwner(ch)
return ch, nil return ch, nil
@ -147,6 +156,7 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
return fmt.Errorf("feishu edit: %w", err) return fmt.Errorf("feishu edit: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -186,6 +196,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
return "", fmt.Errorf("feishu placeholder send: %w", err) return "", fmt.Errorf("feishu placeholder send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
@ -226,6 +237,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
return func() {}, fmt.Errorf("feishu react: %w", err) return func() {}, fmt.Errorf("feishu react: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Reaction API error", map[string]any{ logger.ErrorCF("feishu", "Reaction API error", map[string]any{
"emoji": chosenEmoji, "emoji": chosenEmoji,
"message_id": messageID, "message_id": messageID,
@ -470,6 +482,7 @@ func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
return fmt.Errorf("bot info parse: %w", err) return fmt.Errorf("bot info parse: %w", err)
} }
if result.Code != 0 { if result.Code != 0 {
c.invalidateTokenOnAuthError(result.Code)
return fmt.Errorf("bot info api error (code=%d)", result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code)
} }
if result.Bot.OpenID == "" { if result.Bot.OpenID == "" {
@ -612,6 +625,7 @@ func (c *FeishuChannel) downloadResource(
return "" return ""
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Resource download api error", map[string]any{ logger.ErrorCF("feishu", "Resource download api error", map[string]any{
"code": resp.Code, "code": resp.Code,
"msg": resp.Msg, "msg": resp.Msg,
@ -724,6 +738,7 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
} }
@ -749,6 +764,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
return fmt.Errorf("feishu image upload: %w", err) return fmt.Errorf("feishu image upload: %w", err)
} }
if !uploadResp.Success() { if !uploadResp.Success() {
c.invalidateTokenOnAuthError(uploadResp.Code)
return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
} }
if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
@ -773,6 +789,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
return fmt.Errorf("feishu image send: %w", err) return fmt.Errorf("feishu image send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -803,6 +820,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
return fmt.Errorf("feishu file upload: %w", err) return fmt.Errorf("feishu file upload: %w", err)
} }
if !uploadResp.Success() { if !uploadResp.Success() {
c.invalidateTokenOnAuthError(uploadResp.Code)
return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
} }
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
@ -827,6 +845,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
return fmt.Errorf("feishu file send: %w", err) return fmt.Errorf("feishu file send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -849,3 +868,55 @@ func extractFeishuSenderID(sender *larkim.EventSender) string {
return "" return ""
} }
// invalidateTokenOnAuthError clears the cached tenant_access_token when the
// Feishu API reports it as invalid (99991663), so the next request fetches a
// fresh one. The Lark SDK's built-in retry does not clear the cache, causing
// all API calls to fail until the token naturally expires (~2 hours).
func (c *FeishuChannel) invalidateTokenOnAuthError(code int) {
if code == errCodeTenantTokenInvalid {
c.tokenCache.InvalidateAll()
logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil)
}
}
// tokenCache implements larkcore.Cache with an extra InvalidateAll method.
// This works around a bug in the Lark SDK v3 where the built-in token retry
// loop does not clear stale tokens from cache on auth errors.
type tokenCache struct {
mu sync.RWMutex
store map[string]*tokenEntry
}
type tokenEntry struct {
value string
expireAt time.Time
}
func newTokenCache() *tokenCache {
return &tokenCache{store: make(map[string]*tokenEntry)}
}
func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)}
return nil
}
func (c *tokenCache) Get(_ context.Context, key string) (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.store[key]
if !ok || e.expireAt.Before(time.Now()) {
return "", nil
}
return e.value, nil
}
// InvalidateAll removes all cached tokens, forcing fresh acquisition.
func (c *tokenCache) InvalidateAll() {
c.mu.Lock()
defer c.mu.Unlock()
clear(c.store)
}