Skip to content

Commit 5ed1805

Browse files
tobixentemsocial
andcommitted
feat: handle Retry-After header on 429/503 responses (RFC 6585 / RFC 9110)
Merges and fixes PR #628 by temsocial (#628). New `DAVClient` parameters: - `rate_limit_handle` (bool, default False): when True, automatically sleep and retry on rate-limited responses; when False raise `RateLimitError` immediately so callers can implement their own strategy. - `rate_limit_default_sleep` (Optional[int], default None): fallback sleep in seconds when the server's 429 omits a Retry-After header. None means raise rather than guess a sleep duration. - `rate_limit_max_sleep` (Optional[int], default None): cap on the sleep duration; None means respect the server's value without limit. New `error.RateLimitError(DAVError)` with `retry_after` (raw header string) and `retry_after_seconds` (parsed float) attributes. 11 unit tests added to `TestRateLimiting` in `tests/test_caldav_unit.py`. Co-authored-by: temsocial <temsocial@users.noreply.github.com> Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3e3bf08 commit 5ed1805

4 files changed

Lines changed: 229 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ Additionally, direct `DAVClient()` instantiation should migrate to `get_davclien
125125
* **URL space validation** -- `caldav.lib.url` now validates that URLs don't contain unquoted spaces
126126
* **Fallback for missing calendar-home-set** -- Client falls back to principal URL when `calendar-home-set` property is not available
127127
* **Load fallback for changed URLs** -- `CalendarObjectResource.load()` falls back to UID-based lookup when servers change URLs after save
128+
* **Retry-After / rate-limit handling** (RFC 6585 / RFC 9110) -- `DAVClient` now exposes `rate_limit_handle`, `rate_limit_default_sleep`, and `rate_limit_max_sleep` parameters. When `rate_limit_handle=True` the client automatically sleeps and retries on 429 Too Many Requests and 503 Service Unavailable responses that carry a `Retry-After` header. When `rate_limit_handle=False` (default) a `RateLimitError` is raised immediately so callers can implement their own back-off strategy. https://github.com/python-caldav/caldav/issues/627
128129

129130
### Fixed
130131

caldav/davclient.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
import copy
1212
import logging
1313
import sys
14+
import time
1415
import warnings
16+
from datetime import datetime, timezone
17+
from email.utils import parsedate_to_datetime
1518
from types import TracebackType
1619
from typing import TYPE_CHECKING, Any, Optional
1720
from urllib.parse import unquote
@@ -210,6 +213,9 @@ def __init__(
210213
features: FeatureSet | dict | str = None,
211214
enable_rfc6764: bool = True,
212215
require_tls: bool = True,
216+
rate_limit_handle: bool = False,
217+
rate_limit_default_sleep: Optional[int] = None,
218+
rate_limit_max_sleep: Optional[int] = None,
213219
) -> None:
214220
"""
215221
Sets up a HTTPConnection object towards the server in the url.
@@ -247,6 +253,16 @@ def __init__(
247253
redirect to unencrypted HTTP. Set to False ONLY if you need to
248254
support non-TLS servers and trust your DNS infrastructure.
249255
This parameter has no effect if enable_rfc6764=False.
256+
rate_limit_handle: boolean, whether to automatically sleep and retry when the server
257+
responds with 429 Too Many Requests or 503 Service Unavailable.
258+
Default: False (raise RateLimitError immediately).
259+
rate_limit_default_sleep: int or None, fallback sleep duration in seconds when the
260+
server's 429 response does not include a parseable Retry-After
261+
header. None (default) means raise RateLimitError rather than
262+
sleeping when no Retry-After is provided.
263+
rate_limit_max_sleep: int or None, maximum number of seconds to sleep when rate limited,
264+
regardless of the server's Retry-After value. None (default) means
265+
there is no cap and the server-requested delay is respected as-is.
250266
251267
The niquests library will honor a .netrc-file, if such a file exists
252268
username and password may be omitted.
@@ -344,6 +360,10 @@ def __init__(
344360

345361
self._principal = None
346362

363+
self.rate_limit_handle = rate_limit_handle
364+
self.rate_limit_default_sleep = rate_limit_default_sleep
365+
self.rate_limit_max_sleep = rate_limit_max_sleep
366+
347367
def __enter__(self) -> Self:
348368
## Used for tests, to set up a temporarily test server
349369
if hasattr(self, "setup"):
@@ -936,7 +956,24 @@ def request(
936956
Returns:
937957
DAVResponse
938958
"""
939-
return self._sync_request(url, method, body, headers)
959+
try:
960+
return self._sync_request(url, method, body, headers)
961+
except error.RateLimitError as e:
962+
if not self.rate_limit_handle:
963+
raise
964+
retry_after_seconds = (
965+
e.retry_after_seconds
966+
if e.retry_after_seconds is not None
967+
else self.rate_limit_default_sleep
968+
)
969+
if retry_after_seconds is None or retry_after_seconds <= 0:
970+
raise
971+
if self.rate_limit_max_sleep is not None:
972+
retry_after_seconds = min(retry_after_seconds, self.rate_limit_max_sleep)
973+
if retry_after_seconds <= 0:
974+
raise
975+
time.sleep(retry_after_seconds)
976+
return self._sync_request(url, method, body, headers)
940977

941978
def _sync_request(
942979
self,
@@ -979,8 +1016,31 @@ def _sync_request(
9791016
cert=self.ssl_cert,
9801017
)
9811018

982-
# Handle 401 responses for auth negotiation
9831019
r_headers = CaseInsensitiveDict(r.headers)
1020+
1021+
# Handle 429/503 responses: raise RateLimitError so the caller can decide whether to retry
1022+
if r.status_code in (429, 503):
1023+
retry_after_header: Optional[str] = r_headers.get("Retry-After")
1024+
retry_seconds: Optional[float] = None
1025+
if retry_after_header:
1026+
try:
1027+
retry_seconds = int(retry_after_header)
1028+
except ValueError:
1029+
try:
1030+
retry_date = parsedate_to_datetime(retry_after_header)
1031+
now = datetime.now(timezone.utc)
1032+
retry_seconds = max(0.0, (retry_date - now).total_seconds())
1033+
except (ValueError, TypeError):
1034+
pass
1035+
if r.status_code == 429 or retry_after_header is not None:
1036+
raise error.RateLimitError(
1037+
url=str(url_obj),
1038+
reason=f"Rate limited or service unavailable. Retry after: {retry_after_header}",
1039+
retry_after=retry_after_header,
1040+
retry_after_seconds=retry_seconds,
1041+
)
1042+
1043+
# Handle 401 responses for auth negotiation
9841044
if (
9851045
r.status_code == 401
9861046
and "WWW-Authenticate" in r_headers

caldav/lib/error.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ class ResponseError(DAVError):
133133
pass
134134

135135

136+
class RateLimitError(DAVError):
137+
"""Raised when the server responds with 429 Too Many Requests or
138+
503 Service Unavailable with a Retry-After header."""
139+
140+
def __init__(
141+
self,
142+
url: str | None = None,
143+
reason: str | None = None,
144+
retry_after: str | None = None,
145+
retry_after_seconds: float | None = None,
146+
) -> None:
147+
super().__init__(url=url, reason=reason)
148+
self.retry_after = retry_after
149+
self.retry_after_seconds = retry_after_seconds
150+
151+
136152
exception_by_method: dict[str, DAVError] = defaultdict(lambda: DAVError)
137153
for method in (
138154
"delete",

tests/test_caldav_unit.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
import pickle
10-
from datetime import date, datetime, timedelta
10+
from datetime import date, datetime, timedelta, timezone
1111
from unittest import mock
1212
from urllib.parse import urlparse
1313

@@ -1801,3 +1801,152 @@ def testGetObjectByUidExactMatch(self):
18011801
# Searching for a UID that exists only as a substring of another should fail
18021802
with pytest.raises(error.NotFoundError):
18031803
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

Comments
 (0)