Skip to content

Commit d0f1cfa

Browse files
tobixenclaude
andcommitted
Extend rate-limit handling: async support and consolidated request logic
On top of the 429/503 Retry-After support already in master: - Extract rate-limit helpers into shared utilities reused by sync and async clients - Add async rate-limit support to AsyncDAVClient (parallels sync behaviour) - Consolidate duplicated request-handling logic into BaseDAVClient to reduce code duplication between the sync and async paths - Remove dead bytes-password code from async client; fix HTTP/2 fallback Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 90102e3 commit d0f1cfa

4 files changed

Lines changed: 387 additions & 179 deletions

File tree

caldav/async_davclient.py

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

9+
import asyncio
10+
import logging
911
import sys
1012
from collections.abc import Mapping
1113
from types import TracebackType
@@ -57,9 +59,8 @@
5759
from caldav.base_client import get_davclient as _base_get_davclient
5860
from caldav.compatibility_hints import FeatureSet
5961
from caldav.lib import error
60-
from caldav.lib.python_utilities import to_normal_str, to_wire
62+
from caldav.lib.python_utilities import to_wire
6163
from caldav.lib.url import URL
62-
from caldav.objects import log
6364
from caldav.protocol.types import (
6465
CalendarQueryResult,
6566
PropfindResult,
@@ -78,6 +79,8 @@
7879
from caldav.requests import HTTPBearerAuth
7980
from caldav.response import BaseDAVResponse
8081

82+
log = logging.getLogger("caldav")
83+
8184
if sys.version_info < (3, 11):
8285
from typing_extensions import Self
8386
else:
@@ -141,6 +144,9 @@ def __init__(
141144
features: FeatureSet | dict | str | None = None,
142145
enable_rfc6764: bool = True,
143146
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,
144150
) -> None:
145151
"""
146152
Initialize an async DAV client.
@@ -160,13 +166,19 @@ def __init__(
160166
features: FeatureSet for server compatibility workarounds.
161167
enable_rfc6764: Enable RFC6764 DNS-based service discovery.
162168
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.
163176
"""
164177
headers = headers or {}
165178

166-
if isinstance(features, str):
167-
import caldav.compatibility_hints
179+
from caldav.config import resolve_features
168180

169-
features = getattr(caldav.compatibility_hints, features)
181+
features = resolve_features(features)
170182
if isinstance(features, FeatureSet):
171183
self.features = features
172184
else:
@@ -246,6 +258,10 @@ def __init__(
246258
}
247259
self.headers.update(headers)
248260

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+
249265
def _create_session(self) -> None:
250266
"""Create or recreate the async HTTP client with current settings."""
251267
if _USE_HTTPX:
@@ -325,30 +341,41 @@ async def request(
325341
headers: Mapping[str, str] | None = None,
326342
) -> AsyncDAVResponse:
327343
"""
328-
Send an async HTTP request.
329-
330-
Args:
331-
url: Request URL.
332-
method: HTTP method.
333-
body: Request body.
334-
headers: Additional headers.
344+
Send an async HTTP request, with optional rate-limit sleep-and-retry.
335345
336-
Returns:
337-
AsyncDAVResponse object.
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.
338349
"""
339-
headers = headers or {}
340-
341-
combined_headers = self.headers.copy()
342-
combined_headers.update(headers)
343-
if (body is None or body == "") and "Content-Type" in combined_headers:
344-
del combined_headers["Content-Type"]
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)
345364

