Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
- [Two Redis Instances](#two-redis-instances)
- [Health Checking for Redis Active Connection](#health-checking-for-redis-active-connection)
- [Recovering from a failover (READONLY errors)](#recovering-from-a-failover-readonly-errors)
- [Calendar-aligned MONTH rate limits](#calendar-aligned-month-rate-limits)
- [Memcache](#memcache)
- [Custom headers](#custom-headers)
- [Tracing](#tracing)
Expand Down Expand Up @@ -1396,6 +1397,19 @@ configured address and reaches the current master. The failing command still ret
to the caller; only the connection handling changes. Applies to both the main and the
per-second Redis clients.

## Calendar-aligned MONTH rate limits

1. `USE_CALENDAR_MONTH_RATE_LIMIT` : (default is "false")

By default, a `unit: month` rate limit uses a fixed 30-day window counted from the Unix epoch,
which does not line up with real calendar months (it drifts, and treats every month as 30 days
regardless of its actual length).

Setting `USE_CALENDAR_MONTH_RATE_LIMIT` to `"true"` switches `MONTH` limits to a true calendar
month window instead: the cache key bucket, TTL/expiration, and reported reset time all cover
the 1st through the last day of the month (UTC). This is opt-in because it changes when
existing `MONTH` limits reset and is therefore not enabled by default.

# Memcache

Experimental Memcache support has been added as an alternative to Redis in v1.5.
Expand Down
17 changes: 14 additions & 3 deletions src/limiter/base_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type BaseRateLimiter struct {
localCache *freecache.Cache
nearLimitRatio float32
StatsManager stats.Manager
// useCalendarMonth gates the MONTH-unit fix (calendar-aligned window
// instead of a fixed 30-day divider) for expiration/TTL computations.
useCalendarMonth bool
}

type LimitInfo struct {
Expand Down Expand Up @@ -61,6 +64,12 @@ func (this *BaseRateLimiter) GenerateCacheKeys(request *pb.RateLimitRequest,
return cacheKeys
}

// ExpirationSeconds returns the number of seconds, evaluated from the current
// time, until the given rate limit unit's window ends.
func (this *BaseRateLimiter) ExpirationSeconds(unit pb.RateLimitResponse_RateLimit_Unit) int64 {
return utils.ExpirationSeconds(unit, this.timeSource, this.useCalendarMonth)
}

// Returns `true` in case local cache is enabled and contains value for provided cache key, `false` otherwise.
func (this *BaseRateLimiter) IsOverLimitWithLocalCache(key string) bool {
if this.localCache != nil {
Expand Down Expand Up @@ -116,7 +125,7 @@ func (this *BaseRateLimiter) GetResponseDescriptorStatus(key string, limitInfo *
// similar to mongo_1h, mongo_2h, etc. In the hour 1 (0h0m - 0h59m), the cache key is mongo_1h, we start
// to get ratelimited in the 50th minute, the ttl of local_cache will be set as 1 hour(0h50m-1h49m).
// In the time of 1h1m, since the cache key becomes different (mongo_2h), it won't get ratelimited.
err := this.localCache.Set([]byte(key), []byte{}, int(utils.UnitToDivider(limitInfo.limit.Limit.Unit)))
err := this.localCache.Set([]byte(key), []byte{}, int(this.ExpirationSeconds(limitInfo.limit.Limit.Unit)))
if err != nil {
logger.Errorf("Failing to set local cache key: %s", key)
}
Expand Down Expand Up @@ -144,15 +153,17 @@ func (this *BaseRateLimiter) GetResponseDescriptorStatus(key string, limitInfo *

func NewBaseRateLimit(timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64,
localCache *freecache.Cache, nearLimitRatio float32, cacheKeyPrefix string, statsManager stats.Manager,
useCalendarMonth bool,
) *BaseRateLimiter {
return &BaseRateLimiter{
timeSource: timeSource,
JitterRand: jitterRand,
ExpirationJitterMaxSeconds: expirationJitterMaxSeconds,
cacheKeyGenerator: NewCacheKeyGenerator(cacheKeyPrefix),
cacheKeyGenerator: NewCacheKeyGenerator(cacheKeyPrefix, useCalendarMonth),
localCache: localCache,
nearLimitRatio: nearLimitRatio,
StatsManager: statsManager,
useCalendarMonth: useCalendarMonth,
}
}

Expand Down Expand Up @@ -205,7 +216,7 @@ func (this *BaseRateLimiter) generateResponseDescriptorStatus(responseCode pb.Ra
Code: responseCode,
CurrentLimit: limit,
LimitRemaining: limitRemaining,
DurationUntilReset: utils.CalculateReset(&limit.Unit, this.timeSource),
DurationUntilReset: utils.CalculateReset(&limit.Unit, this.timeSource, this.useCalendarMonth),
}
} else {
return &pb.RateLimitResponse_DescriptorStatus{
Expand Down
20 changes: 16 additions & 4 deletions src/limiter/cache_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import (

type CacheKeyGenerator struct {
prefix string
// useCalendarMonth gates bucketing MONTH-unit limits by real calendar
// month (UTC) instead of the legacy fixed 30-day divider.
useCalendarMonth bool
// bytes.Buffer pool used to efficiently generate cache keys.
bufferPool sync.Pool
}

func NewCacheKeyGenerator(prefix string) CacheKeyGenerator {
func NewCacheKeyGenerator(prefix string, useCalendarMonth bool) CacheKeyGenerator {
return CacheKeyGenerator{
prefix: prefix,
prefix: prefix,
useCalendarMonth: useCalendarMonth,
bufferPool: sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
Expand Down Expand Up @@ -78,8 +82,16 @@ func (this *CacheKeyGenerator) GenerateCacheKey(
b.WriteByte('_')
}

divider := utils.UnitToDivider(limit.Limit.Unit)
b.WriteString(strconv.FormatInt((now/divider)*divider, 10))
var bucketStart int64
if this.useCalendarMonth && limit.Limit.Unit == pb.RateLimitResponse_RateLimit_MONTH {
// Calendar months vary in length, so bucket by the start of the
// current UTC calendar month rather than a fixed-size divider.
bucketStart = utils.MonthStartUnix(now)
} else {
divider := utils.UnitToDivider(limit.Limit.Unit)
bucketStart = (now / divider) * divider
}
b.WriteString(strconv.FormatInt(bucketStart, 10))

return CacheKey{
Key: b.String(),
Expand Down
6 changes: 4 additions & 2 deletions src/memcached/cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func (this *rateLimitMemcacheImpl) increaseAsync(cacheKeys []limiter.CacheKey, i

_, err := this.client.Increment(cacheKey.Key, hitsAddends[i])
if err == memcache.ErrCacheMiss {
expirationSeconds := utils.UnitToDivider(limits[i].Limit.Unit)
expirationSeconds := this.baseRateLimiter.ExpirationSeconds(limits[i].Limit.Unit)
if this.expirationJitterMaxSeconds > 0 {
expirationSeconds += this.jitterRand.Int63n(this.expirationJitterMaxSeconds)
}
Expand Down Expand Up @@ -304,6 +304,7 @@ func runAsync(task func()) {

func NewRateLimitCacheImpl(client Client, timeSource utils.TimeSource, jitterRand *rand.Rand,
expirationJitterMaxSeconds int64, localCache *freecache.Cache, statsManager stats.Manager, nearLimitRatio float32, cacheKeyPrefix string,
useCalendarMonth bool,
) limiter.RateLimitCache {
return &rateLimitMemcacheImpl{
client: client,
Expand All @@ -312,7 +313,7 @@ func NewRateLimitCacheImpl(client Client, timeSource utils.TimeSource, jitterRan
expirationJitterMaxSeconds: expirationJitterMaxSeconds,
localCache: localCache,
nearLimitRatio: nearLimitRatio,
baseRateLimiter: limiter.NewBaseRateLimit(timeSource, jitterRand, expirationJitterMaxSeconds, localCache, nearLimitRatio, cacheKeyPrefix, statsManager),
baseRateLimiter: limiter.NewBaseRateLimit(timeSource, jitterRand, expirationJitterMaxSeconds, localCache, nearLimitRatio, cacheKeyPrefix, statsManager, useCalendarMonth),
}
}

Expand All @@ -328,5 +329,6 @@ func NewRateLimitCacheImplFromSettings(s settings.Settings, timeSource utils.Tim
statsManager,
s.NearLimitRatio,
s.CacheKeyPrefix,
s.UseCalendarMonthRateLimit,
)
}
1 change: 1 addition & 0 deletions src/redis/cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Setting
s.CacheKeyPrefix,
statsManager,
s.StopCacheKeyIncrementWhenOverlimit,
s.UseCalendarMonthRateLimit,
), closer
}
6 changes: 3 additions & 3 deletions src/redis/fixed_cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (this *fixedRateLimitCacheImpl) DoLimit(

logger.Debugf("looking up cache key: %s", cacheKey.Key)

expirationSeconds := utils.UnitToDivider(limits[i].Limit.Unit)
expirationSeconds := this.baseRateLimiter.ExpirationSeconds(limits[i].Limit.Unit)
if this.baseRateLimiter.ExpirationJitterMaxSeconds > 0 {
expirationSeconds += this.baseRateLimiter.JitterRand.Int63n(this.baseRateLimiter.ExpirationJitterMaxSeconds)
}
Expand Down Expand Up @@ -225,12 +225,12 @@ func (this *fixedRateLimitCacheImpl) Flush() {}

func NewFixedRateLimitCacheImpl(client Client, perSecondClient Client, timeSource utils.TimeSource,
jitterRand *rand.Rand, expirationJitterMaxSeconds int64, localCache *freecache.Cache, nearLimitRatio float32, cacheKeyPrefix string, statsManager stats.Manager,
stopCacheKeyIncrementWhenOverlimit bool,
stopCacheKeyIncrementWhenOverlimit bool, useCalendarMonth bool,
) limiter.RateLimitCache {
return &fixedRateLimitCacheImpl{
client: client,
perSecondClient: perSecondClient,
stopCacheKeyIncrementWhenOverlimit: stopCacheKeyIncrementWhenOverlimit,
baseRateLimiter: limiter.NewBaseRateLimit(timeSource, jitterRand, expirationJitterMaxSeconds, localCache, nearLimitRatio, cacheKeyPrefix, statsManager),
baseRateLimiter: limiter.NewBaseRateLimit(timeSource, jitterRand, expirationJitterMaxSeconds, localCache, nearLimitRatio, cacheKeyPrefix, statsManager, useCalendarMonth),
}
}
4 changes: 3 additions & 1 deletion src/service/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type service struct {
globalShadowMode bool
globalQuotaMode bool
responseDynamicMetadataEnabled bool
useCalendarMonthRateLimit bool
}

func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWithAtLeastOneConfigLoad bool) {
Expand Down Expand Up @@ -90,6 +91,7 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi
this.globalShadowMode = rlSettings.GlobalShadowMode
this.globalQuotaMode = rlSettings.GlobalQuotaMode
this.responseDynamicMetadataEnabled = rlSettings.ResponseDynamicMetadata
this.useCalendarMonthRateLimit = rlSettings.UseCalendarMonthRateLimit

if rlSettings.RateLimitResponseHeadersEnabled {
this.customHeadersEnabled = true
Expand Down Expand Up @@ -393,7 +395,7 @@ func (this *service) rateLimitResetHeader(
) *core.HeaderValue {
return &core.HeaderValue{
Key: this.customHeaderResetHeader,
Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10),
Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock, this.useCalendarMonthRateLimit).GetSeconds(), 10),
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ type Settings struct {
CacheKeyPrefix string `envconfig:"CACHE_KEY_PREFIX" default:""`
BackendType string `envconfig:"BACKEND_TYPE" default:"redis"`
StopCacheKeyIncrementWhenOverlimit bool `envconfig:"STOP_CACHE_KEY_INCREMENT_WHEN_OVERLIMIT" default:"false"`
// UseCalendarMonthRateLimit switches MONTH-unit rate limits to a true calendar
// month window (the 1st through the last day of the month, UTC) for cache key
// bucketing, TTL/expiration, and the reported reset time. Defaults to false,
// which preserves the legacy behavior of a fixed 30-day rolling window counted
// from the Unix epoch, so enabling this for existing MONTH limits changes when
// they reset and is opt-in.
UseCalendarMonthRateLimit bool `envconfig:"USE_CALENDAR_MONTH_RATE_LIMIT" default:"false"`

// Settings for optional returning of custom headers
RateLimitResponseHeadersEnabled bool `envconfig:"LIMIT_RESPONSE_HEADERS_ENABLED" default:"false"`
Expand Down
31 changes: 31 additions & 0 deletions src/utils/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,37 @@ func (this *timeSourceImpl) UnixNow() int64 {
return time.Now().Unix()
}

// expiryUntilMonthEnd returns the duration remaining until the start of the
// next calendar month, evaluated in UTC so the result does not depend on the
// server's local timezone or DST transitions.
func expiryUntilMonthEnd(now time.Time) time.Duration {
// Always operate in UTC to avoid timezone/DST drift
nowUTC := now.UTC()
// Calculate the start of the next month in UTC
nextMonth := nowUTC.AddDate(0, 1, -nowUTC.Day()+1)
nextMonthStart := time.Date(
nextMonth.Year(), nextMonth.Month(), 1,
0, 0, 0, 0, time.UTC,
)
// Return the duration between now and the next month boundary
return nextMonthStart.Sub(nowUTC)
}

// MonthExpirationSeconds returns the number of seconds remaining until the
// end of the calendar month (UTC) containing the instant represented by
// nowUnix. Used as the TTL/expiration for a MONTH-unit rate limit entry.
func MonthExpirationSeconds(nowUnix int64) int64 {
return int64(expiryUntilMonthEnd(time.Unix(nowUnix, 0)).Seconds())
}

// MonthStartUnix returns the Unix timestamp (UTC) of the first moment of the
// calendar month containing the instant represented by nowUnix. Used to
// bucket a MONTH-unit rate limit's cache key by calendar month.
func MonthStartUnix(nowUnix int64) int64 {
t := time.Unix(nowUnix, 0).UTC()
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC).Unix()
}

// rand for jitter.
type lockedSource struct {
lk sync.Mutex
Expand Down
21 changes: 18 additions & 3 deletions src/utils/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,25 @@ func UnitToDivider(unit pb.RateLimitResponse_RateLimit_Unit) int64 {
panic("should not get here")
}

func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource) *durationpb.Duration {
// ExpirationSeconds returns the number of seconds, evaluated from the
// current time, until the given rate limit unit's window ends. When
// useCalendarMonth is true, MONTH reflects the actual calendar-aligned
// window (the 1st through the last day of the month, UTC) instead of the
// fixed-length UnitToDivider approximation.
func ExpirationSeconds(unit pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource, useCalendarMonth bool) int64 {
if useCalendarMonth && unit == pb.RateLimitResponse_RateLimit_MONTH {
return MonthExpirationSeconds(timeSource.UnixNow())
}
return UnitToDivider(unit)
}

func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource, useCalendarMonth bool) *durationpb.Duration {
nowUnix := timeSource.UnixNow()
if useCalendarMonth && *unit == pb.RateLimitResponse_RateLimit_MONTH {
return &durationpb.Duration{Seconds: MonthExpirationSeconds(nowUnix)}
}
sec := UnitToDivider(*unit)
now := timeSource.UnixNow()
return &durationpb.Duration{Seconds: sec - now%sec}
return &durationpb.Duration{Seconds: sec - nowUnix%sec}
}

// Mask credentials from a redis connection string like
Expand Down
6 changes: 6 additions & 0 deletions test/config/basic_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,9 @@ descriptors:
rate_limit:
unit: minute
requests_per_unit: 70

- key: key8
rate_limit:
name: key8_rate_limit
unit: month
requests_per_unit: 200
Loading
Loading