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:
Great discovery.

Generally, we intentionally don't handle this RATELIMIT exception because it also happens to for submissions and, if I'm not mistaken, the time can increase to be multiple minutes. However, if you are certain the rate limit maximum is only 5 seconds on such submissions, then it might make sense to automatically handle it.

For the general case, It'd be great to provide a code snippet in the documentation that enables people to catch and automatically handle such exceptions.
@bboe Do you think of making a reddit config option to catch all ratelimits and sleep, regardless of time? This would be default False, but catch submission ratelimits and etc. 

Another option is to use regex on the message, and if it detects `([0-9]{1,2}) seconds?`, it will take the first match group and sleep that many seconds. 
Maybe a hybrid config option for how many seconds to automatically handle would be ideal. We could default it to 5.
Good idea. 

Resolving patch:
diff --git a/praw/config.py b/praw/config.py
--- a/praw/config.py
+++ b/praw/config.py
@@ -137,10 +137,18 @@ def _initialize_attributes(self):
         ):
             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/reddit.py b/praw/reddit.py
--- 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 @@
 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 @@ def get(
             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 @@ def generator(url):
 
         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 @@ def patch(
             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 @@ def post(
             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 @@ def put(
             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
--- a/tests/unit/test_reddit.py
+++ b/tests/unit/test_reddit.py
@@ -8,7 +8,7 @@
 
 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 @@ def test_invalid_config(self):
             == "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 @@ def test_live_info__invalid_param(self):
     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,