fix(feishu): retry rest calls after auth expiry

This commit is contained in:
Alix-007 2026-03-17 15:13:56 +08:00
parent 4a8a2e9c23
commit 88668173dc
2 changed files with 374 additions and 32 deletions

View file

@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@ -31,9 +32,10 @@ import (
type FeishuChannel struct {
*channels.BaseChannel
config config.FeishuConfig
client *lark.Client
wsClient *larkws.Client
config config.FeishuConfig
client *lark.Client
wsClient *larkws.Client
newClient func() *lark.Client
botOpenID atomic.Value // stores string; populated lazily for @mention detection
@ -42,6 +44,9 @@ type FeishuChannel struct {
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
newClient := func() *lark.Client {
return lark.NewClient(cfg.AppID, cfg.AppSecret)
}
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
@ -50,12 +55,81 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
client: newClient(),
newClient: newClient,
}
ch.SetOwner(ch)
return ch, nil
}
const feishuInvalidAccessTokenCode = 99991663
type feishuRespMetaExtractor[T any] func(T) (code int, msg string, ok bool)
func (c *FeishuChannel) currentRESTClient() *lark.Client {
c.mu.Lock()
defer c.mu.Unlock()
return c.client
}
func (c *FeishuChannel) refreshRESTClient() *lark.Client {
c.mu.Lock()
defer c.mu.Unlock()
builder := c.newClient
if builder == nil {
builder = func() *lark.Client {
return lark.NewClient(c.config.AppID, c.config.AppSecret)
}
}
c.client = builder()
return c.client
}
func shouldRetryFeishuAuth[T any](resp T, err error, extract feishuRespMetaExtractor[T]) bool {
if extract != nil {
if code, msg, ok := extract(resp); ok && isFeishuAuthFailure(code, msg) {
return true
}
}
if err == nil {
return false
}
return isFeishuAuthFailure(0, err.Error())
}
func isFeishuAuthFailure(code int, msg string) bool {
if code == feishuInvalidAccessTokenCode {
return true
}
msg = strings.ToLower(strings.TrimSpace(msg))
if msg == "" {
return false
}
return strings.Contains(msg, "99991663") ||
strings.Contains(msg, "invalid access token") ||
strings.Contains(msg, "token attached")
}
func withRESTClientAuthRetry[T any](
c *FeishuChannel,
call func(*lark.Client) (T, error),
extract feishuRespMetaExtractor[T],
) (T, error) {
client := c.currentRESTClient()
resp, err := call(client)
if !shouldRetryFeishuAuth(resp, err, extract) {
return resp, err
}
logger.WarnCF("feishu", "REST auth token rejected, recreating client and retrying once", nil)
client = c.refreshRESTClient()
return call(client)
}
func (c *FeishuChannel) Start(ctx context.Context) error {
if c.config.AppID == "" || c.config.AppSecret == "" {
return fmt.Errorf("feishu app_id or app_secret is empty")
@ -142,7 +216,18 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
Build()
resp, err := c.client.Im.V1.Message.Patch(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.PatchMessageResp, error) {
return client.Im.V1.Message.Patch(ctx, req)
},
func(resp *larkim.PatchMessageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu edit: %w", err)
}
@ -181,7 +266,18 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateMessageResp, error) {
return client.Im.V1.Message.Create(ctx, req)
},
func(resp *larkim.CreateMessageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return "", fmt.Errorf("feishu placeholder send: %w", err)
}
@ -216,7 +312,18 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
Build()).
Build()
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateMessageReactionResp, error) {
return client.Im.V1.MessageReaction.Create(ctx, req)
},
func(resp *larkim.CreateMessageReactionResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
"emoji": chosenEmoji,
@ -252,7 +359,18 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
MessageId(messageID).
ReactionId(reactionID).
Build()
_, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq)
_, _ = withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.DeleteMessageReactionResp, error) {
return client.Im.V1.MessageReaction.Delete(context.Background(), delReq)
},
func(resp *larkim.DeleteMessageReactionResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
}
return undo, nil
}
@ -432,11 +550,30 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/bot/v3/info",
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkcore.ApiResp, error) {
return client.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/bot/v3/info",
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
},
func(resp *larkcore.ApiResp) (int, string, bool) {
if resp == nil || len(resp.RawBody) == 0 {
return 0, "", false
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(resp.RawBody, &result); err != nil {
return 0, "", false
}
return result.Code, result.Msg, true
},
)
if err != nil {
return fmt.Errorf("bot info request: %w", err)
}
@ -583,7 +720,18 @@ func (c *FeishuChannel) downloadResource(
Type(resourceType).
Build()
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.GetMessageResourceResp, error) {
return client.Im.V1.MessageResource.Get(ctx, req)
},
func(resp *larkim.GetMessageResourceResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
"message_id": messageID,
@ -699,7 +847,18 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateMessageResp, error) {
return client.Im.V1.Message.Create(ctx, req)
},
func(resp *larkim.CreateMessageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
}
@ -718,14 +877,29 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
// sendImage uploads an image and sends it as a message.
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
// Upload image to get image_key
uploadReq := larkim.NewCreateImageReqBuilder().
Body(larkim.NewCreateImageReqBodyBuilder().
ImageType("message").
Image(file).
Build()).
Build()
uploadResp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateImageResp, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("feishu image upload seek: %w", err)
}
uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq)
uploadReq := larkim.NewCreateImageReqBuilder().
Body(larkim.NewCreateImageReqBodyBuilder().
ImageType("message").
Image(file).
Build()).
Build()
return client.Im.V1.Image.Create(ctx, uploadReq)
},
func(resp *larkim.CreateImageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu image upload: %w", err)
}
@ -749,7 +923,18 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateMessageResp, error) {
return client.Im.V1.Message.Create(ctx, req)
},
func(resp *larkim.CreateMessageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu image send: %w", err)
}
@ -771,15 +956,30 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
}
// Upload file to get file_key
uploadReq := larkim.NewCreateFileReqBuilder().
Body(larkim.NewCreateFileReqBodyBuilder().
FileType(feishuFileType).
FileName(filename).
File(file).
Build()).
Build()
uploadResp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateFileResp, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("feishu file upload seek: %w", err)
}
uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq)
uploadReq := larkim.NewCreateFileReqBuilder().
Body(larkim.NewCreateFileReqBodyBuilder().
FileType(feishuFileType).
FileName(filename).
File(file).
Build()).
Build()
return client.Im.V1.File.Create(ctx, uploadReq)
},
func(resp *larkim.CreateFileResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu file upload: %w", err)
}
@ -803,7 +1003,18 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
resp, err := withRESTClientAuthRetry(
c,
func(client *lark.Client) (*larkim.CreateMessageResp, error) {
return client.Im.V1.Message.Create(ctx, req)
},
func(resp *larkim.CreateMessageResp) (int, string, bool) {
if resp == nil {
return 0, "", false
}
return resp.Code, resp.Msg, true
},
)
if err != nil {
return fmt.Errorf("feishu file send: %w", err)
}

