Files
gochat/backend/internal/autoassignment/rate_limiter.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

134 lines
4.3 KiB
Go

package autoassignment
// RateLimiter implements Redis-backed rate limiting for auto-assignment.
//
// Reference: Chatwoot RateLimiter (Redis key-per-assignment)
// - Tracks count of assignments per agent per inbox per time window
// - Fair distribution limit (default 5) over window (default 5 min)
// - Key format: gochat:rate_limit:{inboxID}:{agentID}:{windowKey}
// - Ensures no single agent gets too many assignments in a short time
//
// In gochat, we use Redis string keys with TTL for rate counting:
// - Key: gochat:auto_assignment_rate:{inboxID}:{agentID}:{windowStart}
// - Value: count of assignments in this window
// - TTL: window duration (so keys expire automatically)
import (
"context"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
const (
// rateLimitKeyPrefix is the Redis key prefix for rate limit counters.
rateLimitKeyPrefix = "gochat:auto_assignment_rate:"
)
// RateLimiter tracks assignment counts per agent per inbox per time window.
type RateLimiter struct {
redis *redis.Client
}
// NewRateLimiter creates a new RateLimiter.
func NewRateLimiter(rdb *redis.Client) *RateLimiter {
return &RateLimiter{redis: rdb}
}
// key returns the Redis key for rate limiting the given agent in the given inbox.
// The key includes a time window component that changes when the window expires.
func (rl *RateLimiter) key(inboxID uint, agentID uint, windowSeconds int) string {
windowStart := time.Now().Unix() / int64(windowSeconds)
return fmt.Sprintf("%s%d:%d:%d", rateLimitKeyPrefix, inboxID, agentID, windowStart)
}
// IsAllowed checks whether the given agent is allowed to receive another
// assignment in the current time window.
//
// Returns true if the agent's assignment count in this window is below the limit.
func (rl *RateLimiter) IsAllowed(ctx context.Context, inboxID uint, agentID uint, limit int, windowSeconds int) bool {
if limit <= 0 {
return true // no rate limiting configured
}
key := rl.key(inboxID, agentID, windowSeconds)
count, err := rl.redis.Get(ctx, key).Int()
if err == redis.Nil {
return true // no assignments yet in this window
}
if err != nil {
// Redis error — allow assignment to avoid blocking service
return true
}
return count < limit
}
// Increment increments the assignment count for the given agent in the
// current time window.
//
// Sets a TTL on the key equal to the window duration, so the key
// expires automatically when the window ends.
func (rl *RateLimiter) Increment(ctx context.Context, inboxID uint, agentID uint, windowSeconds int) error {
key := rl.key(inboxID, agentID, windowSeconds)
// Increment count
if err := rl.redis.Incr(ctx, key).Err(); err != nil {
return fmt.Errorf("redis Incr: %w", err)
}
// Set TTL (only on first increment in this window)
ttl, err := rl.redis.TTL(ctx, key).Result()
if err != nil {
return fmt.Errorf("redis TTL: %w", err)
}
if ttl == -1 { // key has no TTL (first increment)
if err := rl.redis.Expire(ctx, key, time.Duration(windowSeconds)*time.Second).Err(); err != nil {
return fmt.Errorf("redis Expire: %w", err)
}
}
return nil
}
// GetCount returns the current assignment count for the given agent
// in the current time window.
func (rl *RateLimiter) GetCount(ctx context.Context, inboxID uint, agentID uint, windowSeconds int) (int, error) {
key := rl.key(inboxID, agentID, windowSeconds)
count, err := rl.redis.Get(ctx, key).Int()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("redis Get: %w", err)
}
return count, nil
}
// Reset resets all rate limit counters for the given inbox.
// This is useful when inbox members change significantly.
func (rl *RateLimiter) Reset(ctx context.Context, inboxID uint) error {
pattern := fmt.Sprintf("%s%d:*", rateLimitKeyPrefix, inboxID)
keys, err := rl.redis.Keys(ctx, pattern).Result()
if err != nil {
return fmt.Errorf("redis Keys: %w", err)
}
if len(keys) > 0 {
if err := rl.redis.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("redis Del: %w", err)
}
}
return nil
}
// ParseAgentID extracts a uint agent ID from a string representation.
// Utility function for converting Redis string values to uint IDs.
func ParseAgentID(s string) (uint, error) {
id, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0, err
}
return uint(id), nil
}