<NME> test_client.py
<BEF> import unittest
from unittest.mock import MagicMock
import arxiv


class TestClient(unittest.TestCase):
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):
        client = arxiv.Client(page_size=10, delay_seconds=0)
            ids.add(r.entry_id)

    def test_retry(self):
        num_retries = 2
        broken_client = arxiv.Client(num_retries=num_retries)
        # Always returns a 500 status.
        broken_client.query_url_format = "https://httpstat.us/500?{}"

        def broken_get():
        results = [r for r in generator]
        self.assertEqual(len(results), 55)
        self.assertEqual(client._parse_feed.call_count, 6)

    def test_no_duplicates(self):
        search = arxiv.Search("testing", max_results=100)
        ids = set()
                broken_get()
            except arxiv.HTTPError as e:
                self.assertEqual(e.retry, broken_client.num_retries - 1)
        )
        client._parse_feed(url)
        patched_time_sleep.assert_called_once()
        patched_time_sleep.reset_mock()
        # If _last_request_dt is at least delay_seconds ago, don't sleep.
        client._last_request_dt = (
            datetime.now() - timedelta(seconds=client.delay_seconds)
        )
        client._parse_feed(url)
        patched_time_sleep.assert_not_called()

    @patch('time.sleep', return_value=None)
    def test_sleep_zero_delay(self, patched_time_sleep):
        client = arxiv.Client(page_size=1, delay_seconds=0)
        url = client._format_url(arxiv.Search(query="quantum"), 0, 1)
        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 = TestClient.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():
        """
        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> Only sleep before requests if necessary

If `self.delay_seconds` have already passed (e.g. during processing of
the last page of results), proceed to `_parse_feed` without sleeping.

Moves the sleep into `_parse_feed`. Adds tests.

<DFF> @@ -1,6 +1,8 @@
 import unittest
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
 import arxiv
+from datetime import datetime, timedelta
+from pytest import approx
 
 
 class TestClient(unittest.TestCase):
@@ -30,9 +32,8 @@ class TestClient(unittest.TestCase):
             ids.add(r.entry_id)
 
     def test_retry(self):
-        num_retries = 2
-        broken_client = arxiv.Client(num_retries=num_retries)
-        # Always returns a 500 status.
+        # 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():
@@ -46,3 +47,59 @@ class TestClient(unittest.TestCase):
                 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):
+        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._last_request_dt = (
+            datetime.now() - timedelta(seconds=client.delay_seconds-1)
+        )
+        client._parse_feed(url)
+        patched_time_sleep.assert_called_once()
+        patched_time_sleep.reset_mock()
+        # If _last_request_dt is at least delay_seconds ago, don't sleep.
+        client._last_request_dt = (
+            datetime.now() - timedelta(seconds=client.delay_seconds)
+        )
+        client._parse_feed(url)
+        patched_time_sleep.assert_not_called()
+
+    @patch('time.sleep', return_value=None)
+    def test_sleep_zero_delay(self, patched_time_sleep):
+        client = arxiv.Client(page_size=1, delay_seconds=0)
+        url = client._format_url(arxiv.Search(query="quantum"), 0, 1)
+        client._parse_feed(url)
+        client._parse_feed(url)
+        patched_time_sleep.assert_not_called()

Only sleep before requests if necessary