ratelimit-js icon indicating copy to clipboard operation
ratelimit-js copied to clipboard

Support getting and resetting limit of a specific identifier

Open offchan42 opened this issue 4 months ago • 10 comments

I have a feature request.

Right now the limit function can modify the limit, but we lack a getRemaining function to see the current value of the limit without any modification. It can be useful for displaying the remaining amount to users.

Another useful function is resetting the limit. In practice, this might mean deleting the entry in Redis. It could be useful when we are trying to reset the limit on every billing cycle for paid members. For example, I might allow 1000 AI image generations monthly, and every time the user pays the bill, I can reset the limit.

This is how I currently implement getRemaining() function (it's only for fixed window). I did it by writing a wrapper around the Ratelimit object:

/**
 * Get redis key for fixed window rate limiter
 * @param prefix e.g. 'ratelimit:free'
 * @param identifier e.g. user ID or email
 * @param window time e.g. '1m'
 * @returns a key in the form of "prefix:identifier:timestamp"
 */
function getFixedWindowKey(prefix: string, identifier: string, window: string) {
  const intervalDuration = ms(window)
  const key = [prefix, identifier, Math.floor(Date.now() / intervalDuration)].join(':')
  return key
}

/**
 * Get the remaining limit for a fixed window rate limiter without actually consuming the tokens.
 * @param key redis key in the form of "prefix:identifier:timestamp"
 * @param tokens max number of tokens
 * @returns remaining number of tokens in the window, or 0 if the limit is reached
 */
async function getFixedWindowLimitRemaining(key: string, tokens: number) {
  const used = await kv.get<number>(key)
  let remaining = tokens - (used ?? 0)
  if (remaining < 0) remaining = 0
  return remaining
}

/**
 * Create a fixed window rate limiter with Upstash Redis as the backend.
 * This returns a rate limiter object which extends the original ratelimit object by adding
 * a `getRedisKey` function and a `getRemaining` function.
 */
export function createFixedWindowRatelimit(config: RateLimiterConfig) {
  const { redis, timeout, analytics, prefix, tokens, window } = config

  const ratelimit = new Ratelimit({
    redis,
    timeout,
    analytics,
    prefix,
    limiter: Ratelimit.fixedWindow(tokens, window),
  })

  const getRedisKey = (identifier: string) => {
    return getFixedWindowKey(prefix, identifier, window)
  }

  const getRemaining = async (identifier: string) => {
    const key = getRedisKey(identifier)
    return await getFixedWindowLimitRemaining(key, tokens)
  }

  return {
    limit: ratelimit.limit.bind(ratelimit),
    blockUntilReady: ratelimit.blockUntilReady.bind(ratelimit),
    getRedisKey,
    getRemaining,
  }
}

/**
 * Rate limiter for free tier, should be used to limit image generations based on userID
 */
const ratelimitFree = createFixedWindowRatelimit({
  redis: kv,
  timeout: 1000,
  analytics: true,
  prefix: 'ratelimit:free',
  tokens: 30,
  window: '1h',
})

I think API users can implement the get and reset function as well but it's better if users don't have to understand Redis or Upstash at all. I currently use Vercel KV and I want to be oblivious to how it works. I don't want to learn Redis just to do rate limiting. Maybe what we need is something like the getRemaining function above and another function like reset inside the Ratelimit object.

Thanks!

offchan42 avatar Feb 19 '24 07:02 offchan42

it makes sense. do you have availability to send a PR about this?

enesakar avatar Feb 19 '24 23:02 enesakar

it makes sense. do you have availability to send a PR about this?

Unfortunately, no. I don't know enough to modify this repo and also quite busy in this period. It'd be great if someone can implement this.

offchan42 avatar Feb 20 '24 10:02 offchan42

@off99555 I am not sure about your use case but if you do it like this to get the remaining limit:

 const { remaining } = await ratelimitFree.limit(
    "someUniqueIdentifier"
  );

the remaining property is returned by the limit function call. If this doesn't work for you then please let me know I am happy to work on this issue

And for resetting the tokens we can definitely have an API exposed to do that. I will try to raise a PR

sourabpramanik avatar Feb 22 '24 15:02 sourabpramanik

@sourabpramanik That isn't ideal because I want to check remaining values often without modifying the limit. For example, I might want to display this remaining value on every page refresh, but only consume it when user generates an image.

offchan42 avatar Feb 22 '24 15:02 offchan42

@sourabpramanik That isn't ideal because I want to check remaining values often without modifying the limit. For example, I might want to display this remaining vaue on every page refresh, but only consume it when user generates an image.

Ahh, I see now, that you must have to consume the token to get the value. Thanks for explaining it to me. I will raise a PR for an API exposed for the same. Thanks

sourabpramanik avatar Feb 22 '24 15:02 sourabpramanik

Let me give another example use case of why getRemainingQuota is important.

Suppose you limit the user to 100 requests per hour based on his email address. Some users will create 10 new accounts just to abuse the service 10x more. So you should limit based on other dimensions e.g. IP address as well. This requires getRemainingQuota API so that we can implement multi-dimensional rate-limiting.

Overview of multi-dimensional rate-limiting algorithm (pseudo code from GPT-4):

def is_request_allowed(email, ip, device_fingerprint):
    # Define limits
    limits = {
        "email": 100,  # 100 requests per hour
        "ip": 200,  # 200 requests per hour
        "device": 150  # 150 requests per hour
    }
    
    # Define keys for Redis
    keys = {
        "email": f"email:{email}:count",
        "ip": f"ip:{ip}:count",
        "device": f"device:{device_fingerprint}:count"
    }
    
    # Check current counts without incrementing
    current_counts = {key_type: int(redis.get(key) or 0) for key_type, key in keys.items()}

    # Determine if any limit is exceeded
    limit_exceeded = any(current_counts[key_type] >= limits[key_type] for key_type in keys)

    if limit_exceeded:
        return False  # Do not proceed and do not increment since limit is exceeded
    else:
        # Increment counters since the request is allowed
        for key in keys.values():
            redis.incr(key)
            redis.expire(key, 3600)  # Ensure each key expires in 1 hour to reset the count

    return True  # Proceed with the request

# Example usage
is_allowed = is_request_allowed("[email protected]", "192.168.1.1", "device1234")
if not is_allowed:
    # Reject the request
    pass
else:
    # Proceed with the request
    pass

In this improved version:

  1. Check First, Then Increment: It first checks the current counts against the limits without incrementing. This way, a request that would exceed the limit does not count towards the limit itself.
  2. Increment Only on Allowed Requests: The counters are incremented only if the request is determined to be allowed, ensuring that only successful requests consume the user's quota.

offchan42 avatar Feb 27 '24 14:02 offchan42

I do agree with the benefits of the getRemaining API, but the use case of yours where "a request that would exceed the limit does not count towards the limit itself" can be achieved by otping cache. Once the limit is reached and caching is enabled then the cache will store that IP or any identifier in it and block it until the window duration elapses. Meanwhile, if any new request comes in we first check the cache if that identifier is already blocked or not, if blocked then no further counter mutation is done

sourabpramanik avatar Feb 27 '24 14:02 sourabpramanik

I am working on both the APIs and have to run some tests on it, maybe this week I will raise the PR for it

sourabpramanik avatar Feb 27 '24 14:02 sourabpramanik

Also to mention I am building the Rust SDK for Upstash rate limit (unofficial), so maybe next month I will be able to ship the single region rate-limiting that can work with major rust based frameworks and runtimes hopefully

sourabpramanik avatar Feb 27 '24 14:02 sourabpramanik

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.

github-actions[bot] avatar Mar 29 '24 01:03 github-actions[bot]

getRemaining and resetUsedTokens methods were added to our library with version 1.1.1.

You can refer to our updated documentation for more details.

Thanks for the great suggestion!

CahidArda avatar Apr 15 '24 13:04 CahidArda