diff --git a/docs/config-zh.md b/docs/config-zh.md index 30277e2..07c5b67 100644 --- a/docs/config-zh.md +++ b/docs/config-zh.md @@ -43,6 +43,7 @@ | `llms[].embedding_model` | `string` | LLM 的 Embedding 模型。例如 `text-embedding-3-small`。如果用于 Embedding,则不能为空。如果此 LLM 被使用,则不能与 `model` 同时为空。**注意:** 初次使用后请勿直接修改,应添加新的 LLM 配置。 | | 条件性必需 | | `llms[].tts_model` | `string` | LLM 的文本转语音 (TTS) 模型。 | | 否 | | `llms[].temperature` | `float32` | LLM 的温度 (0-2)。 | `0.0` | 否 | +| `llms[].rpm` | `int` | 每分钟请求数限制 (Requests Per Minute)。用于控制 API 调用频率,避免超过提供商的速率限制。`0` 表示不限流。 | `0` | 否 | ### Jina AI 配置 (`jina`) diff --git a/docs/config.md b/docs/config.md index 5230c54..a6d7f3e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -43,6 +43,7 @@ This section defines the list of available Large Language Models. At least one L | `llms[].embedding_model` | `string` | Embedding model of the LLM. E.g., `text-embedding-3-small`. Cannot be empty if used for embedding. If this LLM is used, cannot be empty along with `model`. **Note:** Do not modify directly after initial use; add a new LLM configuration instead. | | Conditionally Required | | `llms[].tts_model` | `string` | The Text-to-Speech (TTS) model of the LLM. | | No | | `llms[].temperature` | `float32` | Temperature of the LLM (0-2). | `0.0` | No | +| `llms[].rpm` | `int` | Requests per minute limit. Controls API call frequency to avoid exceeding provider rate limits. `0` means no rate limiting. | `0` | No | ### Jina AI Configuration (`jina`) diff --git a/pkg/api/api.go b/pkg/api/api.go index 69f231e..fd458a5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -61,13 +61,16 @@ type API interface { ) (resp *QueryRSSHubWebsitesResponse, err error) QueryRSSHubRoutes(ctx context.Context, req *QueryRSSHubRoutesRequest) (resp *QueryRSSHubRoutesResponse, err error) + AddFeedSource(ctx context.Context, req *AddFeedSourceRequest) (resp *AddFeedSourceResponse, err error) + Write(ctx context.Context, req *WriteRequest) (resp *WriteResponse, err error) // WARN: beta!!! Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse, err error) } type Config struct { - RSSHubEndpoint string - LLM string + RSSHubEndpoint string + RSSHubAccessKey string + LLM string } func (c *Config) Validate() error { @@ -78,6 +81,7 @@ func (c *Config) Validate() error { func (c *Config) From(app *config.App) *Config { c.RSSHubEndpoint = app.Scrape.RSSHubEndpoint + c.RSSHubAccessKey = app.Scrape.RSSHubAccessKey c.LLM = app.API.LLM return c @@ -143,6 +147,12 @@ type RSSHubRoute struct { Features map[string]any `json:"features,omitempty"` } +type AddFeedSourceRequest struct { + Source config.ScrapeSource `json:"source"` +} + +type AddFeedSourceResponse struct{} + type WriteRequest struct { // Beta. Feeds []*model.Feed `json:"feeds"` } @@ -277,6 +287,19 @@ func (a *api) Reload(app *config.App) error { return nil } +// appendAccessKeyToURL adds the RSSHub access key to the URL if configured +func (a *api) appendAccessKeyToURL(url string) string { + if a.Config().RSSHubAccessKey != "" { + if strings.Contains(url, "?") { + return url + "&key=" + a.Config().RSSHubAccessKey + } + + return url + "?key=" + a.Config().RSSHubAccessKey + } + + return url +} + func (a *api) QueryAppConfigSchema( ctx context.Context, req *QueryAppConfigSchemaRequest, @@ -313,7 +336,7 @@ func (a *api) QueryRSSHubCategories( ctx context.Context, req *QueryRSSHubCategoriesRequest, ) (resp *QueryRSSHubCategoriesResponse, err error) { - url := a.Config().RSSHubEndpoint + "/api/namespace" + url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/namespace") // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -357,7 +380,7 @@ func (a *api) QueryRSSHubWebsites( return nil, ErrBadRequest(errors.New("category is required")) } - url := a.Config().RSSHubEndpoint + "/api/category/" + req.Category + url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/category/" + req.Category) // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -407,7 +430,7 @@ func (a *api) QueryRSSHubRoutes( return nil, ErrBadRequest(errors.New("website id is required")) } - url := a.Config().RSSHubEndpoint + "/api/namespace/" + req.WebsiteID + url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/namespace/" + req.WebsiteID) // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -447,6 +470,48 @@ func (a *api) QueryRSSHubRoutes( return resp, nil } +func (a *api) AddFeedSource( + ctx context.Context, + req *AddFeedSourceRequest, +) (resp *AddFeedSourceResponse, err error) { + ctx = telemetry.StartWith(ctx, append(a.TelemetryLabels(), telemetrymodel.KeyOperation, "AddFeedSource")...) + defer func() { telemetry.End(ctx, err) }() + + // Validate request. + if req.Source.Name == "" { + return nil, ErrBadRequest(errors.New("source name is required")) + } + if req.Source.RSS == nil { + return nil, ErrBadRequest(errors.New("rss config is required")) + } + if req.Source.RSS.URL == "" && req.Source.RSS.RSSHubRoutePath == "" { + return nil, ErrBadRequest(errors.New("either url or rsshub_route_path is required")) + } + if req.Source.RSS.URL != "" && req.Source.RSS.RSSHubRoutePath != "" { + return nil, ErrBadRequest(errors.New("url and rsshub_route_path cannot be set at the same time")) + } + + // Get current config. + appConfig := a.Dependencies().ConfigManager.AppConfig() + + // Check if source name already exists. + for _, source := range appConfig.Scrape.Sources { + if source.Name == req.Source.Name { + return nil, ErrBadRequest(errors.New("source name already exists")) + } + } + + // Add new source. + appConfig.Scrape.Sources = append(appConfig.Scrape.Sources, req.Source) + + // Save config. + if err := a.Dependencies().ConfigManager.SaveAppConfig(appConfig); err != nil { + return nil, ErrInternal(errors.Wrap(err, "save app config")) + } + + return &AddFeedSourceResponse{}, nil +} + func (a *api) Write(ctx context.Context, req *WriteRequest) (resp *WriteResponse, err error) { ctx = telemetry.StartWith(ctx, append(a.TelemetryLabels(), telemetrymodel.KeyOperation, "Write")...) defer func() { telemetry.End(ctx, err) }() @@ -584,6 +649,15 @@ func (m *mockAPI) QueryRSSHubRoutes( return args.Get(0).(*QueryRSSHubRoutesResponse), args.Error(1) } +func (m *mockAPI) AddFeedSource( + ctx context.Context, + req *AddFeedSourceRequest, +) (resp *AddFeedSourceResponse, err error) { + args := m.Called(ctx, req) + + return args.Get(0).(*AddFeedSourceResponse), args.Error(1) +} + func (m *mockAPI) Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse, err error) { args := m.Called(ctx, req) diff --git a/pkg/api/http/http.go b/pkg/api/http/http.go index 419ec18..1ba8b83 100644 --- a/pkg/api/http/http.go +++ b/pkg/api/http/http.go @@ -95,6 +95,7 @@ func new(instance string, app *config.App, dependencies Dependencies) (Server, e router.Handle("/query_rsshub_categories", jsonrpc.API(api.QueryRSSHubCategories)) router.Handle("/query_rsshub_websites", jsonrpc.API(api.QueryRSSHubWebsites)) router.Handle("/query_rsshub_routes", jsonrpc.API(api.QueryRSSHubRoutes)) + router.Handle("/add_feed_source", jsonrpc.API(api.AddFeedSource)) router.Handle("/query", jsonrpc.API(api.Query)) httpServer := &http.Server{Addr: config.Address, Handler: router} diff --git a/pkg/api/mcp/mcp.go b/pkg/api/mcp/mcp.go index 2349768..ca12afd 100644 --- a/pkg/api/mcp/mcp.go +++ b/pkg/api/mcp/mcp.go @@ -132,6 +132,7 @@ func new(instance string, app *config.App, dependencies Dependencies) (Server, e func registerTools(h *mcpserver.MCPServer, s *server) { registerConfigTools(h, s) registerRSSHubTools(h, s) + registerFeedSourceTools(h, s) h.AddTool(mcp.NewTool("query", mcp.WithDescription("Query feeds with semantic search. You can query any latest messages. "+ @@ -203,6 +204,26 @@ func registerRSSHubTools(h *mcpserver.MCPServer, s *server) { ), mcpserver.ToolHandlerFunc(s.queryRSSHubRoutes)) } +func registerFeedSourceTools(h *mcpserver.MCPServer, s *server) { + h.AddTool(mcp.NewTool("add_feed_source", + mcp.WithDescription("Add a new feed source to the app config. "+ + "The source will be added to the scrape.sources list. "+ + "You should confirm with the user before adding the source."), + mcp.WithString("name", + mcp.Required(), + mcp.Description("The name of the feed source. It must be unique."), + ), + mcp.WithString("url", + mcp.Description("The direct RSS feed URL. e.g. https://tech.meituan.com/feed. "+ + "Either url or rsshub_route_path must be provided, but not both."), + ), + mcp.WithString("rsshub_route_path", + mcp.Description("The RSSHub route path. e.g. telegram/channel/zrj96. "+ + "Either url or rsshub_route_path must be provided, but not both."), + ), + ), mcpserver.ToolHandlerFunc(s.addFeedSource)) +} + // --- Implementation code block --- type server struct { *component.Base[Config, Dependencies] @@ -334,6 +355,44 @@ func (s *server) queryRSSHubRoutes(ctx context.Context, req mcp.CallToolRequest) return s.response(string(b)), nil } +func (s *server) addFeedSource(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse arguments. + name, ok := req.Params.Arguments["name"].(string) + if !ok || name == "" { + return s.error(errors.New("name is required")), nil + } + + url, _ := req.Params.Arguments["url"].(string) + rsshubRoutePath, _ := req.Params.Arguments["rsshub_route_path"].(string) + + // Validate that either url or rsshub_route_path is provided. + if url == "" && rsshubRoutePath == "" { + return s.error(errors.New("either url or rsshub_route_path must be provided")), nil + } + if url != "" && rsshubRoutePath != "" { + return s.error(errors.New("url and rsshub_route_path cannot be set at the same time")), nil + } + + // Build the source config. + source := config.ScrapeSource{ + Name: name, + RSS: &config.ScrapeSourceRSS{}, + } + if url != "" { + source.RSS.URL = url + } else { + source.RSS.RSSHubRoutePath = rsshubRoutePath + } + + // Forward request to API. + _, err := s.Dependencies().API.AddFeedSource(ctx, &api.AddFeedSourceRequest{Source: source}) + if err != nil { + return s.error(errors.Wrap(err, "add feed source")), nil + } + + return s.response("Feed source added successfully. The source name is: " + name), nil +} + func (s *server) query(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Parse arguments. query, ok := req.Params.Arguments["query"].(string) diff --git a/pkg/config/config.go b/pkg/config/config.go index cbfc2a4..f2bdb17 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -92,6 +92,7 @@ type LLM struct { EmbeddingModel string `yaml:"embedding_model,omitempty" json:"embedding_model,omitempty" desc:"The embedding model of the LLM. e.g. text-embedding-3-small. Can not be empty with model at same time when api.llm is set. NOTE: Once used, do not modify it directly, instead, add a new LLM configuration."` TTSModel string `yaml:"tts_model,omitempty" json:"tts_model,omitempty" desc:"The TTS model of the LLM."` Temperature float32 `yaml:"temperature,omitempty" json:"temperature,omitempty" desc:"The temperature (0-2) of the LLM. Default: 0.0"` + RPM int `yaml:"rpm,omitempty" json:"rpm,omitempty" desc:"Requests per minute limit for this LLM. 0 means no limit. Default: 0"` } type Scrape struct { diff --git a/pkg/llm/gemini.go b/pkg/llm/gemini.go index a219860..16b81c2 100644 --- a/pkg/llm/gemini.go +++ b/pkg/llm/gemini.go @@ -36,7 +36,8 @@ import ( type gemini struct { *component.Base[Config, struct{}] text - hc *http.Client + hc *http.Client + rateLimiter RateLimiter embeddingSpliter embeddingSpliter } @@ -53,13 +54,17 @@ func newGemini(c *Config) LLM { Config: c, }) + rateLimiter := NewRateLimiter(c.RPM) + return &gemini{ Base: base, text: &openaiText{ - Base: base, - client: client, + Base: base, + client: client, + rateLimiter: rateLimiter, }, hc: &http.Client{}, + rateLimiter: rateLimiter, embeddingSpliter: embeddingSpliter, } } @@ -72,6 +77,11 @@ func (g *gemini) WAV(ctx context.Context, text string, speakers []Speaker) (r io return nil, errors.New("tts model is not set") } + // 应用限流 + if err := g.rateLimiter.Wait(ctx); err != nil { + return nil, errors.Wrap(err, "rate limiter wait") + } + reqPayload, err := buildWAVRequestPayload(text, speakers) if err != nil { return nil, errors.Wrap(err, "build wav request payload") diff --git a/pkg/llm/llm.go b/pkg/llm/llm.go index 91fb21d..0a4b1fc 100644 --- a/pkg/llm/llm.go +++ b/pkg/llm/llm.go @@ -71,6 +71,7 @@ type Config struct { APIKey string Model, EmbeddingModel, TTSModel string Temperature float32 + RPM int // Requests per minute limit } type ProviderType string @@ -200,6 +201,7 @@ func (c *FactoryConfig) From(app *config.App) { EmbeddingModel: llm.EmbeddingModel, TTSModel: llm.TTSModel, Temperature: llm.Temperature, + RPM: llm.RPM, }) } } diff --git a/pkg/llm/openai.go b/pkg/llm/openai.go index 4ae3537..8876d0e 100644 --- a/pkg/llm/openai.go +++ b/pkg/llm/openai.go @@ -33,6 +33,7 @@ import ( type openai struct { *component.Base[Config, struct{}] text + rateLimiter RateLimiter } func newOpenAI(c *Config) LLM { @@ -47,13 +48,17 @@ func newOpenAI(c *Config) LLM { Config: c, }) + rateLimiter := NewRateLimiter(c.RPM) + return &openai{ Base: base, text: &openaiText{ Base: base, client: client, embeddingSpliter: embeddingSpliter, + rateLimiter: rateLimiter, }, + rateLimiter: rateLimiter, } } @@ -66,6 +71,7 @@ type openaiText struct { client *oai.Client embeddingSpliter embeddingSpliter + rateLimiter RateLimiter } func (o *openaiText) String(ctx context.Context, messages []string) (value string, err error) { @@ -90,6 +96,11 @@ func (o *openaiText) String(ctx context.Context, messages []string) (value strin Temperature: config.Temperature, } + // 应用限流 + if err := o.rateLimiter.Wait(ctx); err != nil { + return "", errors.Wrap(err, "rate limiter wait") + } + resp, err := o.client.CreateChatCompletion(ctx, req) if err != nil { return "", errors.Wrap(err, "create chat completion") @@ -140,6 +151,11 @@ func (o *openaiText) Embedding(ctx context.Context, s string) (value []float32, if config.EmbeddingModel == "" { return nil, errors.New("embedding model is not set") } + + if err := o.rateLimiter.Wait(ctx); err != nil { + return nil, errors.Wrap(err, "rate limiter wait") + } + vec, err := o.client.CreateEmbeddings(ctx, oai.EmbeddingRequest{ Input: []string{s}, Model: oai.EmbeddingModel(config.EmbeddingModel), diff --git a/pkg/llm/ratelimiter.go b/pkg/llm/ratelimiter.go new file mode 100644 index 0000000..1ba7c72 --- /dev/null +++ b/pkg/llm/ratelimiter.go @@ -0,0 +1,148 @@ +// Copyright (C) 2025 wangyusong +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package llm + +import ( + "context" + "sync" + "time" + + "github.com/pkg/errors" + + "github.com/glidea/zenfeed/pkg/telemetry/log" +) + +// RateLimiter 定义限流器接口 +type RateLimiter interface { + // Wait 等待直到可以执行请求,如果 context 被取消则返回错误 + Wait(ctx context.Context) error + // TryAcquire 尝试获取一个令牌,如果成功返回 true,否则返回 false + TryAcquire() bool +} + +// noopRateLimiter 是一个无操作的限流器,用于没有限流需求的场景 +type noopRateLimiter struct{} + +func newNoopRateLimiter() RateLimiter { + return &noopRateLimiter{} +} + +func (n *noopRateLimiter) Wait(ctx context.Context) error { + return nil +} + +func (n *noopRateLimiter) TryAcquire() bool { + return true +} + +// tokenBucketRateLimiter 使用令牌桶算法实现的限流器 +type tokenBucketRateLimiter struct { + rpm int // 每分钟请求数限制 + interval time.Duration // 每个令牌的生成间隔 + tokens chan struct{} // 令牌桶 + mu sync.Mutex + stopCh chan struct{} + stopped bool +} + +// newTokenBucketRateLimiter 创建一个基于令牌桶算法的限流器 +// rpm: 每分钟请求数限制 +func newTokenBucketRateLimiter(rpm int) RateLimiter { + if rpm <= 0 { + return newNoopRateLimiter() + } + + // 计算每个令牌的生成间隔 + interval := time.Minute / time.Duration(rpm) + + // 令牌桶容量设置为 rpm,允许突发流量 + limiter := &tokenBucketRateLimiter{ + rpm: rpm, + interval: interval, + tokens: make(chan struct{}, rpm), + stopCh: make(chan struct{}), + } + + // 初始化令牌桶,填满令牌 + for range rpm { + limiter.tokens <- struct{}{} + } + + // 启动令牌生成器 + go limiter.refillTokens() + + log.Info(context.Background(), "rate limiter created", "rpm", rpm, "interval", interval) + + return limiter +} + +// refillTokens 定期向令牌桶中添加令牌 +func (t *tokenBucketRateLimiter) refillTokens() { + ticker := time.NewTicker(t.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // 尝试添加一个令牌,如果桶满了则丢弃 + select { + case t.tokens <- struct{}{}: + default: + // 令牌桶已满,丢弃这个令牌 + } + case <-t.stopCh: + return + } + } +} + +// Wait 等待直到可以执行请求 +func (t *tokenBucketRateLimiter) Wait(ctx context.Context) error { + select { + case <-t.tokens: + return nil + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "rate limiter wait cancelled") + case <-t.stopCh: + return errors.New("rate limiter stopped") + } +} + +// TryAcquire 尝试获取一个令牌 +func (t *tokenBucketRateLimiter) TryAcquire() bool { + select { + case <-t.tokens: + return true + default: + return false + } +} + +// Stop 停止限流器 +func (t *tokenBucketRateLimiter) Stop() { + t.mu.Lock() + defer t.mu.Unlock() + + if !t.stopped { + close(t.stopCh) + t.stopped = true + } +} + +// NewRateLimiter 根据 RPM 配置创建限流器 +func NewRateLimiter(rpm int) RateLimiter { + return newTokenBucketRateLimiter(rpm) +} diff --git a/pkg/llm/ratelimiter_test.go b/pkg/llm/ratelimiter_test.go new file mode 100644 index 0000000..32a568d --- /dev/null +++ b/pkg/llm/ratelimiter_test.go @@ -0,0 +1,149 @@ +// Copyright (C) 2025 wangyusong +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package llm + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNoopRateLimiter(t *testing.T) { + limiter := newNoopRateLimiter() + ctx := context.Background() + + // 无限制,应该立即返回 + err := limiter.Wait(ctx) + assert.NoError(t, err) + + // TryAcquire 应该总是返回 true + assert.True(t, limiter.TryAcquire()) +} + +func TestTokenBucketRateLimiter_Basic(t *testing.T) { + // 创建一个每分钟 60 个请求的限流器 (即每秒 1 个请求) + limiter := NewRateLimiter(60) + ctx := context.Background() + + // 第一个请求应该立即成功 + start := time.Now() + err := limiter.Wait(ctx) + assert.NoError(t, err) + assert.Less(t, time.Since(start), 100*time.Millisecond) +} + +func TestTokenBucketRateLimiter_RateLimit(t *testing.T) { + // 创建一个每分钟 6 个请求的限流器 (即每 10 秒 1 个请求) + rpm := 6 + limiter := NewRateLimiter(rpm) + ctx := context.Background() + + // 快速消耗所有令牌 + for i := 0; i < rpm; i++ { + assert.True(t, limiter.TryAcquire()) + } + + // 此时令牌桶应该为空,TryAcquire 应该返回 false + assert.False(t, limiter.TryAcquire()) + + // Wait 应该等待直到有新令牌 + start := time.Now() + err := limiter.Wait(ctx) + assert.NoError(t, err) + elapsed := time.Since(start) + + // 应该等待大约 10 秒 (60秒/6个请求) + expectedWait := time.Minute / time.Duration(rpm) + assert.Greater(t, elapsed, expectedWait-100*time.Millisecond) + assert.Less(t, elapsed, expectedWait+500*time.Millisecond) +} + +func TestTokenBucketRateLimiter_ContextCancellation(t *testing.T) { + // 创建一个每分钟 1 个请求的限流器 + limiter := NewRateLimiter(1) + + // 消耗唯一的令牌 + assert.True(t, limiter.TryAcquire()) + + // 创建一个会被取消的 context + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + // Wait 应该在 context 被取消时返回错误 + err := limiter.Wait(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "rate limiter wait cancelled") +} + +func TestTokenBucketRateLimiter_ZeroRPM(t *testing.T) { + // RPM 为 0 应该返回 noop 限流器 + limiter := NewRateLimiter(0) + ctx := context.Background() + + // 应该没有限制 + for i := 0; i < 100; i++ { + err := limiter.Wait(ctx) + assert.NoError(t, err) + } +} + +func TestTokenBucketRateLimiter_NegativeRPM(t *testing.T) { + // 负数 RPM 应该返回 noop 限流器 + limiter := NewRateLimiter(-10) + ctx := context.Background() + + // 应该没有限制 + for i := 0; i < 100; i++ { + err := limiter.Wait(ctx) + assert.NoError(t, err) + } +} + +func TestTokenBucketRateLimiter_Concurrent(t *testing.T) { + // 创建一个每分钟 60 个请求的限流器 + rpm := 60 + limiter := NewRateLimiter(rpm) + ctx := context.Background() + + // 并发请求 + concurrency := 10 + done := make(chan bool, concurrency) + + start := time.Now() + for i := 0; i < concurrency; i++ { + go func() { + err := limiter.Wait(ctx) + assert.NoError(t, err) + done <- true + }() + } + + // 等待所有请求完成 + for i := 0; i < concurrency; i++ { + <-done + } + + elapsed := time.Since(start) + + // 前 rpm 个请求应该立即完成,剩余的需要等待 + // 由于有 60 个初始令牌,前 60 个请求应该很快完成 + // 但我们只发送了 10 个请求,所以应该很快完成 + if concurrency <= rpm { + assert.Less(t, elapsed, 1*time.Second) + } +} diff --git a/pkg/model/model.go b/pkg/model/model.go index 32a3079..02808ff 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -171,7 +171,11 @@ func (ls Labels) MarshalJSON() ([]byte, error) { return nil, errors.Wrap(err, "write ending brace for Labels object") } - return buf.Bytes(), nil + // Make a copy of the bytes before returning the buffer to the pool + result := make([]byte, buf.Len()) + copy(result, buf.Bytes()) + + return result, nil } func (ls *Labels) UnmarshalJSON(data []byte) error {