Repository: praw-dev/praw

Issue:
API ratelimit hit with chat
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
Live chat comments have a ratelimit of 5 seconds per message, but this message is returned as any other APIException. 

**To Reproduce**
Steps to reproduce the behavior:
1. Obtain a submission instance that is a live chat
2.Just reply twice to the same submission in succession

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
The program should wait for the ratelimit.

**Code/Logs**
<!-- include your code, without the Reddit() initialization, so as to not leak private credentials. -->
```
---------------------------------------------------------------------------
APIException                              Traceback (most recent call last)
<ipython-input-14-5b88e4f8d60d> in <module>
      1 while True:
----> 2     print(repr(s[1].reply("F")))
      3 

/usr/local/lib/python3.7/site-packages/praw/models/reddit/mixins/replyable.py in reply(self, body)
     31         """
     32         data = {"text": body, "thing_id": self.fullname}
---> 33         comments = self._reddit.post(API_PATH["comment"], data=data)
     34         try:
     35             return comments[0]

/usr/local/lib/python3.7/site-packages/praw/reddit.py in post(self, path, data, files, params)
    553             "POST", path, data=data or {}, files=files, params=params
    554         )
--> 555         return self._objector.objectify(data)
    556 
    557     def put(

/usr/local/lib/python3.7/site-packages/praw/objector.py in objectify(self, data)
    168             errors = data["json"]["errors"]
    169             if len(errors) == 1:
--> 170                 raise APIException(*errors[0])
    171             assert not errors
    172         elif isinstance(data, dict):

APIException: RATELIMIT: 'you are doing that too much. try again in 5 seconds.' on field 'ratelimit'
```

**System Info**
 - OS: MacOS 12.14.6
 - Python: 3.7.7
 - PRAW Version: 6.5.1


Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index da741071..17cdef3b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -65,6 +65,11 @@ Unreleased
 * :class:`.Reddit` has a new configurable parameter, ``timeout``. This defaults
   to 16 seconds. It controls how long PRAW will wait for a response before
   throwing an exception.
+* PRAW now handles ratelimit errors returned as instances of
+  :class:`.RedditAPIException`.
+* :class:`.Reddit` has one new parameter, ``ratelimit_seconds`` . The parameter
+  configures the maximum amount of seconds to catch ratelimits for.
+  It defaults to 5 seconds when not specified.
 
 **Changed**
 
diff --git a/praw/config.py b/praw/config.py
index 47508a08..6e0574c4 100644
--- a/praw/config.py
+++ b/praw/config.py
@@ -137,10 +137,18 @@ class Config:
         ):
             setattr(self, attribute, self._fetch_or_not_set(attribute))
 
-        for required_attribute in ("oauth_url", "reddit_url", "timeout"):
+        for required_attribute in (
+            "oauth_url",
+            "ratelimit_seconds",
+            "reddit_url",
+            "timeout",
+        ):
             setattr(self, required_attribute, self._fetch(required_attribute))
 
-        for attribute, conversion in {"timeout": int}.items():
+        for attribute, conversion in {
+            "ratelimit_seconds": int,
+            "timeout": int,
+        }.items():
             try:
                 setattr(self, attribute, conversion(getattr(self, attribute)))
             except ValueError:
diff --git a/praw/praw.ini b/praw/praw.ini
index f58b6406..fe29a1ba 100644
--- a/praw/praw.ini
+++ b/praw/praw.ini
@@ -13,6 +13,9 @@ trophy_kind=t6
 # The URL prefix for OAuth-related requests.
 oauth_url=https://oauth.reddit.com
 
+# The amount of seconds to ratelimit
+ratelimit_seconds=5
+
 # The URL prefix for regular requests.
 reddit_url=https://www.reddit.com
 
diff --git a/praw/reddit.py b/praw/reddit.py
index 79db34c4..54048285 100644
--- a/praw/reddit.py
+++ b/praw/reddit.py
@@ -1,7 +1,10 @@
 """Provide the Reddit class."""
 import configparser
 import os
+import re
+import time
 from itertools import islice
+from logging import getLogger
 from typing import IO, Any, Dict, Generator, Iterable, Optional, Type, Union
 from warnings import warn
 
@@ -41,6 +44,8 @@ Redditor = models.Redditor
 Submission = models.Submission
 Subreddit = models.Subreddit
 
+logger = getLogger("praw")
+
 
 class Reddit:
     """The Reddit class provides convenient access to Reddit's API.
@@ -59,6 +64,7 @@ class Reddit:
     """
 
     update_checked = False
+    _ratelimit_regex = re.compile(r"([0-9]{1,2}) (seconds?|minutes?)")
 
     @property
     def _next_unique(self):
@@ -481,8 +487,7 @@ class Reddit:
             None).
 
         """
-        data = self.request("GET", path, params=params)
-        return self._objector.objectify(data)
+        return self._objectify_request(method="GET", params=params, path=path)
 
     def info(
         self,
@@ -542,6 +547,50 @@ class Reddit:
 
         return generator(url)
 
+    def _objectify_request(
+        self,
+        data: Optional[
+            Union[Dict[str, Union[str, Any]], bytes, IO, str]
+        ] = None,
+        files: Optional[Dict[str, IO]] = None,
+        method: str = "",
+        params: Optional[Union[str, Dict[str, str]]] = None,
+        path: str = "",
+    ) -> Any:
+        """Run a request through the ``Objector``.
+
+        :param data: Dictionary, bytes, or file-like object to send in the body
+            of the request (default: None).
+        :param files: Dictionary, filename to file (like) object mapping
+            (default: None).
+        :param method: The HTTP method (e.g., GET, POST, PUT, DELETE).
+        :param params: The query parameters to add to the request (default:
+            None).
+        :param path: The path to fetch.
+
+        """
+        return self._objector.objectify(
+            self.request(
+                data=data, files=files, method=method, params=params, path=path
+            )
+        )
+
+    def _handle_rate_limit(
+        self, exception: RedditAPIException
+    ) -> Optional[Union[int]]:
+        for item in exception.items:
+            if item.error_type == "RATELIMIT":
+                amount_search = self._ratelimit_regex.search(item.message)
+                if not amount_search:
+                    break
+                seconds = int(amount_search.group(1))
+                if "minute" in amount_search.group(2):
+                    seconds *= 60
+                if seconds <= int(self.config.ratelimit_seconds):
+                    sleep_seconds = seconds + min(seconds / 10, 1)
+                    return sleep_seconds
+        return None
+
     def patch(
         self,
         path: str,
@@ -556,8 +605,7 @@ class Reddit:
             of the request (default: None).
 
         """
