package gateway import ( "context" "errors" "fmt" "strconv" "time" "aigateway.local/core/internal/apikey" "github.com/redis/go-redis/v9" ) var ErrAdmissionUnavailable = errors.New("admission control unavailable") type AdmissionReason string const ( AdmissionAllowed AdmissionReason = "" AdmissionRateLimit AdmissionReason = "rate_limit" AdmissionMonthlyQuota AdmissionReason = "monthly_quota" ) type AdmissionDecision struct { Allowed bool Reason AdmissionReason Limit int64 Remaining int64 ResetAt time.Time RetryAfter time.Duration } type AdmissionController interface { Allow(context.Context, apikey.Principal, time.Time) (AdmissionDecision, error) } type RedisAdmissionController struct { client *redis.Client script *redis.Script } func NewRedisAdmissionController(client *redis.Client) *RedisAdmissionController { return &RedisAdmissionController{client: client, script: redis.NewScript(admissionScript)} } func (c *RedisAdmissionController) Allow(ctx context.Context, principal apikey.Principal, now time.Time) (AdmissionDecision, error) { if principal.RequestsPerMinute == 0 && principal.MonthlyRequestQuota == 0 { return AdmissionDecision{Allowed: true}, nil } if c == nil || c.client == nil || principal.APIKeyID == "" { return AdmissionDecision{}, ErrAdmissionUnavailable } now = now.UTC() minuteReset := now.Truncate(time.Minute).Add(time.Minute) monthReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) minuteKey := fmt.Sprintf("gateway:limit:api-key:%s:minute:%d", principal.APIKeyID, now.Unix()/60) monthKey := fmt.Sprintf("gateway:limit:api-key:%s:month:%s", principal.APIKeyID, now.Format("200601")) result, err := c.script.Run(ctx, c.client, []string{minuteKey, monthKey}, principal.RequestsPerMinute, principal.MonthlyRequestQuota, int64(minuteReset.Sub(now).Seconds())+2, int64(monthReset.Sub(now).Seconds())+86400, ).Slice() if err != nil { return AdmissionDecision{}, fmt.Errorf("%w: %v", ErrAdmissionUnavailable, err) } if len(result) != 3 { return AdmissionDecision{}, ErrAdmissionUnavailable } code, err := redisInteger(result[0]) if err != nil { return AdmissionDecision{}, ErrAdmissionUnavailable } minuteCount, err := redisInteger(result[1]) if err != nil { return AdmissionDecision{}, ErrAdmissionUnavailable } monthCount, err := redisInteger(result[2]) if err != nil { return AdmissionDecision{}, ErrAdmissionUnavailable } switch code { case 0: decision := AdmissionDecision{Allowed: true} if principal.RequestsPerMinute > 0 { decision.Limit = int64(principal.RequestsPerMinute) decision.Remaining = max(decision.Limit-minuteCount, 0) decision.ResetAt = minuteReset } return decision, nil case 1: return AdmissionDecision{ Allowed: false, Reason: AdmissionRateLimit, Limit: int64(principal.RequestsPerMinute), Remaining: 0, ResetAt: minuteReset, RetryAfter: minuteReset.Sub(now), }, nil case 2: return AdmissionDecision{ Allowed: false, Reason: AdmissionMonthlyQuota, Limit: principal.MonthlyRequestQuota, Remaining: max(principal.MonthlyRequestQuota-monthCount, 0), ResetAt: monthReset, RetryAfter: monthReset.Sub(now), }, nil default: return AdmissionDecision{}, ErrAdmissionUnavailable } } func redisInteger(value any) (int64, error) { switch number := value.(type) { case int64: return number, nil case string: return strconv.ParseInt(number, 10, 64) case []byte: return strconv.ParseInt(string(number), 10, 64) default: return 0, fmt.Errorf("unexpected redis integer %T", value) } } const admissionScript = ` local rpm = tonumber(ARGV[1]) local monthly_quota = tonumber(ARGV[2]) local minute_count = tonumber(redis.call('GET', KEYS[1]) or '0') local month_count = tonumber(redis.call('GET', KEYS[2]) or '0') if monthly_quota > 0 and month_count >= monthly_quota then return {2, minute_count, month_count} end if rpm > 0 and minute_count >= rpm then return {1, minute_count, month_count} end if rpm > 0 then minute_count = redis.call('INCR', KEYS[1]) if minute_count == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end end if monthly_quota > 0 then month_count = redis.call('INCR', KEYS[2]) if month_count == 1 then redis.call('EXPIRE', KEYS[2], tonumber(ARGV[4])) end end return {0, minute_count, month_count} `