66For sync usage, see the davclient.py wrapper.
77"""
88
9+ import asyncio
10+ import logging
911import sys
1012from collections .abc import Mapping
1113from types import TracebackType
5759from caldav .base_client import get_davclient as _base_get_davclient
5860from caldav .compatibility_hints import FeatureSet
5961from 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
6163from caldav .lib .url import URL
62- from caldav .objects import log
6364from caldav .protocol .types import (
6465 CalendarQueryResult ,
6566 PropfindResult ,
7879from caldav .requests import HTTPBearerAuth
7980from caldav .response import BaseDAVResponse
8081
82+ log = logging .getLogger ("caldav" )
83+
8184if sys .version_info < (3 , 11 ):
8285 from typing_extensions import Self
8386else :
@@ -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 } \n body:\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