Repository: praw-dev/praw

Issue:
Replying to comments in quarantined subreddits returns an empty object
I have a bot that fetches comment ids from pushshift, then does some work and replies to the comment. If the comment is in a quarantined subreddit that I have not clicked through the quarantine warning for, I get the following error.

```
praw/models/reddit/mixins/replyable.py", line 26, in reply
    return self._reddit.post(API_PATH["comment"], data=data)[0]
IndexError: list index out of range
```
The reply call succeeds, returning a valid http code and the following json
```
{'json': {'errors': [], 'data': {'things': []}}}
```
`objector.objectify` fails to parse this and returns an empty array. Importantly, the comment is successfully created.

I'm not really sure what should happen in this case, but I do think it should be a more clear error message. Happy to put together a pull request if anyone has any ideas.


Discussion:
The alternatives I propose:

* Option 1 - return `None`.
* Option 2 - use the authenticated user’s comment history to retrieve the reply.
* Option 3 - raise a fake APIException.
* Option 4 - have separate `reply()` methods: one that raises and one that doesn’t.

If the returned comment is going to be used by the client then option 2 would be preferred, but when it’s not, option 1 would be the best. Since creating the comment on reddit is `reply()`’s primary purpose, I’m leaning towards option 1 as being the most pure.

Option 3 allows for a clear error message, although unlike option 1, it suggests that the action didn’t go though, and this can be awkward to deal with if the user requires that the reply succeeds and tries to retry upon APIExceptions. We could go with option 4 to avoid this potential mistake but it seems unclean.

The cleanest option would be to add a new exception type and throw that. IMO this case warrants it since it's a unique combination of not returning a result while actually creating the comment.

Other than that I think 2 would be best. I think it's a fairly rare use case, so the extra call wouldn't be that big a deal. But I don't know if there's any precedent for making two api calls in a single function call.

And of course it would be the most complicated to implement, since the layers are fairly well detached.
Actually, there could be a bad precedent waiting in option 2 if down the road PRAW becomes asynchronous: extra care would have to be taken in a concurrent setting to avoid a race condition. Even then, option 2 still involves quite a few moving parts and would be unpleasant to maintain.

Also, what if there is some other yet to be discovered scenario where an empty object gets returned but the comment fails to be created? We wouldn’t have any way of telling if the last comment in the user’s history was the one connected to the last api call. Retrieving the last comment in the user’s history could be risky.
&nbsp;

Option 5 - raise a new “`NoCommentReturned`” exception.

Is this really better than the slightly more simple option 1 though? I don’t really see the advantage in raising a new kind of exception as opposed to just returning `None`, because in either case if the comment object is important then you’d still need to do a check on it, the difference being whether it’s done using a `try`/`catch` block or an `if`. If `None` is returned then those who don’t need the comment object won’t need to have to write out a `try`/`catch` only to `pass` on it.

The empty object response can be a thing that’s mentioned in the docstring of the `reply()` method, rather than in an exception message.

The problem with returning None is that it's much harder to diagnose. If I wasn't paying close attention to my error messages, I would probably have just assumed it was something going wrong with the API and it had accidentally returned something invalid, and restarted my bot. A SuccessNothingReturned error would make it more obvious what happened. The error could only be thrown if the returned object is empty, but the return code is a 2XX.

I do think that returning None would be better than the current situation, and would be trivial to implement.
Well I did think of returning some sort of sentinel object that could make it clearer for the user, but I figured that idea was similar to returning `None`, and returning `None` seems sensible enough. The API response doesn’t return a comment object and a `None` value reflects that appropriately.

Anyone else have an opinion on what should happen here? @bboe @jarhill0

I think returning `None` is the right thing to do, but then again I haven’t made as many bots as @Watchful1


