Repository: ucfopen/canvasapi (python)

Issue:
Handle Rate Limit
When a developer gets a little overzealous and implements multiprocessing, or has many applications/instances of an application using the same access token, It's possible to hit a rate limit. When that happens, Canvas returns a 403 status code. It appears that the only way to distinguish between being rate limited and another type of 403 is the response content will be `403 Forbidden (Rate Limit Exceeded)`.

A new exception (`RateLimitExceeded`?) would help distinguish between when the developer is being rate limited and when they are being denied access for another reason. It would also be beneficial to include the value `X-Rate-Limit-Remaining` header in the exception to show how low the balance is.

Making the new exception a subclass of `Forbidden` might be nice for backwards compatibility where the developer may already be handling `Forbidden` for rate limiting cases.

[Canvas docs on rate limits and throttling](https://canvas.instructure.com/doc/api/file.throttling.html)

Pull request description:
Issue #466 Add RateLimitExceeded Exception
# Proposed Changes

* Add a new exception `RateLimitExceeded` to handle 403 responses with content `403 Forbidden (Rate Limit Exceeded)`

Fixes #466


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5fc59c7..b865df3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### General
+
+- Added `RateLimitExceeded` exception to distinguish between being rate limited and being otherwise forbidden from accesing a resource. It is a subclass of the `Forbidden` exception.
+
 ## [2.1.0] - 2020-12-04
 
 ### New Endpoint Coverage
diff --git a/README.md b/README.md
index d8ddfe7..f4d0c1b 100644
--- a/README.md
+++ b/README.md
@@ -171,4 +171,4 @@ If you have a project that uses CanvasAPI that you'd like to promote, please con
 
 ## Contact Us
 
-Need help? Have an idea? Just want to say hi? Come join us on the [UCF Open Slack Channel](https://ucf-open-slackin.herokuapp.com) and join the `#canvasapi` channel!
+Need help? Have an idea? Feel free to check out our [Discussions](https://github.com/ucfopen/canvasapi/discussions) board. Just want to say hi or get extended spport? Come join us on the [UCF Open Slack Channel](https://ucf-open-slackin.herokuapp.com) and join the `#canvasapi` channel!
diff --git a/canvasapi/exceptions.py b/canvasapi/exceptions.py
index 3e4d22f..2ab31a9 100644
--- a/canvasapi/exceptions.py
+++ b/canvasapi/exceptions.py
@@ -55,6 +55,15 @@ class Forbidden(CanvasException):
     pass
 
 
+class RateLimitExceeded(Forbidden):
+    """
+    Canvas has recieved to many requests from this access token and is
+    throttling this request. Try again later.
+    """
+
+    pass
+
+
 class Conflict(CanvasException):
     """Canvas had a conflict with an existing resource."""
 
diff --git a/canvasapi/requester.py b/canvasapi/requester.py
index 2d236ce..b1b8745 100644
--- a/canvasapi/requester.py
+++ b/canvasapi/requester.py
@@ -10,6 +10,7 @@ from canvasapi.exceptions import (
     Conflict,
     Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -241,7 +242,15 @@ class Requester(object):
             else:
                 raise Unauthorized(response.json())
         elif response.status_code == 403:
-            raise Forbidden(response.text)
+            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)
         elif response.status_code == 404:
             raise ResourceDoesNotExist("Not Found")
         elif response.status_code == 409:
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
index cc3419b..4e26ae6 100644
--- a/docs/exceptions.rst
+++ b/docs/exceptions.rst
@@ -17,6 +17,8 @@ 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.                                |
@@ -81,7 +83,17 @@ 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:
 
     The :class:`~canvasapi.exceptions.Conflict` exception is thrown when Canvas returns an HTTP 409 error.
+
+.. autoclass:: canvasapi.exceptions.UnprocessableEntity
+    :members:
+
+    The :class:`~canvasapi.exceptions.UnprocessableEntity` exception is thrown when Canvas returns an HTTP 422 error.


Test patch:
diff --git a/tests/fixtures/requests.json b/tests/fixtures/requests.json
index ca123fc..1e0c7a5 100644
--- a/tests/fixtures/requests.json
+++ b/tests/fixtures/requests.json
@@ -20,6 +20,31 @@
 		"data": {},
 		"status_code": 401
 	},
+	"403": {
+		"method": "ANY",
+		"endpoint": "403",
+		"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",
diff --git a/tests/test_requester.py b/tests/test_requester.py
index df59eb9..f6a45a7 100644
--- a/tests/test_requester.py
+++ b/tests/test_requester.py
@@ -10,7 +10,9 @@ from canvasapi.exceptions import (
     BadRequest,
     CanvasException,
     Conflict,
+    Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -145,6 +147,34 @@ class TestRequester(unittest.TestCase):
         with self.assertRaises(Unauthorized):
             self.requester.request("GET", "401_unauthorized")
 
+    def test_request_403(self, m):
+        register_uris({"requests": ["403"]}, 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)