Skip to content

Commit 08b4e93

Browse files
Scott MackayScott Mackay
authored andcommitted
Merge pull request #5 in SS/servicestack.ratelimit.redis from develop to master
* commit '7cc174e90273cd85b7c5d8c86ae9b8fbd8ca0d8a': Move config update to avoid multiple calls Tidy up of how JsConfig it used for setting response headers Added license headers to new files Added a maybe<> class Updated readme. Fixed commented out code that shouldn't have been pushed Throw exception from LimitKeyGenerator if no AuthProviders setup Change default layout for appSetting keys. Allow delimiter and prefix to be set as static property Couple of changes post code-review Adding correlation plugin link reverting to xunit 2.0 to try and fix appveyor build Updating packages and nuspec
2 parents 9c10c93 + 7cc174e commit 08b4e93

21 files changed

+334
-155
lines changed

readme.md

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A rate limiting plugin for [ServiceStack](https://servicestack.net/) that uses [
88

99
An accessible running Redis instance.
1010

11-
The plugin needs to be passed an IRedisClientsManager instance to work.
11+
The plugin needs to be passed an `IRedisClientsManager` instance to work.
1212

1313
## Quick Start
1414

@@ -32,11 +32,11 @@ public override void Configure(Container container)
3232

3333
There is a baked-in default limit for each DTO type of: 10 requests per minute, 30 request per hour.
3434

35-
To override this add an AppSetting with key *lmt:default* to the App.Config/Web.Config of project running AppHost will provide limit values.
35+
To override this add an AppSetting with key *ss/lmt/default* to the App.Config/Web.Config of project running AppHost will provide limit values.
3636

3737
```
3838
<!-- default of 10 per second, 50 per minute, 200 per hour-->
39-
<add key="lmt:default" value="{Limits:[{Limit:10,Seconds:1},{Limit:50,Seconds:60},{Limit:200,Seconds:3600}]}"/>
39+
<add key="ss/lmt/default" value="{Limits:[{Limit:10,Seconds:1},{Limit:50,Seconds:60},{Limit:200,Seconds:3600}]}"/>
4040
```
4141

4242
The lookup keys for more granular control are specified below.
@@ -53,46 +53,61 @@ The "Postman Samples" folder contains a sample [Postman](https://www.getpostman.
5353
## Overview
5454
The plugin registers a [global request filter](https://github.com/ServiceStack/ServiceStack/wiki/Request-and-response-filters#global-request-filters). Every time a request is received a check is made using a [Redis LUA script](http://redis.io/commands/eval). If the specified limits have not been hit then the request is processed as expected. However, if the limit has been reached then a [429](https://tools.ietf.org/html/rfc6585#page-3) "Too Many Requests" response is generated and processing of the request is halted.
5555

56-
Two possible headers are returned from any endpoint that is protecte: x-ratelimit-request and x-ratelimit-user. They will show the seconds duration, the limit and how many remaining calls are available per request, or user respectively.
56+
Two possible headers are returned from any endpoint that is protected: `x-ratelimit-request` and `x-ratelimit-user`. They will show the seconds duration, the limit and how many remaining calls are available per request, or user respectively.
5757

5858
### Rate Limits
5959

6060
At a high level, rate limits can be set at either **User** or **Resource** level (by default a resource in this instance is the DTO type name). Limits are fetched from [IAppSettings](https://github.com/ServiceStack/ServiceStack/wiki/AppSettings) and can be set at the following levels, in order of precedence:
6161

62-
* User for resource - User 123 can make X requests to a specific resource (e.g. /api/products). (config key: "lmt:{resourceName}:{userId}")
63-
* User - User 123 can make X total requests for specific time period(s). (config key: "lmt:usr:{userId}")
64-
* User fallback - Fallback total request limit for users without specific defaults. (config key: "lmt:usr:default")
65-
* Resource - Each user can make X requests to a resource (e.g. /api/products) for specific time period(s). (config key: "lmt:{resourceName}".)
66-
* Resource fallback - Fallback limit for requests individual users can make to a resource. (config key: "lmt:default")
62+
| Order | Type | Description | Default Key |
63+
| --- | --- | --- | --- |
64+
| 1 | User for resource | User 123 can make X requests to a specific resource (e.g. /api/products) | `ss/lmt/{resourceName}/{userId}` |
65+
| 2 | User | User 123 can make X total requests for specific time period(s) | `ss/lmt/usr/{userId}` |
66+
| 3 | User fallback | Fallback total request limit for users without specific defaults | `ss/lmt/usr/default` |
67+
| 4 | Resource | Each user can make X requests to a resource (e.g. /api/products) for specific time period(s) | `ss/lmt/{resourceName}` |
68+
| 5 | Resource fallback | Fallback limit for requests individual users can make to a resource. | `ss/lmt/default` |
6769

6870
User limits AND resource limits will be calculated at the same time (if present). User limits are calculated first. If a limit is hit subsequent wider limits are not incremented (e.g. if limit per second is hit, limit per minute would not be counted).
6971

72+
#### Customising Keys
73+
74+
It is possible to change both the prefix, default *ss* (to distinguish ServiceStack settings), and delimiter, default */*, as static variables of the `LimitKeyGenerator` class. For example:
75+
76+
```csharp
77+
LimitKeyGenerator.Delimiter = "-";
78+
LimitKeyGenerator.Prefix = null;
79+
```
80+
81+
Would produce keys like `lmt-default` rather than `ss/lmt/default`.
82+
7083
#### Limit Representation
7184
All limits are per second and are stored as a LimitGroup object serialised to JSV. For example, the following shows a limit of 5 requests per second, 15 per minute (60s) and 100 per hour (3600s):
72-
```xml
85+
```json
7386
{Limits:[{Limit:5,Seconds:1},{Limit:15,Seconds:60},{Limit:100,Seconds:3600}]}
7487
```
7588

7689
#### LUA Script
7790
A LUA script is used for doing the heavy lifting and keeping track of limit totals. To save bandwith on calls to Redis the [EVALSHA](http://redis.io/commands/evalsha) command is used to call a LUA script which has previously been [loaded](http://redis.io/commands/script-load).
7891

79-
The default implementation of ILimitProvider (see below) will check IAppSettings for a value with key "script:ratelimit". This value will be the SHA1 of the script to use. Using this method means that the script can be managed external to the plugin.
92+
The default implementation of ILimitProvider (see below) will check IAppSettings for a value with key *ss/script/ratelimit*. This value will be the SHA1 of the script to use. Using this method means that the script can be managed external to the plugin.
8093

8194
If an AppSetting is not found with the specified key then the RateLimitHash.lua script is loaded, the SHA1 is stored and used for subsequent requests.
8295

8396
**Note:** The RateLimitHash.lua script does not currently use a sliding expiry, instead is resets every X seconds. E.g. if the limit is for 50 requests in 3600 seconds (1 hour) then 50 requests could be made at 10:59 and then 50 request can be made at 11:01. This is something that may be looked at in the future.
8497

8598
### Extensibility
86-
There are a few extension point that can be set when adding the plugin:
87-
88-
* CorrelationIdExtractor - This is a delegate function that customises how an individual request is identified. By default it uses the value of HTTP Header with name specified by CorrelationIdHeader property. **Note:** This is primarily required for when a ServiceStack service calls subsequent ServiceStack services that all use this plugin as it will avoid user totals being incremented multiple times for the same request.
89-
* CorrelationIdHeader - The name of the header used for extracting correlation Id from request (if using default method). Default: x-mac-requestid.
90-
* StatusDescription - the status description returned when limit is breached. Default "Too many requests".
91-
* LimitStatusCode - the status code returned when limit is breached. Default 429.
92-
* KeyGenerator - an implementation of IKeyGenerator for generating config lookup key(s) for request. Defaults outlined above.
93-
* LimitProvider - an implementation of ILimitProvider for getting RateLimits for current request. Default uses IKeyGenerator keys to lookup IAppSettings.
94-
95-
These are all properties of the RateLimitFeature class and can be set when instantiating the plugin
99+
There are a few extension point that can be set when adding the plugin. These are all properties of the RateLimitFeature class and can be set when instantiating the plugin:
100+
101+
| Property | Description | Notes |
102+
| --- | --- | --- |
103+
| CorrelationIdExtractor | This is a delegate function that customises how an individual request is identified. | By default it uses the value of HTTP Header with name specified by CorrelationIdHeader property. This is primarily required for when a ServiceStack service calls subsequent ServiceStack services that all use this plugin as it will avoid user totals being incremented multiple times for the same request. |
104+
| CorrelationIdHeader | The name of the header used for extracting correlation Id from request (if using default method) | Default: `x-mac-requestid` |
105+
| StatusDescription | The status description returned when limit is breached | Default: "Too many requests" |
106+
| LimitStatusCode | the status code returned when limit is breached. | Default: 429 |
107+
| KeyGenerator | an implementation of IKeyGenerator for generating config lookup key(s) for request. | Defaults outlined above |
108+
| LimitProvider | an implementation of ILimitProvider for getting RateLimits for current request. | Default uses `IKeyGenerator` keys to lookup `IAppSettings` |
109+
110+
Example:
96111
```csharp
97112
Plugins.Add(new RateLimitFeature(Container.Resolve<IRedisClientsManager>())
98113
{
@@ -109,9 +124,10 @@ The script needs to be updated to take a list of all Redis Keys that will be ope
109124

110125
### Extras
111126

127+
* [ServiceStack.Request.Correlation](https://github.com/MacLeanElectrical/servicestack-request-correlation) - Designed to work seamlessly with this plugin, it will ensure that service to service calls will not increment api usage stats
112128
* [ServiceStack.Configuration.Consul](https://github.com/MacLeanElectrical/servicestack-configuration-consul) -
113129
This plugin works well with a shared configuration model where rate limits can be centrally managed globally or across multiple instances of your servicestack instances. The rate limiting scripts can also be updated centrally to make adjustments at runtime.
114130

115131
## Attributions
116132

117-
* http://www.corytaylor.ca/api-throttling-with-servicestack/ by Cory Taylor
133+
* http://www.corytaylor.ca/api-throttling-with-servicestack/ by Cory Taylor

src/ServiceStack.RateLimit.Redis/Headers/RateLimitHeader.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public static IEnumerable<RateLimitHeader> Create(IEnumerable<RateLimitTimeResul
1919
return new RateLimitHeader[0];
2020
}
2121

22-
var headers = from result in results
22+
return from result in results
2323
group result by result.User
2424
into grp
2525
select
@@ -28,8 +28,6 @@ into grp
2828
Limits = grp.Select(r => new RateLimitStatus(r.Limit, r.Remaining, r.Seconds)),
2929
HeaderName = grp.Key ? HttpHeaders.RateLimitUser : HttpHeaders.RateLimitRequest
3030
};
31-
32-
return headers;
3331
}
3432
}
3533
}

src/ServiceStack.RateLimit.Redis/LimitKeyGenerator.cs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
44
namespace ServiceStack.RateLimit.Redis
55
{
6+
using System;
67
using System.Collections.Generic;
78
using Auth;
89
using Interfaces;
@@ -12,8 +13,12 @@ namespace ServiceStack.RateLimit.Redis
1213

1314
public class LimitKeyGenerator : ILimitKeyGenerator
1415
{
15-
private const string DefaultConfigKey = "lmt:default";
16-
private const string DefaultUserConfigKey = "lmt:usr:default";
16+
public static string Delimiter = "/";
17+
public static string Prefix = "ss";
18+
19+
private readonly string defaultConfigKey = GenerateKey("lmt", "default");
20+
private readonly string defaultUserConfigKey = GenerateKey("lmt", "usr", "default");
21+
1722
private readonly ILog log = LogManager.GetLogger(typeof(LimitKeyGenerator));
1823

1924
// This is how we will generate the key that is used to lookup the LimitProvider
@@ -23,18 +28,18 @@ public virtual IEnumerable<string> GetConfigKeysForRequest(IRequest request)
2328
string requestId = GetRequestId(request);
2429

2530
// Build up a list of all keys in order of precedence
26-
string userRequestKey = $"lmt:{requestId}:{userId}";
27-
string requestKey = $"lmt:{requestId}";
31+
string userRequestKey = GenerateKey("lmt", requestId, userId);
32+
string requestKey = GenerateKey("lmt", requestId);
2833

29-
return new[] { userRequestKey, requestKey, DefaultConfigKey };
34+
return new[] { userRequestKey, requestKey, defaultConfigKey };
3035
}
3136

3237
public virtual IEnumerable<string> GetConfigKeysForUser(IRequest request)
3338
{
3439
string userId = GetConsumerId(request);
3540

36-
string userKey = $"lmt:usr:{userId}";
37-
return new[] { userKey, DefaultUserConfigKey };
41+
string userKey = GenerateKey("lmt", "usr", userId);
42+
return new[] { userKey, defaultUserConfigKey };
3843
}
3944

4045
public virtual string GetRequestId(IRequest request)
@@ -44,16 +49,34 @@ public virtual string GetRequestId(IRequest request)
4449

4550
public virtual string GetConsumerId(IRequest request)
4651
{
52+
if (AuthenticateService.AuthProviders == null)
53+
{
54+
throw new InvalidOperationException(
55+
"AuthService not initialized. This is required for generating default ConsumerId for RateLimitting.");
56+
}
57+
4758
IAuthSession userSession = request.GetSession();
4859

4960
// TODO This will need more love to authorize user rather than just verify authentication (not necessarily here but in general)
50-
if (!(userSession?.IsAuthenticated ?? false))
61+
if (!IsUserAuthenticated(userSession))
5162
{
5263
log.Error($"User {userSession?.UserName ?? "<unknown>"} not authenticated for request {request.AbsoluteUri}");
5364
throw new AuthenticationException("You must be authenticated to access this service");
5465
}
5566

5667
return userSession.UserAuthId?.ToLowerInvariant();
5768
}
69+
70+
private static bool IsUserAuthenticated(IAuthSession userSession)
71+
{
72+
return userSession?.IsAuthenticated ?? false;
73+
}
74+
75+
private static string GenerateKey(params string[] keyParts)
76+
{
77+
var usablePrefix = string.IsNullOrWhiteSpace(Prefix) ? string.Empty : string.Concat(Prefix, Delimiter);
78+
79+
return $"{usablePrefix}{string.Join(Delimiter, keyParts)}";
80+
}
5881
}
5982
}

src/ServiceStack.RateLimit.Redis/LimitProviderBase.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace ServiceStack.RateLimit.Redis
99
using Interfaces;
1010
using Logging;
1111
using Models;
12+
using Utilities;
1213
using Web;
1314

1415
public class LimitProviderBase : ILimitProvider
@@ -49,8 +50,8 @@ public Limits GetLimits(IRequest request)
4950
return new Limits
5051
{
5152
// Return default if none found
52-
Request = requestLimits ?? defaultLimits,
53-
User = userLimits
53+
Request = requestLimits.HasValue ? requestLimits.Value : defaultLimits,
54+
User = userLimits.HasValue ? userLimits.Value : null
5455
};
5556
}
5657

@@ -59,15 +60,15 @@ public string GetRateLimitScriptId()
5960
return appSettings.GetString(ScriptKey);
6061
}
6162

62-
protected virtual LimitGroup GetConfigLimit(params string[] keys)
63+
protected virtual Maybe<LimitGroup> GetConfigLimit(params string[] keys)
6364
{
6465
// Return the first value that is found as keys are in order of precedence
6566
foreach (var key in keys)
6667
{
6768
var limit = appSettings.Get<LimitGroup>(key);
6869
if (limit != null)
6970
{
70-
return limit;
71+
return new Maybe<LimitGroup>(limit);
7172
}
7273
}
7374

@@ -76,17 +77,17 @@ protected virtual LimitGroup GetConfigLimit(params string[] keys)
7677
log.Debug($"No matching config values found for {keys.ToCsv()}");
7778
}
7879

79-
return null;
80+
return new Maybe<LimitGroup>();
8081
}
8182

82-
private LimitGroup GetRequestLimits(IRequest request)
83+
private Maybe<LimitGroup> GetRequestLimits(IRequest request)
8384
{
8485
var requestKeys = keyGenerator.GetConfigKeysForRequest(request);
8586
var requestLimits = GetConfigLimit(requestKeys.ToArray());
8687
return requestLimits;
8788
}
8889

89-
private LimitGroup GetUserLimits(IRequest request)
90+
private Maybe<LimitGroup> GetUserLimits(IRequest request)
9091
{
9192
var userKey = keyGenerator.GetConfigKeysForUser(request);
9293
var userLimit = GetConfigLimit(userKey.ToArray());

src/ServiceStack.RateLimit.Redis/RateLimitFeature.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,14 @@ private static void SetLimitHeaders(IResponse response, RateLimitResult result)
8686
{
8787
var headerResults = RateLimitHeader.Create(result?.Results);
8888

89-
var excludeTypeInfo = JsConfig.ExcludeTypeInfo;
90-
foreach (var header in headerResults)
89+
using (var config = JsConfig.BeginScope())
9190
{
92-
JsConfig.ExcludeTypeInfo = true;
93-
response.AddHeader(header.HeaderName, header.Limits.ToJson());
91+
config.ExcludeTypeInfo = true;
92+
foreach (var header in headerResults)
93+
{
94+
response.AddHeader(header.HeaderName, header.Limits.ToJson());
95+
}
9496
}
95-
96-
JsConfig.ExcludeTypeInfo = excludeTypeInfo;
9797
}
9898

9999
private void ProcessResult(IResponse response, RateLimitResult rateLimitResult)

0 commit comments

Comments
 (0)