<NME> test_client.py
<BEF> import unittest
from unittest.mock import MagicMock, patch
import arxiv
from datetime import datetime, timedelta
from pytest import approx
import feedparser


class TestClient(unittest.TestCase):
    def test_invalid_format_id(self):
        with self.assertRaises(arxiv.HTTPError):
            list(arxiv.Client(num_retries=0).results(arxiv.Search(id_list=["abc"])))

    def test_invalid_id(self):
        results = list(arxiv.Search(id_list=["0000.0000"]).results())
        self.assertEqual(len(results), 0)

    def test_nonexistent_id_in_list(self):
        # Assert _from_feed_entry throws MissingFieldError.
        feed = feedparser.parse("http://export.arxiv.org/api/query?id_list=0808.05394")
        with self.assertRaises(arxiv.Result.MissingFieldError):
            arxiv.Result._from_feed_entry(feed.entries[0])
        # Assert thrown error is handled and hidden by generator.
        results = list(arxiv.Search(id_list=["0808.05394"]).results())
        self.assertEqual(len(results), 0)
        # Generator should still yield valid entries.
        results = list(arxiv.Search(id_list=["0808.05394", "1707.08567"]).results())
        self.assertEqual(len(results), 1)

    def test_max_results(self):
            self.assertFalse(r.entry_id in ids)
            ids.add(r.entry_id)

    def test_retry(self):
        # broken_client always encounters a 500 status.
        broken_client = arxiv.Client(page_size=1, delay_seconds=0)
        broken_client.query_url_format = "https://httpstat.us/500?{}"

        def broken_get():
            search = arxiv.Search(query="quantum")
            return next(broken_client.results(search))

        self.assertRaises(arxiv.HTTPError, broken_get)
        for num_retries in [2, 5]:
            broken_client.num_retries = num_retries
            try:
                broken_get()
            except arxiv.HTTPError as e:
                self.assertEqual(e.retry, broken_client.num_retries - 1)

    @patch('time.sleep', return_value=None)
    def test_sleep_standard(self, patched_time_sleep):

        def broken_get():
            search = arxiv.Search(query="quantum")
            return next(broken_client.results(search))
        self.assertRaises(arxiv.HTTPError, broken_get)

        for num_retries in [2, 5]:
            broken_client.num_retries = num_retries
            try:
                broken_get()
                self.fail("broken_get didn't throw HTTPError")
            except arxiv.HTTPError as e:
                self.assertEqual(e.status, 500)
                self.assertEqual(e.retry, broken_client.num_retries)

    @patch('time.sleep', return_value=None)
    def test_sleep_standard(self, patched_time_sleep):
        client = arxiv.Client(page_size=1)
        url = client._format_url(arxiv.Search(query="quantum"), 0, 1)
        # A client should sleep until delay_seconds have passed.
        client._parse_feed(url)
        patched_time_sleep.assert_not_called()
        # Overwrite _last_request_dt to minimize flakiness: different
        # environments will have different page fetch times.
        client._last_request_dt = datetime.now()
        client._parse_feed(url)
        patched_time_sleep.assert_called_once_with(
            approx(client.delay_seconds, rel=1e-3)
        )

    @patch('time.sleep', return_value=None)
    def test_sleep_multiple_requests(self, patched_time_sleep):
        client = arxiv.Client(page_size=1)
        url1 = client._format_url(arxiv.Search(query="quantum"), 0, 1)
        url2 = client._format_url(arxiv.Search(query="testing"), 0, 1)
        # Rate limiting is URL-independent; expect same behavior as in
        # `test_sleep_standard`.
        client._parse_feed(url1)
        patched_time_sleep.assert_not_called()
        client._last_request_dt = datetime.now()
        client._parse_feed(url2)
        patched_time_sleep.assert_called_once_with(
            approx(client.delay_seconds, rel=1e-3)
        )

    @patch('time.sleep', return_value=None)
    def test_sleep_elapsed(self, patched_time_sleep):
        client = arxiv.Client(page_size=1)
        url = client._format_url(arxiv.Search(query="quantum"), 0, 1)
        # If _last_request_dt is less than delay_seconds ago, sleep.
        client._parse_feed(url)
        client._parse_feed(url)
        patched_time_sleep.assert_not_called()
        try:
            client._parse_feed(url)
        except arxiv.HTTPError:
            pass
        # Should sleep between retries.
        patched_time_sleep.assert_called()
        self.assertEqual(patched_time_sleep.call_count, client.num_retries)
        patched_time_sleep.assert_has_calls([
            call(approx(client.delay_seconds, rel=1e-3)),
        ] * client.num_retries)

    def get_broken_client():
        """
        get_broken_client returns an arxiv.Client that always encounters a 500
        status.
        """
        # TODO: reimplement broken_client with a mock.
        broken_client = arxiv.Client(page_size=1)
        broken_client.query_url_format = "https://httpstat.us/500?{}"
        return broken_client

    def get_once_client():
        """
        get_once_client returns an arxiv.Client that only tries once.
        """
        return arxiv.Client(num_retries=0)

