Skip to content

Commit 572d07b

Browse files
tobixenclaude
andcommitted
refactor: extract rate-limit helpers; add async rate-limit support
Move Retry-After parsing and sleep-decision logic into three shared module-level helpers in caldav/lib/error.py, adjacent to RateLimitError: parse_retry_after(header) -> float | None compute_sleep_seconds(s, default, max) -> float | None raise_if_rate_limited(status, url, header) -> None (raises on 429/503) Both sync (DAVClient) and async (AsyncDAVClient) clients now call these helpers instead of duplicating the logic inline. Changes per file: - caldav/lib/error.py: add helpers + necessary imports (datetime, timezone, parsedate_to_datetime) - caldav/davclient.py: replace 15-line inline parse block with single raise_if_rate_limited() call; simplify request() except handler via compute_sleep_seconds(); remove now-unused datetime/timezone/ parsedate_to_datetime imports - caldav/async_davclient.py: add rate_limit_handle/default_sleep/max_sleep params to __init__; split monolithic request() into request() (thin rate-limit wrapper using asyncio.sleep) + _async_request() (HTTP call + auth negotiation); insert raise_if_rate_limited() call in _async_request - tests/test_caldav_unit.py: add TestRateLimitHelpers (17 tests covering all three helpers directly) - tests/test_async_davclient.py: add TestAsyncRateLimiting (9 tests mirroring the sync TestRateLimiting class, using AsyncMock + patched asyncio.sleep) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5ed1805 commit 572d07b

5 files changed

Lines changed: 334 additions & 60 deletions

File tree