-        data = self.request("PATCH", path, data=data)
-        return self._objector.objectify(data)
+        return self._objectify_request(data=data, method="PATCH", path=path)
 
     def post(
         self,
@@ -579,10 +627,32 @@ class Reddit:
             None).
 
         """
-        data = self.request(
-            "POST", path, data=data or {}, files=files, params=params
-        )
-        return self._objector.objectify(data)
+        data = data or {}
+        try:
+            return self._objectify_request(
+                data=data,
+                files=files,
+                method="POST",
+                params=params,
+                path=path,
+            )
+        except RedditAPIException as exception:
+            seconds = self._handle_rate_limit(exception=exception)
+            if seconds is not None:
+                logger.debug(
+                    "Rate limit hit, sleeping for {:.2f} seconds".format(
+                        seconds
+                    )
+                )
+                time.sleep(seconds)
+                return self._objectify_request(
+                    data=data,
+                    files=files,
+                    method="POST",
+                    params=params,
+                    path=path,
+                )
+            raise
 
     def put(
         self,
@@ -598,8 +668,7 @@ class Reddit:
             of the request (default: None).
 
         """
-        data = self.request("PUT", path, data=data)
-        return self._objector.objectify(data)
+        return self._objectify_request(data=data, method="PUT", path=path,)
 
     def random_subreddit(self, nsfw: bool = False) -> Subreddit:
         """Return a random lazy instance of :class:`~.Subreddit`.


Test patch:
diff --git a/tests/unit/test_reddit.py b/tests/unit/test_reddit.py
index 0a664c5a..966fef5a 100644
--- a/tests/unit/test_reddit.py
+++ b/tests/unit/test_reddit.py
@@ -8,7 +8,7 @@ from prawcore.exceptions import BadRequest
 
 from praw import Reddit, __version__
 from praw.config import Config
-from praw.exceptions import ClientException
+from praw.exceptions import ClientException, RedditAPIException
 
 from . import UnitTest
 
@@ -61,6 +61,14 @@ class TestReddit(UnitTest):
             == "An incorrect config type was given for option timeout. The "
             "expected type is int, but the given value is test."
         )
+        with pytest.raises(ValueError) as excinfo:
+            Reddit(ratelimit_seconds="test", **self.REQUIRED_DUMMY_SETTINGS)
+        assert (
+            excinfo.value.args[0]
+            == "An incorrect config type was given for option "
+            "ratelimit_seconds. The expected type is int, but the given value "
+            "is test."
+        )
 
     def test_info__not_list(self):
         with pytest.raises(TypeError) as excinfo:
@@ -80,6 +88,78 @@ class TestReddit(UnitTest):
     def test_multireddit(self):
         assert self.reddit.multireddit("bboe", "aa").path == "/user/bboe/m/aa"
 
+    @mock.patch(
+        "praw.Reddit.request",
+        side_effect=[
+            {
+                "json": {
+                    "errors": [
+                        [
+                            "RATELIMIT",
+                            "You are doing that too much. Try again in 5 "
+                            "seconds.",
+                            "ratelimit",
+                        ]
+                    ]
+                }
+            },
+            {
+                "json": {
+                    "errors": [
+                        [
+                            "RATELIMIT",
+                            "You are doing that too much. Try again in 5 "
+                            "seconds.",
+                            "ratelimit",
+                        ]
+                    ]
+                }
+            },
+            {
+                "json": {
+                    "errors": [
+                        [
+                            "RATELIMIT",
+                            "You are doing that too much. Try again in 10 "
+                            "minutes.",
+                            "ratelimit",
+                        ]
+                    ]
+                }
+            },
+            {
+                "json": {
+                    "errors": [
+                        [
+                            "RATELIMIT",
+                            "APRIL FOOLS FROM REDDIT, TRY AGAIN",
+                            "ratelimit",
+                        ]
+                    ]
+                }
+            },
+            {},
+        ],
+    )
+    @mock.patch("time.sleep", return_value=None)
+    def test_post_ratelimit(self, __, _):
+        with pytest.raises(RedditAPIException) as exc:
+            self.reddit.post("test")
+        assert (
+            exc.value.message
+            == "You are doing that too much. Try again in 5 seconds."
+        )
+        with pytest.raises(RedditAPIException) as exc2:
+            self.reddit.post("test")
+        assert (
+            exc2.value.message
+            == "You are doing that too much. Try again in 10 minutes."
+        )
+        with pytest.raises(RedditAPIException) as exc3:
+            self.reddit.post("test")
+        assert exc3.value.message == "APRIL FOOLS FROM REDDIT, TRY AGAIN"
+        assert self.reddit.post("test") == {}
+
     def test_read_only__with_authenticated_core(self):
         with Reddit(
             password=None,