346-
# Objectify the URL
347-
url_obj = URL.objectify(url)
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.
348374
349-
log.debug(
350-
f"sending request - method={method}, url={str(url_obj)}, headers={combined_headers}\nbody:\n{to_normal_str(body)}"
351-
)
375+
Handles connection-abort workaround, 429/503 rate-limit detection,
376+
and 401 auth negotiation (including HTTP/2 fallback).
377+
"""
378+
url_obj, combined_headers = self._prepare_request(url, method, body, headers)
352379

353380
# Build request kwargs - different for httpx vs niquests
354381
if _USE_HTTPX:
@@ -437,71 +464,32 @@ async def request(
437464
r = await self.session.request(**request_kwargs)
438465
response = AsyncDAVResponse(r, self)
439466

440-
# Handle 401 responses for auth negotiation (after try/except)
441-
# This matches the original sync client's auth negotiation logic
442-
# httpx headers are already case-insensitive
443-
if (
444-
r.status_code == 401
445-
and "WWW-Authenticate" in r.headers
446-
and not self.auth
447-
and self.username is not None
448-
and self.password is not None # Empty password OK, but None means not configured
449-
):
450-
auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
451-
self.build_auth_object(auth_types)
452-
453-
if not self.auth:
454-
raise NotImplementedError(
455-
"The server does not provide any of the currently "
456-
"supported authentication methods: basic, digest, bearer"
457-
)
467+
# Handle 429/503 rate-limit responses
468+
error.raise_if_rate_limited(r.status_code, str(url_obj), r.headers.get("Retry-After"))
458469

459-
# Retry request with authentication
460-
return await self.request(url, method, body, headers)
470+
# Handle 401: negotiate auth then retry
471+
if self._should_negotiate_auth(r.status_code, r.headers):
472+
self._build_auth_from_401(r.headers["WWW-Authenticate"])
473+
return await self._async_request(url, method, body, headers)
461474

462475
elif (
463476
r.status_code == 401
464477
and "WWW-Authenticate" in r.headers
465478
and self.auth
466-
and self.password
467-
and isinstance(self.password, bytes)
479+
and self.features.is_supported("http.multiplexing", return_defaults=False) is None
468480
):
469-
# Handle HTTP/2 issue (matches original sync client)
470-
# Most likely wrong username/password combo, but could be an HTTP/2 problem
471-
if self.features.is_supported("http.multiplexing", return_defaults=False) is None:
472-
await self.close() # Uses correct close method for httpx/niquests
473-
self._http2 = False
474-
self._create_session()
475-
# Set multiplexing to False BEFORE retry to prevent infinite loop
476-
# If the retry succeeds, this was the right choice
477-
# If it also fails with 401, it's not a multiplexing issue but an auth issue
478-
self.features.set_feature("http.multiplexing", False)
479-
# If this one also fails, we give up
480-
ret = await self.request(str(url_obj), method, body, headers)
481-
return ret
482-
483-
# Most likely we're here due to wrong username/password combo,
484-
# but it could also be charset problems. Some (ancient) servers
485-
# don't like UTF-8 binary auth with Digest authentication.
486-
# An example are old SabreDAV based servers. Not sure about UTF-8
487-
# and Basic Auth, but likely the same. So retry if password is
488-
# a bytes sequence and not a string.
489-
auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
490-
self.password = self.password.decode()
491-
self.build_auth_object(auth_types)
492-
493-
self.username = None
494-
self.password = None
495-
496-
return await self.request(str(url_obj), method, body, headers)
497-
498-
# Raise AuthorizationError for 401/403 responses (matches original sync client)
481+
# Handle HTTP/2 multiplexing issue: most likely wrong username/password, but could
482+
# be an HTTP/2 problem. Retry with HTTP/2 disabled if multiplexing was auto-detected.
483+
await self.close()
484+
self._http2 = False
485+
self._create_session()
486+
# Set multiplexing to False BEFORE retry to prevent infinite loop
487+
self.features.set_feature("http.multiplexing", False)
488+
return await self._async_request(str(url_obj), method, body, headers)
489+
490+
# Raise AuthorizationError for 401/403 responses
499491
if response.status in (401, 403):
500-
try:
501-
reason = response.reason
502-
except AttributeError:
503-
reason = "None given"
504-
raise error.AuthorizationError(url=str(url_obj), reason=reason)
492+
self._raise_authorization_error(str(url_obj), response)
505493

506494
return response
507495

@@ -936,7 +924,7 @@ async def get_calendars(self, principal: Optional["Principal"] = None) -> list["
936924
principal = await client.get_principal()
937925
calendars = await client.get_calendars(principal)
938926
for cal in calendars:
939-
print(f"Calendar: {cal.name}")
927+
print(f"Calendar: {cal.get_display_name()}")
940928
"""
941929
from caldav.collection import Calendar
942930
from caldav.operations.calendarset_ops import (

0 commit comments

Comments
 (0)