diff --git a/AUTHORS.md b/AUTHORS.md index fb1924bd..7345e43c 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -32,11 +32,14 @@ - Caitlin Fabian [@Caitlin-Fabian](https://github.com/Caitlin-Fabian) - Cameron Cuff [@ctcuff](https://github.com/ctcuff) - Catherine Abbruzzese [@cat0698](https://github.com/cat0698) +- Christopher Nitta [@cjnitta](https://github.com/cjnitta) - Craig Thompson [@craigdsthompson](https://github.com/craigdsthompson) - Dalton Durst [@UniversalSuperBox](https://github.com/UniversalSuperBox) - Damian Sweeney [@damianfs](https://github.com/damianfs) +- Daniel Bosk [@dbosk](https://github.com/dbosk) - Daniel Brinkman [@DanBrink91](https://github.com/DanBrink91) - Daniel Grobani [@dgrobani](https://github.com/dgrobani) +- Daniel Gawne [@dgwn](https://github.com/dgwn) - Daniel Molares [@dmols](https://github.com/dmols) - David Warden [@dfwarden](https://github.com/dfwarden) - Davis Goff [@Goff-Davis](https://github.com/Goff-Davis) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd585b0..2bedc43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [3.5.0] - 2026-03-12 + +### General + +- Updated `RateLimitExceeded` exception to trigger on HTTP 429 instead of old 403. +- Add a default User-Agent header + +### Bugfixes + +- `QuizGroup` fixed to have `course_id` of the quiz's `course_id`, instead of the `quiz_id` (Thanks, [@cjnitta](https://github.com/cjnitta) and [@dgwn](https://github.com/dgwn)) + ## [3.4.0] - 2025-11-10 ### New Endpoint Coverage @@ -656,7 +667,8 @@ Huge thanks to [@liblit](https://github.com/liblit) for lots of issues, suggesti - Fixed some incorrectly defined parameters - Fixed an issue where tests would fail due to an improperly configured requires block -[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v3.4.0...develop +[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v3.5.0...develop +[3.5.0]: https://github.com/ucfopen/canvasapi/compare/v3.4.0...v3.5.0 [3.4.0]: https://github.com/ucfopen/canvasapi/compare/v3.3.0...v3.4.0 [3.3.0]: https://github.com/ucfopen/canvasapi/compare/v3.2.0...v3.3.0 [3.2.0]: https://github.com/ucfopen/canvasapi/compare/v3.1.0...v3.2.0 diff --git a/canvasapi/__init__.py b/canvasapi/__init__.py index 7a50a6e5..46277f79 100644 --- a/canvasapi/__init__.py +++ b/canvasapi/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Canvas"] -__version__ = "3.4.0" +__version__ = "3.5.0" diff --git a/canvasapi/quiz.py b/canvasapi/quiz.py index 1602fb0f..41ed481b 100644 --- a/canvasapi/quiz.py +++ b/canvasapi/quiz.py @@ -112,7 +112,7 @@ def create_question_group(self, quiz_groups, **kwargs): ) response_json = response.json() - response_json["quiz_groups"][0].update({"course_id": self.id}) + response_json["quiz_groups"][0].update({"course_id": self.course_id}) return QuizGroup(self._requester, response_json.get("quiz_groups")[0]) diff --git a/canvasapi/requester.py b/canvasapi/requester.py index 81b9c4de..abb0e2af 100644 --- a/canvasapi/requester.py +++ b/canvasapi/requester.py @@ -133,7 +133,7 @@ def request( _url=None, _kwargs=None, json=False, - **kwargs + **kwargs, ): """ Make a request to the Canvas API and return the response. @@ -167,6 +167,8 @@ def request( :type json: `bool` :rtype: :class:`requests.Response` """ + from canvasapi import __version__ + # Check for specific URL endpoints available from Canvas. If not # specified, pass the given URL and move on. if not _url: @@ -185,6 +187,9 @@ def request( auth_header = {"Authorization": "Bearer {}".format(self.access_token)} headers.update(auth_header) + if "User-Agent" not in headers: + headers["User-Agent"] = f"python-canvasapi/{__version__}" + # Convert kwargs into list of 2-tuples and combine with _kwargs. _kwargs = _kwargs or [] _kwargs.extend(kwargs.items()) @@ -262,21 +267,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)