Skip to content

Commit 722e959

Browse files
tobixentemsocial
andcommitted
Handle Retry-After header on 429/503 responses
Relevant standards: RFC 6585 / RFC 9110 Merges and fixes https://github.com/python-tcaldav/caldav/pull/628 by temsocial 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 32620d6 commit 722e959

4 files changed

Lines changed: 343 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ Additionally, direct `DAVClient()` instantiation should migrate to `get_davclien
9898
searcher.add_filter(...)
9999
results = searcher.search()
100100
```
101+
* **`get_calendars()` and `get_calendar()` context managers** -- Module-level factory functions that create a client, fetch calendars, and clean up on exit:
102+
```python
103+
with get_calendars(url="...", username="...", password="...") as calendars:
104+
for cal in calendars:
105+
...
106+
```
107+
* **Base+override feature profiles** -- YAML config now supports inheriting from base feature profiles:
108+
```yaml
109+
my-server:
110+
features:
111+
base: nextcloud
112+
search.comp-type: unsupported
113+
```
114+
* **Feature validation** -- `caldav.config` now validates feature configuration and raises errors on unknown feature names
115+
* **URL space validation** -- `caldav.lib.url` now validates that URLs don't contain unquoted spaces
116+
* **Fallback for missing calendar-home-set** -- Client falls back to principal URL when `calendar-home-set` property is not available
117+
* **Load fallback for changed URLs** -- `CalendarObjectResource.load()` falls back to UID-based lookup when servers change URLs after save
118+
* **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
101119

102120
### Fixed
103121

caldav/davclient.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
import logging
1212
import sys
13+
import time
1314
import warnings
15+
from datetime import datetime, timezone
16+
from email.utils import parsedate_to_datetime
1417
from types import TracebackType
1518
from typing import TYPE_CHECKING, Any, Optional
1619
from urllib.parse import unquote
@@ -206,6 +209,9 @@ def __init__(
206209
features: FeatureSet | dict | str = None,
207210
enable_rfc6764: bool = True,
208211
require_tls: bool = True,
212+
rate_limit_handle: bool = False,
213+
rate_limit_default_sleep: Optional[int] = None,
214+
rate_limit_max_sleep: Optional[int] = None,
209215
) -> None:
210216
"""
211217
Sets up a HTTPConnection object towards the server in the url.
@@ -243,6 +249,16 @@ def __init__(
243249
redirect to unencrypted HTTP. Set to False ONLY if you need to
244250
support non-TLS servers and trust your DNS infrastructure.
245251
This parameter has no effect if enable_rfc6764=False.
252+
rate_limit_handle: boolean, whether to automatically sleep and retry when the server
253+
responds with 429 Too Many Requests or 503 Service Unavailable.
254+
Default: False (raise RateLimitError immediately).
255+
rate_limit_default_sleep: int or None, fallback sleep duration in seconds when the
256+
server's 429 response does not include a parseable Retry-After
257+
header. None (default) means raise RateLimitError rather than
258+
sleeping when no Retry-After is provided.
259+
rate_limit_max_sleep: int or None, maximum number of seconds to sleep when rate limited,
260+
regardless of the server's Retry-After value. None (default) means
261+
there is no cap and the server-requested delay is respected as-is.
246262
247263
The niquests library will honor a .netrc-file, if such a file exists
248264
username and password may be omitted.
@@ -341,6 +357,10 @@ def __init__(
341357

342358
self._principal = None
343359

360+
self.rate_limit_handle = rate_limit_handle
361+
self.rate_limit_default_sleep = rate_limit_default_sleep
362+
self.rate_limit_max_sleep = rate_limit_max_sleep
363+
344364
def __enter__(self) -> Self:
345365
## Used for tests, to set up a temporarily test server
346366
if hasattr(self, "setup"):
@@ -931,7 +951,24 @@ def request(
931951
Returns:
932952
DAVResponse
933953
"""
934-
return self._sync_request(url, method, body, headers)
954+
try:
955+
return self._sync_request(url, method, body, headers)
956+
except error.RateLimitError as e:
957+
if not self.rate_limit_handle:
958+
raise
959+
retry_after_seconds = (
960+
e.retry_after_seconds
961+
if e.retry_after_seconds is not None
962+
else self.rate_limit_default_sleep
963+
)
964+
if retry_after_seconds is None or retry_after_seconds <= 0:
965+
raise
966+
if self.rate_limit_max_sleep is not None:
967+
retry_after_seconds = min(retry_after_seconds, self.rate_limit_max_sleep)
968+
if retry_after_seconds <= 0:
969+
raise
970+
time.sleep(retry_after_seconds)
971+
return self._sync_request(url, method, body, headers)
935972

936973
def _sync_request(
937974
self,
@@ -974,8 +1011,31 @@ def _sync_request(
9741011
cert=self.ssl_cert,
9751012
)
9761013

977-
# Handle 401 responses for auth negotiation
9781014
r_headers = CaseInsensitiveDict(r.headers)
1015+
1016+
# Handle 429/503 responses: raise RateLimitError so the caller can decide whether to retry
1017+
if r.status_code in (429, 503):
1018+
retry_after_header: Optional[str] = r_headers.get("Retry-After")
1019+
retry_seconds: Optional[float] = None
1020+
if retry_after_header:
1021+
try:
1022+
retry_seconds = int(retry_after_header)
1023+
except ValueError:
1024+
try:
1025+
retry_date = parsedate_to_datetime(retry_after_header)
1026+
now = datetime.now(timezone.utc)
1027+
retry_seconds = max(0.0, (retry_date - now).total_seconds())
1028+
except (ValueError, TypeError):
1029+
pass
1030+
if r.status_code == 429 or retry_after_header is not None:
1031+
raise error.RateLimitError(
1032+
url=str(url_obj),
1033+
reason=f"Rate limited or service unavailable. Retry after: {retry_after_header}",
1034+
retry_after=retry_after_header,
1035+
retry_after_seconds=retry_seconds,
1036+
)
1037+
1038+
# Handle 401 responses for auth negotiation
9791039
if (
9801040
r.status_code == 401
9811041
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",

0 commit comments

Comments
 (0)