|
7 | 7 | """ |
8 | 8 |
|
9 | 9 | import pickle |
10 | | -from datetime import date, datetime, timedelta |
| 10 | +from datetime import date, datetime, timedelta, timezone |
11 | 11 | from unittest import mock |
12 | 12 | from urllib.parse import urlparse |
13 | 13 |
|
@@ -1801,3 +1801,152 @@ def testGetObjectByUidExactMatch(self): |
1801 | 1801 | # Searching for a UID that exists only as a substring of another should fail |
1802 | 1802 | with pytest.raises(error.NotFoundError): |
1803 | 1803 | calendar.get_object_by_uid("20010712T182145Z-123401@example.com-nope") |
| 1804 | + |
| 1805 | + |
| 1806 | +class TestRateLimiting: |
| 1807 | + """ |
| 1808 | + Unit tests for 429/503 rate-limit handling (issue #627). |
| 1809 | + No real server communication - uses mock.patch on the session. |
| 1810 | + """ |
| 1811 | + |
| 1812 | + def _make_response(self, status_code, headers=None): |
| 1813 | + """Build a minimal mock HTTP response.""" |
| 1814 | + r = mock.MagicMock() |
| 1815 | + r.status_code = status_code |
| 1816 | + r.headers = headers or {} |
| 1817 | + r.reason = "Too Many Requests" if status_code == 429 else "Service Unavailable" |
| 1818 | + return r |
| 1819 | + |
| 1820 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1821 | + def test_429_no_retry_after_raises(self, mocked): |
| 1822 | + """429 without Retry-After header always raises RateLimitError with retry_after_seconds=None.""" |
| 1823 | + mocked.return_value = self._make_response(429) |
| 1824 | + client = DAVClient(url="http://cal.example.com/") |
| 1825 | + with pytest.raises(error.RateLimitError) as exc_info: |
| 1826 | + client.request("/") |
| 1827 | + assert exc_info.value.retry_after is None |
| 1828 | + assert exc_info.value.retry_after_seconds is None |
| 1829 | + |
| 1830 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1831 | + def test_429_with_integer_retry_after(self, mocked): |
| 1832 | + """429 with integer Retry-After header parses the seconds correctly.""" |
| 1833 | + mocked.return_value = self._make_response(429, {"Retry-After": "30"}) |
| 1834 | + client = DAVClient(url="http://cal.example.com/") |
| 1835 | + with pytest.raises(error.RateLimitError) as exc_info: |
| 1836 | + client.request("/") |
| 1837 | + assert exc_info.value.retry_after == "30" |
| 1838 | + assert exc_info.value.retry_after_seconds == 30 |
| 1839 | + |
| 1840 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1841 | + def test_429_with_http_date_retry_after(self, mocked): |
| 1842 | + """429 with HTTP-date Retry-After header computes seconds from now.""" |
| 1843 | + from email.utils import format_datetime |
| 1844 | + |
| 1845 | + future = datetime.now(timezone.utc) + timedelta(seconds=60) |
| 1846 | + retry_after_str = format_datetime(future) |
| 1847 | + mocked.return_value = self._make_response(429, {"Retry-After": retry_after_str}) |
| 1848 | + client = DAVClient(url="http://cal.example.com/") |
| 1849 | + with pytest.raises(error.RateLimitError) as exc_info: |
| 1850 | + client.request("/") |
| 1851 | + assert exc_info.value.retry_after == retry_after_str |
| 1852 | + # Should be close to 60s (allow a few seconds tolerance) |
| 1853 | + assert exc_info.value.retry_after_seconds is not None |
| 1854 | + assert 55 <= exc_info.value.retry_after_seconds <= 65 |
| 1855 | + |
| 1856 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1857 | + def test_429_with_unparseable_retry_after(self, mocked): |
| 1858 | + """429 with a garbled Retry-After header still raises; retry_after_seconds is None.""" |
| 1859 | + mocked.return_value = self._make_response(429, {"Retry-After": "banana"}) |
| 1860 | + client = DAVClient(url="http://cal.example.com/") |
| 1861 | + with pytest.raises(error.RateLimitError) as exc_info: |
| 1862 | + client.request("/") |
| 1863 | + assert exc_info.value.retry_after == "banana" |
| 1864 | + assert exc_info.value.retry_after_seconds is None |
| 1865 | + |
| 1866 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1867 | + def test_503_without_retry_after_does_not_raise_rate_limit(self, mocked): |
| 1868 | + """503 without Retry-After falls through as a normal (non-rate-limit) response.""" |
| 1869 | + mocked.return_value = self._make_response(503) |
| 1870 | + client = DAVClient(url="http://cal.example.com/") |
| 1871 | + # Should NOT raise RateLimitError; returns a DAVResponse with status 503 |
| 1872 | + response = client.request("/") |
| 1873 | + assert response.status == 503 |
| 1874 | + |
| 1875 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1876 | + def test_503_with_retry_after_raises(self, mocked): |
| 1877 | + """503 with Retry-After header raises RateLimitError.""" |
| 1878 | + mocked.return_value = self._make_response(503, {"Retry-After": "10"}) |
| 1879 | + client = DAVClient(url="http://cal.example.com/") |
| 1880 | + with pytest.raises(error.RateLimitError) as exc_info: |
| 1881 | + client.request("/") |
| 1882 | + assert exc_info.value.retry_after_seconds == 10 |
| 1883 | + |
| 1884 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1885 | + def test_rate_limit_handle_sleeps_and_retries(self, mocked): |
| 1886 | + """With rate_limit_handle=True the client sleeps then retries, returning the second response.""" |
| 1887 | + ok_response = mock.MagicMock() |
| 1888 | + ok_response.status_code = 200 |
| 1889 | + ok_response.headers = {} |
| 1890 | + mocked.side_effect = [ |
| 1891 | + self._make_response(429, {"Retry-After": "5"}), |
| 1892 | + ok_response, |
| 1893 | + ] |
| 1894 | + client = DAVClient(url="http://cal.example.com/", rate_limit_handle=True) |
| 1895 | + with mock.patch("caldav.davclient.time.sleep") as mock_sleep: |
| 1896 | + response = client.request("/") |
| 1897 | + mock_sleep.assert_called_once_with(5) |
| 1898 | + assert response.status == 200 |
| 1899 | + assert mocked.call_count == 2 |
| 1900 | + |
| 1901 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1902 | + def test_rate_limit_handle_default_sleep_used_when_no_retry_after(self, mocked): |
| 1903 | + """With rate_limit_default_sleep set, that value is used when server omits Retry-After.""" |
| 1904 | + ok_response = mock.MagicMock() |
| 1905 | + ok_response.status_code = 200 |
| 1906 | + ok_response.headers = {} |
| 1907 | + mocked.side_effect = [ |
| 1908 | + self._make_response(429), |
| 1909 | + ok_response, |
| 1910 | + ] |
| 1911 | + client = DAVClient( |
| 1912 | + url="http://cal.example.com/", rate_limit_handle=True, rate_limit_default_sleep=3 |
| 1913 | + ) |
| 1914 | + with mock.patch("caldav.davclient.time.sleep") as mock_sleep: |
| 1915 | + response = client.request("/") |
| 1916 | + mock_sleep.assert_called_once_with(3) |
| 1917 | + assert response.status == 200 |
| 1918 | + |
| 1919 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1920 | + def test_rate_limit_handle_no_sleep_info_raises(self, mocked): |
| 1921 | + """rate_limit_handle=True but no Retry-After and no default sleep re-raises RateLimitError.""" |
| 1922 | + mocked.return_value = self._make_response(429) |
| 1923 | + client = DAVClient(url="http://cal.example.com/", rate_limit_handle=True) |
| 1924 | + with pytest.raises(error.RateLimitError): |
| 1925 | + client.request("/") |
| 1926 | + |
| 1927 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1928 | + def test_rate_limit_max_sleep_caps_sleep_time(self, mocked): |
| 1929 | + """rate_limit_max_sleep caps the sleep even when server requests longer.""" |
| 1930 | + ok_response = mock.MagicMock() |
| 1931 | + ok_response.status_code = 200 |
| 1932 | + ok_response.headers = {} |
| 1933 | + mocked.side_effect = [ |
| 1934 | + self._make_response(429, {"Retry-After": "3600"}), |
| 1935 | + ok_response, |
| 1936 | + ] |
| 1937 | + client = DAVClient( |
| 1938 | + url="http://cal.example.com/", rate_limit_handle=True, rate_limit_max_sleep=60 |
| 1939 | + ) |
| 1940 | + with mock.patch("caldav.davclient.time.sleep") as mock_sleep: |
| 1941 | + client.request("/") |
| 1942 | + mock_sleep.assert_called_once_with(60) |
| 1943 | + |
| 1944 | + @mock.patch("caldav.davclient.requests.Session.request") |
| 1945 | + def test_rate_limit_max_sleep_zero_raises(self, mocked): |
| 1946 | + """rate_limit_max_sleep=0 means never sleep, always raise.""" |
| 1947 | + mocked.return_value = self._make_response(429, {"Retry-After": "30"}) |
| 1948 | + client = DAVClient( |
| 1949 | + url="http://cal.example.com/", rate_limit_handle=True, rate_limit_max_sleep=0 |
| 1950 | + ) |
| 1951 | + with pytest.raises(error.RateLimitError): |
| 1952 | + client.request("/") |
0 commit comments