Resolving patch:
diff --git a/praw/models/reddit/mixins/replyable.py b/praw/models/reddit/mixins/replyable.py
--- a/praw/models/reddit/mixins/replyable.py
+++ b/praw/models/reddit/mixins/replyable.py
@@ -9,7 +9,14 @@ def reply(self, body):
         """Reply to the object.
 
         :param body: The markdown formatted content for a comment.
-        :returns: A :class:`~.Comment` object for the newly created comment.
+        :returns: A :class:`~.Comment` object for the newly created
+            comment or ``None`` if Reddit doesn't provide one.
+
+        A ``None`` value can be returned if the target is a comment or
+        submission in a quarantined subreddit and the authenticated user
+        has not opt-ed in to viewing the content. When this happens the
+        comment will be sucessfully created on Reddit and can be retried
+        by drawing the comment from the user's comment history.
 
         Example usage:
 
@@ -23,4 +30,8 @@ def reply(self, body):
 
         """
         data = {"text": body, "thing_id": self.fullname}
-        return self._reddit.post(API_PATH["comment"], data=data)[0]
+        comments = self._reddit.post(API_PATH["comment"], data=data)
+        try:
+            return comments[0]
+        except IndexError:
+            return None


Test patch:
diff --git a/tests/integration/cassettes/TestComment.test_reply__none.json b/tests/integration/cassettes/TestComment.test_reply__none.json
new file mode 100644
--- /dev/null
+++ b/tests/integration/cassettes/TestComment.test_reply__none.json
@@ -0,0 +1,227 @@
+{
+  "http_interactions": [
+    {
+      "recorded_at": "2019-07-12T15:14:37",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "grant_type=password&password=<PASSWORD>&username=<USERNAME>"
+        },
+        "headers": {
+          "Accept": [
+            "*/*"
+          ],
+          "Accept-Encoding": [
+            "identity"
+          ],
+          "Authorization": [
+            "Basic <BASIC_AUTH>"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "56"
+          ],
+          "Content-Type": [
+            "application/x-www-form-urlencoded"
+          ],
+          "User-Agent": [
+            "<USER_AGENT> PRAW/6.3.2.dev0 prawcore/1.0.1"
+          ]
+        },
+        "method": "POST",
+        "uri": "https://www.reddit.com/api/v1/access_token"
+      },
+      "response": {
+        "body": {
+          "encoding": "UTF-8",
+          "string": "{\"access_token\": \"<ACCESS_TOKEN>\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"*\"}"
+        },
+        "headers": {
+          "Accept-Ranges": [
+            "bytes"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "117"
+          ],
+          "Content-Type": [
+            "application/json; charset=UTF-8"
+          ],
+          "Date": [
+            "Fri, 12 Jul 2019 15:14:37 GMT"
+          ],
+          "Server": [
+            "snooserv"
+          ],
+          "Set-Cookie": [
+            "edgebucket=Lq0e2qhhNmwWTfRixd; Domain=reddit.com; Max-Age=63071999; Path=/;  secure"
+          ],
+          "Strict-Transport-Security": [
+            "max-age=15552000; includeSubDomains; preload"
+          ],
+          "Via": [
+            "1.1 varnish"
+          ],
+          "X-Cache": [
+            "MISS"
+          ],
+          "X-Cache-Hits": [
+            "0"
+          ],
+          "X-Moose": [
+            "majestic"
+          ],
+          "X-Served-By": [
+            "cache-mel19022-MEL"
+          ],
+          "X-Timer": [
+            "S1562944477.924115,VS0,VE587"
+          ],
+          "cache-control": [
+            "max-age=0, must-revalidate"
+          ],
+          "x-content-type-options": [
+            "nosniff"
+          ],
+          "x-frame-options": [
+            "SAMEORIGIN"
+          ],
+          "x-xss-protection": [
+            "1; mode=block"
+          ]
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://www.reddit.com/api/v1/access_token"
+      }
+    },
+    {
+      "recorded_at": "2019-07-12T15:14:38",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "api_type=json&text=TEST&thing_id=t1_eear2ml"
+        },
+        "headers": {
+          "Accept": [
+            "*/*"
+          ],
+          "Accept-Encoding": [
+            "identity"
+          ],
+          "Authorization": [
+            "bearer <ACCESS_TOKEN>"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "43"
+          ],
+          "Content-Type": [
+            "application/x-www-form-urlencoded"
+          ],
+          "Cookie": [
+            "edgebucket=Lq0e2qhhNmwWTfRixd"
+          ],
+          "User-Agent": [
+            "<USER_AGENT> PRAW/6.3.2.dev0 prawcore/1.0.1"
+          ]
+        },
+        "method": "POST",
+        "uri": "https://oauth.reddit.com/api/comment/?raw_json=1"
+      },
+      "response": {
+        "body": {
+          "encoding": "UTF-8",
+          "string": "{\"json\": {\"errors\": [], \"data\": {\"things\": []}}}"
+        },
+        "headers": {
+          "Accept-Ranges": [
+            "bytes"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "48"
+          ],
+          "Content-Type": [
+            "application/json; charset=UTF-8"
+          ],
+          "Date": [
+            "Fri, 12 Jul 2019 15:14:38 GMT"
+          ],
+          "Server": [
+            "snooserv"
+          ],
+          "Strict-Transport-Security": [
+            "max-age=15552000; includeSubDomains; preload"
+          ],
+          "Via": [
+            "1.1 varnish"
+          ],
+          "X-Cache": [
+            "MISS"
+          ],
+          "X-Cache-Hits": [
+            "0"
+          ],
+          "X-Moose": [
+            "majestic"
+          ],
+          "X-Served-By": [
+            "cache-mel19025-MEL"
+          ],
+          "X-Timer": [
+            "S1562944478.720358,VS0,VE438"
+          ],
+          "cache-control": [
+            "private, s-maxage=0, max-age=0, must-revalidate, max-age=0, must-revalidate"
+          ],
+          "expires": [
+            "-1"
+          ],
+          "set-cookie": [
+            "loid=000000000004x25quk.2.1498230528268.Z0FBQUFBQmRLS1BlY18tb1kyUzBNLVdiSXVOOWlsZWptS3hscElnVmg4dVNtdm1EcVJWZ0tacFRGMmRzVUp2VktDMFUtQmRsR3FzcVlsMnhDUEhkMXU0MlItdnhOWHB1UWk0eDBLMmtzZjdGUWJQUEVlMlE0TkZnSGo2YzhVWl80d0w2MWRxY1dYaks; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sun, 11-Jul-2021 15:14:38 GMT; secure",
+            "redesign_optout=true; Domain=reddit.com; Max-Age=94607999; Path=/; expires=Mon, 11-Jul-2022 15:14:37 GMT; secure",
+            "session_tracker=Qy2noNS0midAv6X4Ov.0.1562944477887.Z0FBQUFBQmRLS1BlMHhPMndOU1lYNTc1LUtwb1ZYcGhXMDdGbElmR3lRUmg0VGx4SGxWQkdtYTRIR25nNVJyVFpHVnM0akF2ZkRGalRFSVNRZkV1XzRHVkJTaDJjNlhIT0Zvc3RXVi0xd1FsaDhFTThBdVlRc0xWYXVfb1FoQlJGcEVneVlwQ2tMTXM; Domain=reddit.com; Max-Age=7199; Path=/; expires=Fri, 12-Jul-2019 17:14:38 GMT; secure"
+          ],
+          "x-content-type-options": [
+            "nosniff"
+          ],
+          "x-frame-options": [
+            "SAMEORIGIN"
+          ],
+          "x-ratelimit-remaining": [
+            "598.0"
+          ],
+          "x-ratelimit-reset": [
+            "323"
+          ],
+          "x-ratelimit-used": [
+            "2"
+          ],
+          "x-ua-compatible": [
+            "IE=edge"
+          ],
+          "x-xss-protection": [
+            "1; mode=block"
+          ]
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://oauth.reddit.com/api/comment/?raw_json=1"
+      }
+    }
+  ],
+  "recorded_with": "betamax/0.8.1"
+}
\ No newline at end of file
diff --git a/tests/integration/cassettes/TestSubmission.test_reply__none.json b/tests/integration/cassettes/TestSubmission.test_reply__none.json
new file mode 100644
--- /dev/null
+++ b/tests/integration/cassettes/TestSubmission.test_reply__none.json
@@ -0,0 +1,227 @@
+{
+  "http_interactions": [
+    {
+      "recorded_at": "2019-07-12T15:13:01",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "grant_type=password&password=<PASSWORD>&username=<USERNAME>"
+        },
+        "headers": {
+          "Accept": [
+            "*/*"
+          ],
+          "Accept-Encoding": [
+            "identity"
+          ],
+          "Authorization": [
+            "Basic <BASIC_AUTH>"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "56"
+          ],
+          "Content-Type": [
+            "application/x-www-form-urlencoded"
+          ],
+          "User-Agent": [
+            "<USER_AGENT> PRAW/6.3.2.dev0 prawcore/1.0.1"
+          ]
+        },
+        "method": "POST",
+        "uri": "https://www.reddit.com/api/v1/access_token"
+      },
+      "response": {
+        "body": {
+          "encoding": "UTF-8",
+          "string": "{\"access_token\": \"<ACCESS_TOKEN>\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"*\"}"
+        },
+        "headers": {
+          "Accept-Ranges": [
+            "bytes"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "117"
+          ],
+          "Content-Type": [
+            "application/json; charset=UTF-8"
+          ],
+          "Date": [
+            "Fri, 12 Jul 2019 15:13:01 GMT"
+          ],
+          "Server": [
+            "snooserv"
+          ],
+          "Set-Cookie": [
+            "edgebucket=do264aSECmph8G9l67; Domain=reddit.com; Max-Age=63071999; Path=/;  secure"
+          ],
+          "Strict-Transport-Security": [
+            "max-age=15552000; includeSubDomains; preload"
+          ],
+          "Via": [
+            "1.1 varnish"
+          ],
+          "X-Cache": [
+            "MISS"
+          ],
+          "X-Cache-Hits": [
+            "0"
+          ],
+          "X-Moose": [
+            "majestic"
+          ],
+          "X-Served-By": [
+            "cache-mel19026-MEL"
+          ],
+          "X-Timer": [
+            "S1562944381.224560,VS0,VE612"
+          ],
+          "cache-control": [
+            "max-age=0, must-revalidate"
+          ],
+          "x-content-type-options": [
+            "nosniff"
+          ],
+          "x-frame-options": [
+            "SAMEORIGIN"
+          ],
+          "x-xss-protection": [
+            "1; mode=block"
+          ]
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://www.reddit.com/api/v1/access_token"
+      }
+    },
+    {
+      "recorded_at": "2019-07-12T15:13:02",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "api_type=json&text=TEST&thing_id=t3_ah19vv"
+        },
+        "headers": {
+          "Accept": [
+            "*/*"
+          ],
+          "Accept-Encoding": [
+            "identity"
+          ],
+          "Authorization": [
+            "bearer <ACCESS_TOKEN>"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "42"
+          ],
+          "Content-Type": [
+            "application/x-www-form-urlencoded"
+          ],
+          "Cookie": [
+            "edgebucket=do264aSECmph8G9l67"
+          ],
+          "User-Agent": [
+            "<USER_AGENT> PRAW/6.3.2.dev0 prawcore/1.0.1"
+          ]
+        },
+        "method": "POST",
+        "uri": "https://oauth.reddit.com/api/comment/?raw_json=1"
+      },
+      "response": {
+        "body": {
+          "encoding": "UTF-8",
+          "string": "{\"json\": {\"errors\": [], \"data\": {\"things\": []}}}"
+        },
+        "headers": {
+          "Accept-Ranges": [
+            "bytes"
+          ],
+          "Connection": [
+            "keep-alive"
+          ],
+          "Content-Length": [
+            "48"
+          ],
+          "Content-Type": [
+            "application/json; charset=UTF-8"
+          ],
+          "Date": [
+            "Fri, 12 Jul 2019 15:13:02 GMT"
+          ],
+          "Server": [
+            "snooserv"
+          ],
+          "Strict-Transport-Security": [
+            "max-age=15552000; includeSubDomains; preload"
+          ],
+          "Via": [
+            "1.1 varnish"
+          ],
+          "X-Cache": [
+            "MISS"
+          ],
+          "X-Cache-Hits": [
+            "0"
+          ],
+          "X-Moose": [
+            "majestic"
+          ],
+          "X-Served-By": [
+            "cache-mel19024-MEL"
+          ],
+          "X-Timer": [
+            "S1562944382.056076,VS0,VE501"
+          ],
+          "cache-control": [
+            "private, s-maxage=0, max-age=0, must-revalidate, max-age=0, must-revalidate"
+          ],
+          "expires": [
+            "-1"
+          ],
+          "set-cookie": [
+            "loid=000000000004x25quk.2.1498230528268.Z0FBQUFBQmRLS04tWGVsQ3NUaUVwakxxbjJPZ3BiYXBfQmdETU0xZ2NsQ1ZnYkhNSEVsN2s4eXg2TWl5TXF4bXN3NkJuVWF4YVBnc1EzMnlmYjd1aGVrVnh3RVNCWkk5RGlpSUtTUEI2SmJOX2hkNk1OZFpQTXVWSmFiU010Zl8tXzVOZUVyVzBFZjg; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sun, 11-Jul-2021 15:13:02 GMT; secure",
+            "redesign_optout=true; Domain=reddit.com; Max-Age=94607999; Path=/; expires=Mon, 11-Jul-2022 15:13:02 GMT; secure",
+            "session_tracker=ui7RMAPOusngxR7qtZ.0.1562944382411.Z0FBQUFBQmRLS04teGlYYktsb3p5V0RfSnJfX2k2NmN6WWR0aUZQVkMwbFpLYjFJQTk3QWlaVDFBdThBMVI2ci1hSjd6SDROZGZOZ2k2ZEN5Umd3UjVObXhCYkJTcWpUTFB6NlBIUWpaaDNiTjdXNkpscXI2THNJNWU0bnd4a2FwM3NoN21OUmkwRzY; Domain=reddit.com; Max-Age=7199; Path=/; expires=Fri, 12-Jul-2019 17:13:02 GMT; secure"
+          ],
+          "x-content-type-options": [
+            "nosniff"
+          ],
+          "x-frame-options": [
+            "SAMEORIGIN"
+          ],
+          "x-ratelimit-remaining": [
+            "599.0"
+          ],
+          "x-ratelimit-reset": [
+            "418"
+          ],
+          "x-ratelimit-used": [
+            "1"
+          ],
+          "x-ua-compatible": [
+            "IE=edge"
+          ],
+          "x-xss-protection": [
+            "1; mode=block"
+          ]
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://oauth.reddit.com/api/comment/?raw_json=1"
+      }
+    }
+  ],
+  "recorded_with": "betamax/0.8.1"
+}
\ No newline at end of file
diff --git a/tests/integration/models/reddit/test_comment.py b/tests/integration/models/reddit/test_comment.py
--- a/tests/integration/models/reddit/test_comment.py
+++ b/tests/integration/models/reddit/test_comment.py
@@ -218,6 +218,13 @@ def test_reply(self):
             assert not comment.is_root
             assert comment.parent_id == parent_comment.fullname
 
+    def test_reply__none(self):
+        self.reddit.read_only = False
+        comment = Comment(self.reddit, "eear2ml")
+        with self.recorder.use_cassette("TestComment.test_reply__none"):
+            reply = comment.reply("TEST")
+        assert reply is None
+
     def test_report(self):
         self.reddit.read_only = False
         with self.recorder.use_cassette("TestComment.test_report"):
diff --git a/tests/integration/models/reddit/test_submission.py b/tests/integration/models/reddit/test_submission.py
--- a/tests/integration/models/reddit/test_submission.py
+++ b/tests/integration/models/reddit/test_submission.py
@@ -120,6 +120,13 @@ def test_reply(self):
             assert comment.body == "Test reply"
             assert comment.parent_id == submission.fullname
 
+    def test_reply__none(self):
+        self.reddit.read_only = False
+        submission = Submission(self.reddit, "ah19vv")
+        with self.recorder.use_cassette("TestSubmission.test_reply__none"):
+            reply = submission.reply("TEST")
+        assert reply is None
+
     def test_report(self):
         self.reddit.read_only = False
         with self.recorder.use_cassette("TestSubmission.test_report"):