Repository: encode/django-rest-framework

Issue:
Check each throttle class before returning a 429 response
## Checklist

- [x] I have verified that that issue exists against the `master` branch of Django REST framework.
- [x] I have searched for similar issues in both open and closed tickets and cannot find a duplicate.
- [x] This is not a usage question. (Those should be directed to the [discussion group](https://groups.google.com/forum/#!forum/django-rest-framework) instead.)
- [ ] This cannot be dealt with as a third party library. (We prefer new functionality to be [in the form of third party libraries](https://www.django-rest-framework.org/community/third-party-packages/#about-third-party-packages) where possible.)
- [x] I have reduced the issue to the simplest possible case.
- [ ] I have included a failing test as a pull request. (If you are unable to do so we can still accept the issue.)

## Steps to reproduce
- Add several throttling classes to a view

## Expected behavior
- Each request is added to the history of all throttling classes, even when one of them disallows the request

## Actual behavior
- If a single throttling class disallows the request, the other throttling classes never see the request and it never gets added to their history

This is because [DRF checks throttling classes in a loop and raises as soon as it finds one failing](https://github.com/encode/django-rest-framework/blob/9811a29a5a1348f1a5de30a9a7a5d0f8d2fd4843/rest_framework/views.py#L353-L355):

```python
        for throttle in self.get_throttles():
            if not throttle.allow_request(request, self):
                self.throttled(request, throttle.wait())
```

It's faster, but this makes throttling with multiple classes less efficient as some throttling classes never see some of the requests.

See also https://github.com/encode/django-rest-framework/issues/6666

It could be done as a third-party library, but I believe it's better to have this change in DRF itself to make throttling more efficient against brute-force attacks. I haven't written the pull request yet, but it's relatively trivial and I'd be willing to do it, I just want to validate the idea first.



Discussion:
Example of where it can matter:

Consider a view protected with the following classes, using `throttling_classes = (TestBurstThrottle, Test SustainedThrottle)`:
```python
 class TestBurstThrottle(AnonRateThrottle):
    rate = '3/min'

 class TestSustainedThrottle(AnonRateThrottle):
    rate = '10/hour'
```

If someone makes 4 requests in quick succession, the 4th one is blocked and they need to wait one minute to continue. But once that minute is up, instead of being allowed 6 more requests for the hour, they're allowed 7, because the 4th request was never sent to the `TestSustainedThrottle` class.
Is this different to #6666?
In any case - yes sounds like it'd be a valid change. Happy to consider pull requests towards it.
It's different, yes: #6666 would make throttle failures count towards history, but when there are multiple throttling classes set, that still wouldn't be enough, the `check_throttles()` code in the views would bail before even sending the request to the other throttling classes. Both fixes are nice to have but work independently. 


Resolving patch:
diff --git a/rest_framework/views.py b/rest_framework/views.py
--- a/rest_framework/views.py
+++ b/rest_framework/views.py
@@ -350,9 +350,13 @@ def check_throttles(self, request):
         Check if request should be throttled.
         Raises an appropriate exception if the request is throttled.
         """
+        throttle_durations = []
         for throttle in self.get_throttles():
             if not throttle.allow_request(request, self):
-                self.throttled(request, throttle.wait())
+                throttle_durations.append(throttle.wait())
+
+        if throttle_durations:
+            self.throttled(request, max(throttle_durations))
 
     def determine_version(self, request, *args, **kwargs):
         """


Test patch:
diff --git a/tests/test_throttling.py b/tests/test_throttling.py
--- a/tests/test_throttling.py
+++ b/tests/test_throttling.py
@@ -30,6 +30,11 @@ class User3MinRateThrottle(UserRateThrottle):
     scope = 'minutes'
 
 
+class User6MinRateThrottle(UserRateThrottle):
+    rate = '6/min'
+    scope = 'minutes'
+
+
 class NonTimeThrottle(BaseThrottle):
     def allow_request(self, request, view):
         if not hasattr(self.__class__, 'called'):
@@ -38,6 +43,13 @@ def allow_request(self, request, view):
         return False
 
 
+class MockView_DoubleThrottling(APIView):
+    throttle_classes = (User3SecRateThrottle, User6MinRateThrottle,)
+
+    def get(self, request):
+        return Response('foo')
+
+
 class MockView(APIView):
     throttle_classes = (User3SecRateThrottle,)
 
@@ -80,7 +92,8 @@ def set_throttle_timer(self, view, value):
         """
         Explicitly set the timer, overriding time.time()
         """
-        view.throttle_classes[0].timer = lambda self: value
+        for cls in view.throttle_classes:
+            cls.timer = lambda self: value
 
     def test_request_throttling_expires(self):
         """
@@ -115,6 +128,37 @@ def test_request_throttling_is_per_user(self):
         """
         self.ensure_is_throttled(MockView, 200)
 
+    def test_request_throttling_multiple_throttles(self):
+        """
+        Ensure all throttle classes see each request even when the request is
+        already being throttled
+        """
+        self.set_throttle_timer(MockView_DoubleThrottling, 0)
+        request = self.factory.get('/')
+        for dummy in range(4):
+            response = MockView_DoubleThrottling.as_view()(request)
+        assert response.status_code == 429
+        assert int(response['retry-after']) == 1
+
+        # At this point our client made 4 requests (one was throttled) in a
+        # second. If we advance the timer by one additional second, the client
+        # should be allowed to make 2 more before being throttled by the 2nd
+        # throttle class, which has a limit of 6 per minute.
+        self.set_throttle_timer(MockView_DoubleThrottling, 1)
+        for dummy in range(2):
+            response = MockView_DoubleThrottling.as_view()(request)
+            assert response.status_code == 200
+
+        response = MockView_DoubleThrottling.as_view()(request)
+        assert response.status_code == 429
+        assert int(response['retry-after']) == 59
+
+        # Just to make sure check again after two more seconds.
+        self.set_throttle_timer(MockView_DoubleThrottling, 2)
+        response = MockView_DoubleThrottling.as_view()(request)
+        assert response.status_code == 429
+        assert int(response['retry-after']) == 58
+
     def ensure_response_header_contains_proper_throttle_field(self, view, expected_headers):
         """
         Ensure the response returns an Retry-After field with status and next attributes