caldav/async_davclient.py

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
For sync usage, see the davclient.py wrapper.
77
"""
88

9+
import asyncio
910
import logging
1011
import sys
1112
from collections.abc import Mapping
@@ -143,6 +144,9 @@ def __init__(
143144
features: FeatureSet | dict | str | None = None,
144145
enable_rfc6764: bool = True,
145146
require_tls: bool = True,
147+
rate_limit_handle: bool = False,
148+
rate_limit_default_sleep: Optional[int] = None,
149+
rate_limit_max_sleep: Optional[int] = None,
146150
) -> None:
147151
"""
148152
Initialize an async DAV client.
@@ -162,6 +166,13 @@ def __init__(
162166
features: FeatureSet for server compatibility workarounds.
163167
enable_rfc6764: Enable RFC6764 DNS-based service discovery.
164168
require_tls: Require TLS for discovered services (security consideration).
169+
rate_limit_handle: When True, automatically sleep and retry on 429/503
170+
responses. When False (default), raise RateLimitError immediately.
171+
rate_limit_default_sleep: Fallback sleep seconds when the server's 429
172+
response omits a Retry-After header. None (default) means raise
173+
rather than sleeping when no Retry-After is provided.
174+
rate_limit_max_sleep: Cap on sleep duration in seconds regardless of
175+
server's Retry-After value. None (default) means no cap.
165176
"""
166177
headers = headers or {}
167178

@@ -247,6 +258,10 @@ def __init__(
247258
}
248259
self.headers.update(headers)
249260

261+
self.rate_limit_handle = rate_limit_handle
262+
self.rate_limit_default_sleep = rate_limit_default_sleep
263+
self.rate_limit_max_sleep = rate_limit_max_sleep
264+
250265
def _create_session(self) -> None:
251266
"""Create or recreate the async HTTP client with current settings."""
252267
if _USE_HTTPX:
@@ -326,16 +341,39 @@ async def request(
326341
headers: Mapping[str, str] | None = None,
327342
) -> AsyncDAVResponse:
328343
"""
329-
Send an async HTTP request.
344+
Send an async HTTP request, with optional rate-limit sleep-and-retry.
330345
331-
Args:
332-
url: Request URL.
333-
method: HTTP method.
334-
body: Request body.
335-
headers: Additional headers.
346+
Catches RateLimitError from _async_request. When rate_limit_handle is
347+
True and a usable sleep duration is available, sleeps then retries once.
348+
Otherwise re-raises immediately.
349+
"""
350+
try:
351+
return await self._async_request(url, method, body, headers)
352+
except error.RateLimitError as e:
353+
if not self.rate_limit_handle:
354+
raise
355+
sleep_seconds = error.compute_sleep_seconds(
356+
e.retry_after_seconds,
357+
self.rate_limit_default_sleep,
358+
self.rate_limit_max_sleep,
359+
)
360+
if sleep_seconds is None:
361+
raise
362+
await asyncio.sleep(sleep_seconds)
363+
return await self._async_request(url, method, body, headers)
336364

337-
Returns:
338-
AsyncDAVResponse object.
365+
async def _async_request(
366+
self,
367+
url: str,
368+
method: str = "GET",
369+
body: str = "",
370+
headers: Mapping[str, str] | None = None,
371+
) -> AsyncDAVResponse:
372+
"""
373+
Async HTTP request implementation with auth negotiation.
374+
375+
Handles connection-abort workaround, 429/503 rate-limit detection,
376+
and 401 auth negotiation (including HTTP/2 fallback).
339377
"""
340378
headers = headers or {}
341379

@@ -438,8 +476,10 @@ async def request(
438476
r = await self.session.request(**request_kwargs)
439477
response = AsyncDAVResponse(r, self)
440478

441-
# Handle 401 responses for auth negotiation (after try/except)
442-
# This matches the original sync client's auth negotiation logic
479+
# Handle 429/503 rate-limit responses
480+
error.raise_if_rate_limited(r.status_code, str(url_obj), r.headers.get("Retry-After"))
481+
482+
# Handle 401 responses for auth negotiation
443483
# httpx headers are already case-insensitive
444484
if (
445485
r.status_code == 401
@@ -458,7 +498,7 @@ async def request(
458498
)
459499

460500
# Retry request with authentication
461-
return await self.request(url, method, body, headers)
501+
return await self._async_request(url, method, body, headers)
462502

463503
elif (
464504
r.status_code == 401
@@ -467,36 +507,28 @@ async def request(
467507
and self.password
468508
and isinstance(self.password, bytes)
469509
):
470-
# Handle HTTP/2 issue (matches original sync client)
471-
# Most likely wrong username/password combo, but could be an HTTP/2 problem
510+
# Handle HTTP/2 issue: most likely wrong username/password, but could be an
511+
# HTTP/2 problem. Retry with HTTP/2 disabled if multiplexing was auto-detected.
472512
if self.features.is_supported("http.multiplexing", return_defaults=False) is None:
473-
await self.close() # Uses correct close method for httpx/niquests
513+
await self.close()
474514
self._http2 = False
475515
self._create_session()
476516
# Set multiplexing to False BEFORE retry to prevent infinite loop
477-
# If the retry succeeds, this was the right choice
478-
# If it also fails with 401, it's not a multiplexing issue but an auth issue
479517
self.features.set_feature("http.multiplexing", False)
480-
# If this one also fails, we give up
481-
ret = await self.request(str(url_obj), method, body, headers)
482-
return ret
483-
484-
# Most likely we're here due to wrong username/password combo,
485-
# but it could also be charset problems. Some (ancient) servers
486-
# don't like UTF-8 binary auth with Digest authentication.
487-
# An example are old SabreDAV based servers. Not sure about UTF-8
488-
# and Basic Auth, but likely the same. So retry if password is
489-
# a bytes sequence and not a string.
518+
return await self._async_request(str(url_obj), method, body, headers)
519+
520+
# Could also be charset problems with old SabreDAV-based servers that don't like
521+
# UTF-8 binary auth with Digest/Basic authentication.
490522
auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
491523
self.password = self.password.decode()
492524
self.build_auth_object(auth_types)
493525

494526
self.username = None
495527
self.password = None
496528

497-
return await self.request(str(url_obj), method, body, headers)
529+
return await self._async_request(str(url_obj), method, body, headers)
498530

499-
# Raise AuthorizationError for 401/403 responses (matches original sync client)
531+
# Raise AuthorizationError for 401/403 responses
500532
if response.status in (401, 403):
501533
try:
502534
reason = response.reason

caldav/davclient.py

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
import sys
1414
import time
1515
import warnings
16-
from datetime import datetime, timezone
17-
from email.utils import parsedate_to_datetime
1816
from types import TracebackType
1917
from typing import TYPE_CHECKING, Any, Optional
2018
from urllib.parse import unquote
@@ -961,18 +959,14 @@ def request(
961959
except error.RateLimitError as e:
962960
if not self.rate_limit_handle:
963961
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
962+
sleep_seconds = error.compute_sleep_seconds(
963+
e.retry_after_seconds,
964+
self.rate_limit_default_sleep,
965+
self.rate_limit_max_sleep,
968966
)
969-
if retry_after_seconds is None or retry_after_seconds <= 0:
967+
if sleep_seconds is None:
970968
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)
969+
time.sleep(sleep_seconds)
976970
return self._sync_request(url, method, body, headers)
977971

978972
def _sync_request(
@@ -1019,26 +1013,7 @@ def _sync_request(
10191013
r_headers = CaseInsensitiveDict(r.headers)
10201014

10211015
# 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-
)
1016+
error.raise_if_rate_limited(r.status_code, str(url_obj), r_headers.get("Retry-After"))
10421017

10431018
# Handle 401 responses for auth negotiation
10441019
if (

caldav/lib/error.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env python
22
import logging
33
from collections import defaultdict
4+
from datetime import datetime, timezone
5+
from email.utils import parsedate_to_datetime
6+
from typing import Optional
47

58
from caldav import __version__
69

@@ -149,6 +152,85 @@ def __init__(
149152
self.retry_after_seconds = retry_after_seconds
150153

151154

155+
def parse_retry_after(retry_after_header: str) -> Optional[float]:
156+
"""Parse a Retry-After header value into seconds from now.
157+
158+
Handles both the integer-seconds form (RFC 7231 §7.1.3) and the HTTP-date
159+
form. Returns None if the value cannot be parsed.
160+
"""
161+
try:
162+
return float(int(retry_after_header))
163+
except ValueError:
164+
pass
165+
try:
166+
retry_date = parsedate_to_datetime(retry_after_header)
167+
now = datetime.now(timezone.utc)
168+
return max(0.0, (retry_date - now).total_seconds())
169+
except (ValueError, TypeError):
170+
return None
171+
172+
173+
def compute_sleep_seconds(
174+
retry_after_seconds: Optional[float],
175+
default_sleep: Optional[int],
176+
max_sleep: Optional[int],
177+
) -> Optional[float]:
178+
"""Compute the effective sleep duration for rate-limit handling.
179+
180+
Returns None when there is no usable duration (meaning the caller should
181+
re-raise the RateLimitError rather than sleeping).
182+
183+
Args:
184+
retry_after_seconds: Parsed seconds from server Retry-After header,
185+
or None if the server did not provide one.
186+
default_sleep: Fallback duration when retry_after_seconds is None.
187+
None means no fallback; re-raise.
188+
max_sleep: Hard cap on sleep duration. None means no cap; 0 means
189+
never sleep.
190+
"""
191+
effective: Optional[float] = (
192+
retry_after_seconds
193+
if retry_after_seconds is not None
194+
else (float(default_sleep) if default_sleep is not None else None)
195+
)
196+
if effective is None or effective <= 0:
197+
return None
198+
if max_sleep is not None:
199+
effective = min(effective, float(max_sleep))
200+
if effective <= 0:
201+
return None
202+
return effective
203+
204+
205+
def raise_if_rate_limited(
206+
status_code: int,
207+
url: str,
208+
retry_after_header: Optional[str],
209+
) -> None:
210+
"""Raise RateLimitError when the response indicates rate limiting.
211+
212+
Raises for:
213+
- Any 429 response (regardless of Retry-After presence)
214+
- 503 responses that include a Retry-After header
215+
216+
Args:
217+
status_code: HTTP status code from the response.
218+
url: Request URL (included in exception for context).
219+
retry_after_header: Raw Retry-After header value, or None.
220+
"""
221+
if status_code not in (429, 503):
222+
return
223+
if status_code != 429 and retry_after_header is None:
224+
return
225+
retry_seconds = parse_retry_after(retry_after_header) if retry_after_header else None
226+
raise RateLimitError(
227+
url=url,
228+
reason=f"Rate limited or service unavailable. Retry after: {retry_after_header}",
229+
retry_after=retry_after_header,
230+
retry_after_seconds=retry_seconds,
231+
)
232+
233+
152234
exception_by_method: dict[str, DAVError] = defaultdict(lambda: DAVError)
153235
for method in (
154236
"delete",

0 commit comments

Comments
 (0)