Files
goclaw/internal/tools/rate_limiter_test.go
Zezae Oh eaa011dae3 feat(agent): honor per-agent tools.rate_limit_per_hour (#1263)
The agent config UI (tools-profile-section) and i18n already expose a
per-agent "Rate Limit (per hour)" field saved into tools_config, but the
backend ignored it — the tool rate limiter only ever used the global
tools.rate_limit_per_hour. Wire the field through: ToolPolicySpec gains
RateLimitPerHour; the agent loop threads it into the tool-execution
context; the registry passes it to the limiter, which uses it in place
of the global max when > 0 (0 inherits the global).
2026-06-23 12:53:33 +07:00

154 lines
3.4 KiB
Go

package tools
import (
"testing"
"time"
)
func TestNewToolRateLimiter_Zero(t *testing.T) {
rl := NewToolRateLimiter(0)
if rl != nil {
t.Errorf("expected nil for maxPerHour=0, got %v", rl)
}
}
func TestNewToolRateLimiter_Negative(t *testing.T) {
rl := NewToolRateLimiter(-5)
if rl != nil {
t.Errorf("expected nil for maxPerHour=-5, got %v", rl)
}
}
func TestToolRateLimiter_AllowUnderLimit(t *testing.T) {
rl := NewToolRateLimiter(5)
for i := range 5 {
if err := rl.Allow("user1"); err != nil {
t.Errorf("action %d should be allowed: %v", i, err)
}
}
}
func TestToolRateLimiter_BlockOverLimit(t *testing.T) {
rl := NewToolRateLimiter(3)
for i := range 3 {
if err := rl.Allow("user1"); err != nil {
t.Fatalf("action %d should be allowed: %v", i, err)
}
}
err := rl.Allow("user1")
if err == nil {
t.Error("4th action should be blocked")
}
}
func TestToolRateLimiter_SeparateKeys(t *testing.T) {
rl := NewToolRateLimiter(2)
// Fill user1
rl.Allow("user1")
rl.Allow("user1")
// user1 is blocked
if err := rl.Allow("user1"); err == nil {
t.Error("user1 should be blocked")
}
// user2 is independent
if err := rl.Allow("user2"); err != nil {
t.Errorf("user2 should be allowed: %v", err)
}
}
func TestToolRateLimiter_AllowWithLimit_Override(t *testing.T) {
rl := NewToolRateLimiter(100) // global default 100
// A per-agent override of 2 caps this key at 2, regardless of the global 100.
if err := rl.AllowWithLimit("agentA", 2); err != nil {
t.Fatalf("call 1 should be allowed: %v", err)
}
if err := rl.AllowWithLimit("agentA", 2); err != nil {
t.Fatalf("call 2 should be allowed: %v", err)
}
if err := rl.AllowWithLimit("agentA", 2); err == nil {
t.Error("call 3 should be blocked by the override of 2")
}
// maxOverride <= 0 falls back to the configured global (100), on its own key.
for i := range 3 {
if err := rl.AllowWithLimit("agentB", 0); err != nil {
t.Fatalf("agentB call %d should use global 100: %v", i, err)
}
}
}
func TestToolRateLimiter_WindowExpiry(t *testing.T) {
rl := &ToolRateLimiter{
windows: make(map[string][]time.Time),
maxPerHr: 2,
window: 100 * time.Millisecond, // short window for testing
}
// Fill the window
rl.Allow("key1")
rl.Allow("key1")
if err := rl.Allow("key1"); err == nil {
t.Error("should be blocked at limit")
}
// Wait for window to expire
time.Sleep(150 * time.Millisecond)
if err := rl.Allow("key1"); err != nil {
t.Errorf("should be allowed after window expiry: %v", err)
}
}
func TestToolRateLimiter_Cleanup(t *testing.T) {
rl := &ToolRateLimiter{
windows: make(map[string][]time.Time),
maxPerHr: 10,
window: 50 * time.Millisecond,
}
rl.Allow("key1")
rl.Allow("key2")
time.Sleep(100 * time.Millisecond)
rl.Cleanup()
rl.mu.Lock()
count := len(rl.windows)
rl.mu.Unlock()
if count != 0 {
t.Errorf("cleanup should remove all expired entries, got %d", count)
}
}
func TestToolRateLimiter_CleanupPartial(t *testing.T) {
rl := &ToolRateLimiter{
windows: make(map[string][]time.Time),
maxPerHr: 10,
window: 200 * time.Millisecond,
}
rl.Allow("key1") // will expire
time.Sleep(100 * time.Millisecond)
rl.Allow("key1") // still fresh
// Only the first entry should be pruned, not the whole key
time.Sleep(150 * time.Millisecond)
rl.Cleanup()
rl.mu.Lock()
entries := len(rl.windows["key1"])
rl.mu.Unlock()
if entries != 1 {
t.Errorf("expected 1 remaining entry, got %d", entries)
}
}