Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sargis Panosyan #289

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open

Conversation

spanosyan
Copy link

@spanosyan spanosyan commented Feb 25, 2025

Summary

In the following sections, I will do my best to explain my design process as well as how to use the Rate Limiter system.

Request Model

The RequestModel encapsulates all of the different data that can be used to identify and rate limit a given request.

  public record RequestModel(
      string RequestPath, 
      string UserId, 
      string OrganizationId, 
      string IpAddress, 
      string Region);

Rate Limiter

The RateLimiter is designed to do the following:

  1. Register rules: RegisterRule - Registers an IRateLimitRule by adding it to the rate limiter's ruleset data store
  2. Check whether a given request should be allowed: IsRequestAllowedAsync - goes through all configured rules for the resource that the RequsetModel request is asking to access and checks each rule to ensure limits have not been exceeded.

Rate Limiting Rules

You can add a new rate limiting rule through the following steps:

  1. Add your new rule to the Rules namespace/folder: MyNewRule
  2. You may choose to inherit from BaseRateLimitRule if you wish to utilize the semaphore logic within IsRequestAllowedAsync
    • If you choose to inherit from BaseRateLimitRule, then your subclass must implement the ProcessRuleAsync method
    • If you choose not to inherit from BaseRateLimitRule, then your subclass should implement the IsRequestAllowedAsync directly
  3. Add a new enum type entry for the new rule in Constants.RateLimitRuleTypes.cs
  4. Add a new case statement to the RateLimitRuleFactory.CreateRule method

Rate Limit Data Store

In order to make it easier to switch from in-memory data store / cache to external data stores/caches, such as Redis, I chose to do the following:

  1. Abstract the rate limit data store behind the IRateLimitDataStore interface
  2. Add an enum type to capture the different types of data stores
  3. Add a factory that will create each type of data store
  4. Develop the rate limit rules to use IRateLimitDataStore

A new rate limit data store can be added to the system by doing the following:

  1. Create your new rate limit data store: MyNewRateLimitDataStore
  2. Inherit from and implement the IRateLimitDataStore interface
  3. Add an entry to the Constants.RateLimitDateStoreTypes enum for MyNewRateLimitDataStore
  4. Add a case statement to the RateLimitDataStoreFactory.CreateDataStore method

Data Store Key Generator

While developing different rate limit rules, I noticed that the rule logic could be shared across multiple instances. Rather than duplicate that logic, it seemed better to reuse the logic by creating a way to generate different rate limit data store keys based on the request and use those keys to access the data from the data store. To achieve this, I created the DataStoreKeyGenerator. One of the major benefits to utilizing the strategy pattern and adding a DataStoreKeyGenerator turned out to be how easy it was to extend rule functionality and support different types of rules more easily.

The DataStoreKeyGenerator generates a data store key string based on the DataStoreKeyType and the given RequestModel request. For example, the DataStoreKeyTypes.RequestsPerUserPerResource creates a key string by concatenating the request.UserId and request.RequestPath together.

To add a new DataStoreKeyType:

  1. Add a new enum type entry in Constants.DataStoreKeyTypes
  2. Add a new case statement to Stores.DataStoreKeyGenerator for the new enum type entry

Sample Usage

Instantiating the rate limiter:

var rulesetStore = new RateLimitRulesetStore();

// I have not implemented logging for the library as I decided it would be better
// to allow the calling application or API to give the Rate Limiter an ILogger
// instance through DI.
var logger = new Mock<ILogger<RateLimiter>>();

// Set allowRequestOnFailure to false if you do not want to allow requests 
// when the rate limit system has a failure (e.g. getting data from data store)
var allowRequestOnFailure = true; 
var rateLimiter = new RateLimiter(rulesetStore, logger, allowRequestOnFailure);

Creating rules and registering them:

var dataStoreFactory = new RateLimitDataStoreFactory();
var ruleFactory = new RateLimitRuleFactory(dataStoreFactory);

// Creating rate limit rules based on Requests Per TimeSpan rule type
var userRequestLimitRule = ruleFactory.CreateRule(
    RateLimitRuleTypes.RequestsPerTimeSpan,
    RateLimitDataStoreTypes.ConcurrentInMemory,
    DataStoreKeyTypes.RequestsPerUser,
    numRequestsAllowed,
    interval);
	
var orgRequestLimitRule = ruleFactory.CreateRule(
    RateLimitRuleTypes.RequestsPerTimeSpan,
    RateLimitDataStoreTypes.ConcurrentInMemory,
    DataStoreKeyTypes.RequestsPerOrganization,
    numRequestsAllowed,
    interval);

// Registering rules
rateLimiter.RegisterRule(userRequestLimitRule);

Checking if a request should be allowed:

var request = new RequestModel(
    "api/accounts",
    "123",
    "Sample Corp",
    "123.46.78.90",
    "us-east");
	
var isAllowed = await rateLimiter.IsRequestAllowedAsync(request);

…t model.

The rate limiting rules were refactored to utilize semaphores to ensure limit counting is consistent across multiple threads. The rate limiter was updated to use a RequestModel so that requests can be limited based on different request metadata: request path, user id, organization id, IP address, or region. Tests were updated to reflect these changes.
The DataStoreKeyGenerator utilizes the strategy pattern to generate different types of data store keys that a rate limit rule can use to store the rate limit counting data. Doing so allows the RequestPerTimeSpan rule logic to be reused with different types of keys, e.g. RequestsPerResource, RequestsPerUser, etc... In this way, new types of RequestPerTimeSpan rules can be created by simply added a new data store key type.
Added the allowRequestsOnFailure parameter to the rate limiter In order to allow the user of this library to configure whether the rate limiter will allow or deny requests when an exception occurs. This configuration parameter will give us the flexibility to choose whether we allow or block requests in failure scenarios.

Although there is currently only one implementation of each data store that stores the data in memory, we may eventually want to utilize a Redis distributed cache.  This is one such case where failures outside of our system may cause the rate limiter to fail.
Added tests for factories and store key generator.
Added code to append the enum to the NotImplementedException error messsages.
Reorganized using statements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant