Skip to content

Commit dddae18

Browse files
committed
Added doc on rate limiting
1 parent 3911aa1 commit dddae18

File tree

1 file changed

+220
-1
lines changed

1 file changed

+220
-1
lines changed

docs/throttling.md

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,220 @@
1-
## Coming Soon
1+
A common technique to protect applications from brute-force attacks is rate-limiting.
2+
3+
To get started, you'll need to install the `ellar-throttler` package.
4+
5+
```shell
6+
$(venv) pip install ellar-throttler
7+
```
8+
9+
## ThrottlerModule
10+
11+
The `ThrottlerModule` is the main entry point for this package, and can be used in a synchronous or asynchronous manner.
12+
All the needs to be passed is the `ttl`, the time to live in seconds for the request tracker, and the `limit`,
13+
or how many times an endpoint can be hit before returning a 429 status code.
14+
15+
```python
16+
from ellar.common import Module
17+
from ellar_throttler import ThrottlerModule
18+
19+
@Module(modules=[
20+
ThrottlerModule.setup(ttl=60, limit=10)
21+
])
22+
class ApplicationModule:
23+
pass
24+
```
25+
The above would mean that 10 requests from the same IP can be made to a single endpoint in 1 minute.
26+
27+
```python
28+
from ellar.common import Module
29+
from ellar_throttler import ThrottlerModule, ThrottlerGuard
30+
from ellar.core import Config, ModuleSetup, DynamicModule
31+
32+
def throttler_module_factory(module: ThrottlerModule, config: Config) -> DynamicModule:
33+
return module.setup(ttl=config['THROTTLE_TTL'], limit=config['THROTTLE_LIMIT'])
34+
35+
36+
@Module(modules=[
37+
ModuleSetup(ThrottlerModule, inject=[Config], factory=throttler_module_factory)
38+
])
39+
class ApplicationModule:
40+
pass
41+
42+
# server.py
43+
application = AppFactory.create_from_app_module(
44+
ApplicationModule,
45+
config_module=os.environ.get(
46+
ELLAR_CONFIG_MODULE, "dialerai.config:DevelopmentConfig"
47+
),
48+
global_guards=[ThrottlerGuard]
49+
)
50+
```
51+
The above is also a valid configuration for `ThrottleModule` registration if you want to work with config.
52+
53+
If you add the `ThrottlerGuard` to your application `global_guards`, then all the incoming requests will be throttled by default.
54+
This can also be omitted in favor of `@guards(ThrottlerGuard)`. The global guard check can be skipped using the `@skip_throttle()` decorator mentioned later.
55+
56+
Example with `@guards(ThrottlerGuard)`
57+
```python
58+
# project_name/controller.py
59+
from ellar.common import Controller, guards
60+
from ellar_throttler import throttle, ThrottlerGuard, skip_throttle
61+
62+
@Controller()
63+
class AppController:
64+
65+
@guards(ThrottlerGuard)
66+
@throttle(limit=5, ttl=30)
67+
def normal(self):
68+
pass
69+
70+
```
71+
72+
### **ThrottlerModule Configuration Options:**
73+
74+
- `ttl`: the number of seconds that each request will last in storage
75+
- `limit`: the maximum number of requests within the TTL limit
76+
- `storage`: the storage setting for how to keep track of the requests. see [throttler storage](#throttlerstorageservice)
77+
78+
79+
### Decorators
80+
81+
#### @throttle()
82+
```
83+
@throttle(*, limit: int = 20, ttl: int = 60)
84+
```
85+
This decorator will set `THROTTLER_LIMIT` and `THROTTLER_TTL` metadata on the route, for retrieval from the Reflector class.
86+
It can be applied to controllers and routes.
87+
88+
#### @skip_throttle()
89+
```
90+
@skip_throttle(skip: bool = True)
91+
```
92+
This decorator can be used to skip a route or a class or to negate the skipping of a route in
93+
a class that is skipped.
94+
95+
```python
96+
# project_name/controller.py
97+
from ellar.common import Controller
98+
from ellar_throttler import ThrottlerGuard, skip_throttle
99+
100+
@skip_throttle()
101+
@Controller(guards=[ThrottlerGuard])
102+
class AppController:
103+
104+
def do_skip(self):
105+
pass
106+
107+
@skip_throttle(skip=False)
108+
def dont_skip(self):
109+
pass
110+
```
111+
In the above controller, `dont_skip` would be counted against and
112+
rate-limited while `do_skip` would not be limited in any way.
113+
114+
### **ThrottlerStorage**
115+
Interface to define the methods to handle the details when it comes to keeping track of the requests.
116+
117+
Currently, the key is seen as an `MD5` hash of the IP the `class name` and the `function name`,
118+
to ensure that no unsafe characters are used.
119+
120+
The interface looks like this:
121+
122+
```python
123+
import typing as t
124+
from abc import ABC, abstractmethod
125+
126+
class IThrottlerStorage(ABC):
127+
@property
128+
@abstractmethod
129+
def storage(self) -> t.Dict[str, ThrottlerStorageOption]:
130+
"""
131+
The internal storage with all the request records.
132+
The key is a hashed key based on the current context and IP.
133+
:return:
134+
"""
135+
136+
@abstractmethod
137+
async def increment(self, key: str, ttl: int) -> ThrottlerStorageRecord:
138+
"""
139+
Increment the amount of requests for a given record. The record will
140+
automatically be removed from the storage once its TTL has been reached.
141+
:param key:
142+
:param ttl:
143+
:return:
144+
"""
145+
```
146+
So long as the Storage service implements this interface, it should be usable by the `ThrottlerGuard`.
147+
148+
#### **ThrottlerStorageService**
149+
`ThrottlerStorageService` extends `IThrottlerStorage` which defines the methods to handle the details when
150+
it comes to keeping track of the requests.
151+
152+
By default, `ThrottlerModule` uses `ThrottlerStorageService` when storage option is not provided.
153+
154+
#### **CacheThrottlerStorageService**
155+
`CacheThrottlerStorageService` uses the **`default`** cache that is set up in the `CacheModule` to track throttling.
156+
It depends on **`ICacheService`** which provided by **`CacheModule`**.
157+
158+
A quick example of how to set up `ThrottlerModule` with **CacheThrottlerStorageService**:
159+
160+
```python
161+
from ellar.common import Module
162+
from ellar_throttler import ThrottlerModule, CacheThrottlerStorageService
163+
from ellar.cache import CacheModule
164+
from ellar.cache.backends.local_cache import LocalMemCacheBackend
165+
166+
@Module(modules=[
167+
ThrottlerModule.setup(ttl=60, limit=10, storage=CacheThrottlerStorageService),
168+
CacheModule.setup(default=LocalMemCacheBackend(key_prefix='local'))
169+
])
170+
class ApplicationModule:
171+
pass
172+
```
173+
174+
### **Proxies**
175+
If you are working with multiple proxies, you can override the `get_tracker()` method to pull the value from the header or install
176+
[`ProxyHeadersMiddleware`](https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py)
177+
178+
```python
179+
# throttler_behind_proxy.guard.py
180+
from ellar_throttler import ThrottlerGuard
181+
from ellar.di import injectable
182+
from ellar.core.connection import HTTPConnection
183+
184+
185+
@injectable()
186+
class ThrottlerBehindProxyGuard(ThrottlerGuard):
187+
def get_tracker(self, connection: HTTPConnection) -> str:
188+
return connection.client.host # individualize IP extraction to meet your own needs
189+
190+
# project_name/controller.py
191+
from .throttler_behind_proxy import ThrottlerBehindProxyGuard
192+
193+
@Controller('', guards=[ThrottlerBehindProxyGuard])
194+
class AppController:
195+
pass
196+
```
197+
198+
### **Working with WebSockets**
199+
To work with Websockets you can extend the `ThrottlerGuard` and override the `handle_request` method with the code below:
200+
```python
201+
from ellar_throttler import ThrottlerGuard
202+
from ellar.di import injectable
203+
from ellar.core import IExecutionContext
204+
from ellar_throttler import ThrottledException
205+
206+
@injectable()
207+
class WsThrottleGuard(ThrottlerGuard):
208+
async def handle_request(self, context: IExecutionContext, limit: int, ttl: int) -> bool:
209+
websocket_client = context.switch_to_websocket().get_client()
210+
211+
host = websocket_client.client.host
212+
key = self.generate_key(context, host)
213+
result = await self.storage_service.increment(key, ttl)
214+
215+
# Throw an error when the user reached their limit.
216+
if result.total_hits > limit:
217+
raise ThrottledException(wait=result.time_to_expire)
218+
219+
return True
220+
```

0 commit comments

Comments
 (0)