Skip to content

Commit

Permalink
Merge pull request #1208 from vitalik/throttling
Browse files Browse the repository at this point in the history
Throttling
  • Loading branch information
vitalik authored Jun 27, 2024
2 parents 2a13704 + 62888c7 commit 2b69b16
Show file tree
Hide file tree
Showing 10 changed files with 752 additions and 30 deletions.
95 changes: 95 additions & 0 deletions docs/docs/guides/throttling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Throttling

Throttles allows to control the rate of requests that clients can make to an API. Django Ninja allows to set custom throttlers globally (across all operations in NinjaAPI instance), on router level and each operation individually.

!!! note
The application-level throttling that Django Ninja provides should not be considered a security measure or protection against brute forcing or denial-of-service attacks. Deliberately malicious actors will always be able to spoof IP origins. The built-in throttling implementations are implemented using Django's cache framework, and use non-atomic operations to determine the request rate, which may sometimes result in some fuzziness.


Django Ninja’s throttling feature is pretty much based on what Django Rest Framework (DRF) uses, which you can check out [here](https://www.django-rest-framework.org/api-guide/throttling/). So, if you’ve already got custom throttling set up for DRF, there’s a good chance it’ll work with Django Ninja right out of the box. They key difference is that you need to pass initialized Throttle objects instead of classes (which should give a better performance)


## Usage

### Global

The following example will limit unauthenticated users to only 10 requests per second, while authenticated can make 100/s

```Python
from ninja.throttling import AnonRateThrottle, AuthRateThrottle

api = NinjaAPI(
throttle=[
AnonRateThrottle('10/s'),
AuthRateThrottle('100/s'),
],
)
```

!!! tip
`throttle` argument accepts single object and list of throttle objects

### Router level

Pass `throttle` argument either to `add_router` function

```Python
api = NinjaAPI()
...

api.add_router('/sensitive', 'myapp.api.router', throttle=AnonRateThrottle('100/m'))
```

or directly to init of the Router class:

```Python
router = Router(..., throttle=[AnonRateThrottle('1000/h')])
```


### Operation level

If `throttle` argument is passed to operation - it will overrule all global and router throttles:

```Python
from ninja.throttling import UserRateThrottle

@api.get('/some', throttle=[UserRateThrottle('10000/d')])
def some(request):
...
```

## Builtin throttlers

### AnonRateThrottle

Will only throttle unauthenticated users. The IP address of the incoming request is used to generate a unique key to throttle against.


### UserRateThrottle

Will throttle users (**if you use django build-in user authentication**) to a given rate of requests across the API. The user id is used to generate a unique key to throttle against. Unauthenticated requests will fall back to using the IP address of the incoming request to generate a unique key to throttle against.

### AuthRateThrottle

Will throttle by Django ninja [authentication](guides/authentication.md) to a given rate of requests across the API. Unauthenticated requests will fall back to using the IP address of the incoming request to generate a unique key to throttle against.

Note: the cache key in case of `request.auth` will be generated by `sha256(str(request.auth))` - so if you returning some custom objects inside authentication make sure to implement `__str__` method that will return a unique value for the user.


## Custom throttles
To create a custom throttle, override `BaseThrottle` (or any of builtin throttles) and implement `.allow_request(self, request)`. The method should return `True` if the request should be allowed, and `False` otherwise.

Example

```Python
from ninja.throttling import AnonRateThrottle

class NoReadsThrottle(AnonRateThrottle):
"""Do not throttle GET requests"""

def allow_request(self, request):
if request.method == "GET":
return True
return super().allow_request(request)
```
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ nav:
- guides/response/response-renderers.md
- Splitting your API with Routers: guides/routers.md
- guides/authentication.md
- guides/throttling.md
- guides/testing.md
- guides/api-docs.md
- guides/errors.md
Expand Down
27 changes: 13 additions & 14 deletions ninja/conf.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
from math import inf
from typing import Dict, Optional

from django.conf import settings as django_settings
from pydantic import BaseModel, Field


class Settings(BaseModel):
"""
Alter these by modifying the values in Django's settings module (usually
`settings.py`).
Attributes:
NINJA_PAGINATION_CLASS (str):
The pagination class to use. Defaults to
`ninja.pagination.LimitOffsetPagination`.
NINJA_PAGINATION_PER_PAGE (int):
The default page size. Defaults to `100`.
NINJA_PAGINATION_MAX_LIMIT (int):
The maximum number of results per page. Defaults to `inf`.
"""

# Pagination
PAGINATION_CLASS: str = Field(
"ninja.pagination.LimitOffsetPagination", alias="NINJA_PAGINATION_CLASS"
)
PAGINATION_PER_PAGE: int = Field(100, alias="NINJA_PAGINATION_PER_PAGE")
PAGINATION_MAX_LIMIT: int = Field(inf, alias="NINJA_PAGINATION_MAX_LIMIT")

# Throttling
NUM_PROXIES: Optional[int] = Field(None, alias="NINJA_NUM_PROXIES")
DEFAULT_THROTTLE_RATES: Dict[str, Optional[str]] = Field(
{
"auth": "10000/day",
"user": "10000/day",
"anon": "1000/day",
},
alias="NINJA_DEFAULT_THROTTLE_RATES",
)

class Config:
from_attributes = True

Expand Down
8 changes: 7 additions & 1 deletion ninja/errors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import traceback
from functools import partial
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING, List, Optional

from django.conf import settings
from django.http import Http404, HttpRequest, HttpResponse
Expand Down Expand Up @@ -53,6 +53,12 @@ def __str__(self) -> str:
return self.message


class Throttled(HttpError):
def __init__(self, wait: Optional[int]) -> None:
self.wait = wait
super().__init__(status_code=429, message="Too many requests.")


def set_default_exc_handlers(api: "NinjaAPI") -> None:
api.add_exception_handler(
Exception,
Expand Down
21 changes: 21 additions & 0 deletions ninja/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ninja.parser import Parser
from ninja.renderers import BaseRenderer, JSONRenderer
from ninja.router import Router
from ninja.throttling import BaseThrottle
from ninja.types import DictStrAny, TCallable
from ninja.utils import is_debug_server, normalize_path

Expand Down Expand Up @@ -61,6 +62,7 @@ def __init__(
urls_namespace: Optional[str] = None,
csrf: bool = False,
auth: Optional[Union[Sequence[Callable], Callable, NOT_SET_TYPE]] = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
renderer: Optional[BaseRenderer] = None,
parser: Optional[Parser] = None,
default_router: Optional[Router] = None,
Expand Down Expand Up @@ -111,6 +113,8 @@ def __init__(
else:
self.auth = auth

self.throttle = throttle

self._routers: List[Tuple[str, Router]] = []
self.default_router = default_router or Router()
self.add_router("", self.default_router)
Expand All @@ -120,6 +124,7 @@ def get(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -141,6 +146,7 @@ def get(
return self.default_router.get(
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -161,6 +167,7 @@ def post(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -182,6 +189,7 @@ def post(
return self.default_router.post(
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -202,6 +210,7 @@ def delete(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -223,6 +232,7 @@ def delete(
return self.default_router.delete(
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -243,6 +253,7 @@ def patch(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -264,6 +275,7 @@ def patch(
return self.default_router.patch(
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -284,6 +296,7 @@ def put(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -305,6 +318,7 @@ def put(
return self.default_router.put(
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -326,6 +340,7 @@ def api_operation(
path: str,
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
response: Any = NOT_SET,
operation_id: Optional[str] = None,
summary: Optional[str] = None,
Expand All @@ -344,6 +359,7 @@ def api_operation(
methods,
path,
auth=auth is NOT_SET and self.auth or auth,
throttle=throttle is NOT_SET and self.throttle or throttle,
response=response,
operation_id=operation_id,
summary=summary,
Expand All @@ -365,6 +381,7 @@ def add_router(
router: Union[Router, str],
*,
auth: Any = NOT_SET,
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
tags: Optional[List[str]] = None,
parent_router: Optional[Router] = None,
) -> None:
Expand All @@ -374,6 +391,10 @@ def add_router(

if auth is not NOT_SET:
router.auth = auth

if throttle is not NOT_SET:
router.throttle = throttle

if tags is not None:
router.tags = tags

Expand Down
Loading

0 comments on commit 2b69b16

Please sign in to comment.