<MSG> Run retries recursively to sleep between erroneous responses (#53)

* Factor recursive helper `__try_parse_feed` out of `_parse_feed`

Recursive helper enforces delays between API requests, which means
sleeping after requests that get erroneous responses.

* Test sleeping between erroneous responses
<DFF> @@ -1,5 +1,5 @@
 import unittest
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, call, patch
 import arxiv
 from datetime import datetime, timedelta
 from pytest import approx
@@ -31,22 +31,23 @@ class TestClient(unittest.TestCase):
             self.assertFalse(r.entry_id in ids)
             ids.add(r.entry_id)
 
-    def test_retry(self):
-        # broken_client always encounters a 500 status.
-        broken_client = arxiv.Client(page_size=1, delay_seconds=0)
-        broken_client.query_url_format = "https://httpstat.us/500?{}"
+    @patch('time.sleep', return_value=None)
+    def test_retry(self, patched_time_sleep):
+        broken_client = get_broken_client()
 
         def broken_get():
             search = arxiv.Search(query="quantum")
             return next(broken_client.results(search))
-
         self.assertRaises(arxiv.HTTPError, broken_get)
+
         for num_retries in [2, 5]:
             broken_client.num_retries = num_retries
             try:
                 broken_get()
+                self.fail("broken_get didn't throw HTTPError")
             except arxiv.HTTPError as e:
-                self.assertEqual(e.retry, broken_client.num_retries - 1)
+                self.assertEqual(e.status, 500)
+                self.assertEqual(e.retry, broken_client.num_retries)
 
     @patch('time.sleep', return_value=None)
     def test_sleep_standard(self, patched_time_sleep):
@@ -103,3 +104,25 @@ class TestClient(unittest.TestCase):
         client._parse_feed(url)
         client._parse_feed(url)
         patched_time_sleep.assert_not_called()
+
+    @patch('time.sleep', return_value=None)
+    def test_sleep_between_errors(self, patched_time_sleep):
+        client = get_broken_client()
+        url = client._format_url(arxiv.Search(query="quantum"), 0, 1)
+        try:
+            client._parse_feed(url)
+        except arxiv.HTTPError:
+            pass
+        # Should sleep between retries.
+        patched_time_sleep.assert_called()
+        self.assertEqual(patched_time_sleep.call_count, client.num_retries)
+        patched_time_sleep.assert_has_calls([
+            call(approx(client.delay_seconds, rel=1e-3)),
+        ] * client.num_retries)
+
+
+def get_broken_client():
+    # broken_client always encounters a 500 status.
+    broken_client = arxiv.Client(page_size=1)
+    broken_client.query_url_format = "https://httpstat.us/500?{}"
+    return broken_client

Run retries recursively to sleep between erroneous responses (#53)