diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd585b0..2b588a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### General + +- Updated `RateLimitExceeded` exception to trigger on HTTP 429 instead of old 403. + ## [3.4.0] - 2025-11-10 ### New Endpoint Coverage diff --git a/canvasapi/requester.py b/canvasapi/requester.py index 81b9c4de..5af21bcb 100644 --- a/canvasapi/requester.py +++ b/canvasapi/requester.py @@ -262,21 +262,19 @@ def request( else: raise Unauthorized(response.json()) elif response.status_code == 403: - if b"Rate Limit Exceeded" in response.content: - remaining = str( - response.headers.get("X-Rate-Limit-Remaining", "Unknown") - ) - raise RateLimitExceeded( - "Rate Limit Exceeded. X-Rate-Limit-Remaining: {}".format(remaining) - ) - else: - raise Forbidden(response.text) + raise Forbidden(response.text) elif response.status_code == 404: raise ResourceDoesNotExist("Not Found") elif response.status_code == 409: raise Conflict(response.text) elif response.status_code == 422: raise UnprocessableEntity(response.text) + elif response.status_code == 429: + raise RateLimitExceeded( + "Rate Limit Exceeded. X-Rate-Limit-Remaining: {}".format( + response.headers.get("X-Rate-Limit-Remaining", "Unknown") + ) + ) elif response.status_code > 400: # generic catch-all for error codes raise CanvasException( diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 4e26ae6c..7bb4e6bc 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -17,14 +17,14 @@ Quick Guide +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.Forbidden` | 403 | Canvas has denied access to the resource for this user. | +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ -| :class:`~canvasapi.exceptions.RateLimitExceeded` | 403 | Canvas is throttling this request. Try again later. | -+-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.ResourceDoesNotExist` | 404 | Canvas could not locate the requested resource. | +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.Conflict` | 409 | Canvas had a conflict with an existing resource. | +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.UnprocessableEntity` | 422 | Canvas was unable to process the request. | +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ +| :class:`~canvasapi.exceptions.RateLimitExceeded` | 429 | Canvas is throttling this request. Try again later. | ++-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.RequiredFieldMissing` | N/A | A required keyword argument was not included. | +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+ | :class:`~canvasapi.exceptions.CanvasException` | N/A | An unknown error was thrown. | @@ -83,11 +83,6 @@ Class Reference The :class:`~canvasapi.exceptions.Forbidden` exception is thrown when Canvas returns an HTTP 403 error. -.. autoclass:: canvasapi.exceptions.RateLimitExceeded - :members: - - The :class:`~canvasapi.exceptions.RateLimitExceeded` exception is thrown when Canvas returns an HTTP 403 error that includes the body "403 Forbidden (Rate Limit Exceeded)". It will include the value of the ``X-Rate-Limit-Remaining`` header (if available) for reference. - .. autoclass:: canvasapi.exceptions.Conflict :members: @@ -97,3 +92,8 @@ Class Reference :members: The :class:`~canvasapi.exceptions.UnprocessableEntity` exception is thrown when Canvas returns an HTTP 422 error. + +.. autoclass:: canvasapi.exceptions.RateLimitExceeded + :members: + + The :class:`~canvasapi.exceptions.RateLimitExceeded` exception is thrown when Canvas returns an HTTP 429 error. It will include the value of the ``X-Rate-Limit-Remaining`` header (if available) for reference. diff --git a/tests/fixtures/requests.json b/tests/fixtures/requests.json index 1e0c7a5d..b2106864 100644 --- a/tests/fixtures/requests.json +++ b/tests/fixtures/requests.json @@ -26,25 +26,6 @@ "data": {}, "status_code": 403 }, - "403_rate_limit": { - "method": "ANY", - "endpoint": "403_rate_limit", - "data": "403 Forbidden (Rate Limit Exceeded)", - "headers": { - "X-Rate-Limit-Remaining": "3.14159265359", - "X-Request-Cost": "1.61803398875" - }, - "status_code": 403 - }, - "403_rate_limit_no_remaining_header": { - "method": "ANY", - "endpoint": "403_rate_limit_no_remaining_header", - "data": "403 Forbidden (Rate Limit Exceeded)", - "headers": { - "X-Request-Cost": "1.61803398875" - }, - "status_code": 403 - }, "404": { "method": "ANY", "endpoint": "404", @@ -63,6 +44,29 @@ "data": {}, "status_code": 422 }, + "429_rate_limit": { + "method": "ANY", + "endpoint": "429_rate_limit", + "data": { + "error": "Rate limit exceeded. Please wait and try again." + }, + "headers": { + "X-Rate-Limit-Remaining": "3.14159265359", + "X-Request-Cost": "1.61803398875" + }, + "status_code": 429 + }, + "429_rate_limit_no_remaining_header": { + "method": "ANY", + "endpoint": "429_rate_limit_no_remaining_header", + "data": { + "error": "Rate limit exceeded. Please wait and try again." + }, + "headers": { + "X-Request-Cost": "1.61803398875" + }, + "status_code": 429 + }, "500": { "method": "ANY", "endpoint": "500", @@ -117,4 +121,4 @@ "data": {}, "status_code": 200 } -} \ No newline at end of file +} diff --git a/tests/test_requester.py b/tests/test_requester.py index f6a45a7d..c7dfd30c 100644 --- a/tests/test_requester.py +++ b/tests/test_requester.py @@ -153,28 +153,6 @@ def test_request_403(self, m): with self.assertRaises(Forbidden): self.requester.request("GET", "403") - def test_request_403_RateLimitExeeded(self, m): - register_uris({"requests": ["403_rate_limit"]}, m) - - with self.assertRaises(RateLimitExceeded) as exc: - self.requester.request("GET", "403_rate_limit") - - self.assertEqual( - exc.exception.message, - "Rate Limit Exceeded. X-Rate-Limit-Remaining: 3.14159265359", - ) - - def test_request_403_RateLimitExeeded_no_remaining_header(self, m): - register_uris({"requests": ["403_rate_limit_no_remaining_header"]}, m) - - with self.assertRaises(RateLimitExceeded) as exc: - self.requester.request("GET", "403_rate_limit_no_remaining_header") - - self.assertEqual( - exc.exception.message, - "Rate Limit Exceeded. X-Rate-Limit-Remaining: Unknown", - ) - def test_request_404(self, m): register_uris({"requests": ["404"]}, m) @@ -193,6 +171,28 @@ def test_request_422(self, m): with self.assertRaises(UnprocessableEntity): self.requester.request("GET", "422") + def test_request_429_RateLimitExeeded(self, m): + register_uris({"requests": ["429_rate_limit"]}, m) + + with self.assertRaises(RateLimitExceeded) as exc: + self.requester.request("GET", "429_rate_limit") + + self.assertEqual( + exc.exception.message, + "Rate Limit Exceeded. X-Rate-Limit-Remaining: 3.14159265359", + ) + + def test_request_429_RateLimitExeeded_no_remaining_header(self, m): + register_uris({"requests": ["429_rate_limit_no_remaining_header"]}, m) + + with self.assertRaises(RateLimitExceeded) as exc: + self.requester.request("GET", "429_rate_limit_no_remaining_header") + + self.assertEqual( + exc.exception.message, + "Rate Limit Exceeded. X-Rate-Limit-Remaining: Unknown", + ) + def test_request_500(self, m): register_uris({"requests": ["500"]}, m)