Repository: vitalik/django-ninja
Pull request #1373 (2024-12-28 00:50:56)

Support multi-period intervals in throttling
This pull request enhances the throttling functionality to support multi-period intervals, such as `5/30s`, `10/5m`, and `100/2h`. Previously, the throttling implementation only allowed single-period intervals (e.g., `5/s`, `100/d`), which limited the granularity and flexibility of rate limits.

The new implementation expands the allowed syntax for throttle rates to accommodate use cases requiring multi-unit periods, making it easier to define more precise rate limits.

### Motivation

While single-period intervals work for many scenarios, they lack flexibility for more complex use cases. For example:

* Higher granularity: A rate like `20/5m` (20 requests every 5 minutes) cannot be expressed with the current system.
* Short-term bursts: Rates like `50/2h` allow finer control over bursts without spanning a full day.




Patch:
diff --git a/docs/docs/guides/throttling.md b/docs/docs/guides/throttling.md
index cf72f08..fead0fa 100644
--- a/docs/docs/guides/throttling.md
+++ b/docs/docs/guides/throttling.md
@@ -8,6 +8,18 @@ Throttles allows to control the rate of requests that clients can make to an API
 
 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. The key difference is that you need to pass initialized Throttle objects instead of classes (which should give a better performance).
 
+You can specify a rate using the format requests/time-unit, where time-unit represents a number of units followed by an optional unit of time. If the unit is omitted, it defaults to seconds. For example, the following are equivalent and all represent "100 requests per 5 minutes":
+
+    * 100/5m
+    * 100/300s
+    * 100/300
+
+The following units are supported:
+
+    * `s` or `sec`
+    * `m` or `min`
+    * `h` or `hour`
+    * `d` or `day`
 
 ## Usage
 
diff --git a/ninja/throttling.py b/ninja/throttling.py
index 640f104..d702050 100644
--- a/ninja/throttling.py
+++ b/ninja/throttling.py
@@ -67,6 +67,16 @@ class SimpleRateThrottle(BaseThrottle):
     cache_format = "throttle_%(scope)s_%(ident)s"
     scope: Optional[str] = None
     THROTTLE_RATES: Dict[str, Optional[str]] = settings.DEFAULT_THROTTLE_RATES
+    _PERIODS = {
+        "s": 1,
+        "m": 60,
+        "h": 60 * 60,
+        "d": 60 * 60 * 24,
+        "sec": 1,
+        "min": 60,
+        "hour": 60 * 60,
+        "day": 60 * 60 * 24,
+    }
 
     def __init__(self, rate: Optional[str] = None):
         self.rate: Optional[str]
@@ -106,10 +116,23 @@ class SimpleRateThrottle(BaseThrottle):
         """
         if rate is None:
             return (None, None)
-        num, period = rate.split("/")
-        num_requests = int(num)
-        duration = {"s": 1, "m": 60, "h": 3600, "d": 86400}[period[0]]
-        return (num_requests, duration)
+
+        try:
+            count, rest = rate.split("/", 1)
+
+            for unit in self._PERIODS:
+                if rest.endswith(unit):
+                    multi = int(rest[: -len(unit)]) if rest[: -len(unit)] else 1
+                    period = self._PERIODS[unit]
+                    break
+            else:
+                multi, period = int(rest), 1
+
+            count = int(count)
+            return count, multi * period
+
+        except (ValueError, IndexError):
+            raise ValueError(f"Invalid rate format: {rate}") from None
 
     def allow_request(self, request: HttpRequest) -> bool:
         """


Test patch:
diff --git a/tests/test_throttling.py b/tests/test_throttling.py
index b5ad50d..8430454 100644
--- a/tests/test_throttling.py
+++ b/tests/test_throttling.py
@@ -250,9 +250,22 @@ def test_rate_parser():
     th = SimpleRateThrottle("1/s")
     assert th.parse_rate(None) == (None, None)
     assert th.parse_rate("1/s") == (1, 1)
+    assert th.parse_rate("1/sec") == (1, 1)
+    assert th.parse_rate("100/10s") == (100, 10)
+    assert th.parse_rate("100/10sec") == (100, 10)
+    assert th.parse_rate("100/10") == (100, 10)
     assert th.parse_rate("5/m") == (5, 60)
+    assert th.parse_rate("5/min") == (5, 60)
+    assert th.parse_rate("500/10m") == (500, 600)
+    assert th.parse_rate("500/10min") == (500, 600)
     assert th.parse_rate("10/h") == (10, 3600)
+    assert th.parse_rate("10/hour") == (10, 3600)
+    assert th.parse_rate("1000/2h") == (1000, 7200)
+    assert th.parse_rate("1000/2hour") == (1000, 7200)
     assert th.parse_rate("100/d") == (100, 86400)
+    assert th.parse_rate("100/day") == (100, 86400)
+    assert th.parse_rate("10_000/7d") == (10000, 86400 * 7)
+    assert th.parse_rate("10_000/7day") == (10000, 86400 * 7)
 
 
 def test_proxy_throttle():