View file

@ -3,11 +3,19 @@
package feishu
import (
"context"
"errors"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
type feishuRetryResp struct {
code int
msg string
}
func TestExtractContent(t *testing.T) {
tests := []struct {
name string
@ -254,3 +262,126 @@ func TestExtractFeishuSenderID(t *testing.T) {
})
}
}
func TestWithRESTClientAuthRetry_RecreatesClientOnInvalidAuth(t *testing.T) {
first := &lark.Client{}
second := &lark.Client{}
newClientCalls := 0
ch := &FeishuChannel{
client: first,
newClient: func() *lark.Client {
newClientCalls++
return second
},
}
attempts := 0
resp, err := withRESTClientAuthRetry(
ch,
func(client *lark.Client) (feishuRetryResp, error) {
attempts++
switch attempts {
case 1:
if client != first {
t.Fatalf("first attempt used %p, want %p", client, first)
}
return feishuRetryResp{
code: feishuInvalidAccessTokenCode,
msg: "Invalid access token for authorization. Please make a request with token attached.",
}, nil
case 2:
if client != second {
t.Fatalf("second attempt used %p, want %p", client, second)
}
return feishuRetryResp{}, nil
default:
t.Fatalf("unexpected attempt %d", attempts)
return feishuRetryResp{}, nil
}
},
func(resp feishuRetryResp) (int, string, bool) {
return resp.code, resp.msg, true
},
)
if err != nil {
t.Fatalf("withRESTClientAuthRetry() error = %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
if newClientCalls != 1 {
t.Fatalf("newClientCalls = %d, want 1", newClientCalls)
}
if ch.currentRESTClient() != second {
t.Fatal("expected channel client to be replaced after auth retry")
}
if resp.code != 0 || resp.msg != "" {
t.Fatalf("final response = %+v, want success response", resp)
}
}
func TestWithRESTClientAuthRetry_DoesNotRetryNonAuthError(t *testing.T) {
first := &lark.Client{}
newClientCalls := 0
expectedErr := errors.New("network timeout")
ch := &FeishuChannel{
client: first,
newClient: func() *lark.Client {
newClientCalls++
return &lark.Client{}
},
}
attempts := 0
_, err := withRESTClientAuthRetry(
ch,
func(client *lark.Client) (feishuRetryResp, error) {
attempts++
if client != first {
t.Fatalf("attempt used %p, want %p", client, first)
}
return feishuRetryResp{}, expectedErr
},
func(resp feishuRetryResp) (int, string, bool) {
return resp.code, resp.msg, true
},
)
if !errors.Is(err, expectedErr) {
t.Fatalf("withRESTClientAuthRetry() error = %v, want %v", err, expectedErr)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
if newClientCalls != 0 {
t.Fatalf("newClientCalls = %d, want 0", newClientCalls)
}
if ch.currentRESTClient() != first {
t.Fatal("expected channel client to remain unchanged for non-auth error")
}
}
func TestShouldRetryFeishuAuth_ErrorMessageFallback(t *testing.T) {
retry := shouldRetryFeishuAuth(
feishuRetryResp{},
context.DeadlineExceeded,
func(resp feishuRetryResp) (int, string, bool) {
return resp.code, resp.msg, false
},
)
if retry {
t.Fatal("shouldRetryFeishuAuth() retried unexpected non-auth error")
}
retry = shouldRetryFeishuAuth(
feishuRetryResp{},
errors.New("code=99991663 msg=Invalid access token for authorization"),
func(resp feishuRetryResp) (int, string, bool) {
return resp.code, resp.msg, false
},
)
if !retry {
t.Fatal("shouldRetryFeishuAuth() should retry invalid access token errors")
}
}