From e7cad4c13dfabec74851c5d369381f6f88953d7f Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:12:16 +0300 Subject: [PATCH 01/12] chore: add ratelimit error class --- caldav/lib/error.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/caldav/lib/error.py b/caldav/lib/error.py index 3eafb364..824afcb8 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -132,6 +132,12 @@ class ConsistencyError(DAVError): class ResponseError(DAVError): pass +class RateLimitError(CaldavError): + """Returns in case 429 Too Many Requests or 503 Service Unavailable w Retry-After.""" + def __init__(self, msg, retry_after=None): + super().__init__(msg) + self.retry_after = retry_after + exception_by_method: dict[str, DAVError] = defaultdict(lambda: DAVError) for method in ( From e34ebb519bed6ad0302a103b29ef738e1a41668e Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:23:12 +0300 Subject: [PATCH 02/12] feat(retry-after support): add retry-after for sync requests --- caldav/davclient.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index f13ecb86..c76e9b0b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -14,6 +14,8 @@ from types import TracebackType from typing import TYPE_CHECKING, Any, Optional from urllib.parse import unquote +from email.utils import parsedate_to_datetime +from datetime import datetime, timezone # Try niquests first (preferred), fall back to requests _USE_NIQUESTS = False @@ -973,9 +975,29 @@ def _sync_request( verify=self.ssl_verify_cert, cert=self.ssl_cert, ) + + r_headers = CaseInsensitiveDict(r.headers) + + # Handle 429, 503 responses for retry negotiation + if r.status_code in (429, 503) and "Retry-After" in r_headers: + retry_after = r_headers["Retry-After"] + if retry_after: + try: + retry_seconds = int(retry_after) + except ValueError: + try: + retry_date = parsedate_to_datetime(retry_after) + now = datetime.now(timezone.utc) + retry_seconds = max(0, (retry_date - now).total_seconds()) + except: + retry_seconds = None + + raise error.RateLimitError( + f"Rate limited or service unavailable. Retry after: {retry_after}", + retry_after=retry_seconds if retry_seconds is not None else retry_after, + ) # Handle 401 responses for auth negotiation - r_headers = CaseInsensitiveDict(r.headers) if ( r.status_code == 401 and "WWW-Authenticate" in r_headers From a9ada16467b3e644c7161dfebd5c7fd773df90f9 Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:39:44 +0300 Subject: [PATCH 03/12] chor: add retry_after_seconds in RateLimit error class --- caldav/lib/error.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/caldav/lib/error.py b/caldav/lib/error.py index 824afcb8..ea721974 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -134,9 +134,10 @@ class ResponseError(DAVError): class RateLimitError(CaldavError): """Returns in case 429 Too Many Requests or 503 Service Unavailable w Retry-After.""" - def __init__(self, msg, retry_after=None): + def __init__(self, msg, retry_after=None, retry_after_seconds=None): super().__init__(msg) self.retry_after = retry_after + self.retry_after_seconds = retry_after_seconds exception_by_method: dict[str, DAVError] = defaultdict(lambda: DAVError) From 37fa3aef451d87e12776173de3bbbbbb2b28e66a Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:40:53 +0300 Subject: [PATCH 04/12] chore: devide source retry-after and parsed int value --- caldav/davclient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index c76e9b0b..c5578560 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -994,7 +994,8 @@ def _sync_request( raise error.RateLimitError( f"Rate limited or service unavailable. Retry after: {retry_after}", - retry_after=retry_seconds if retry_seconds is not None else retry_after, + retry_after=retry_after, + retry_after_seconds=retry_seconds ) # Handle 401 responses for auth negotiation From a3d231a38bead76226d998db848eb391de1899e5 Mon Sep 17 00:00:00 2001 From: Artem Soloukhin Date: Sun, 22 Feb 2026 21:50:30 +0300 Subject: [PATCH 05/12] chore: fix ruff errs --- caldav/davclient.py | 6 +++--- caldav/lib/error.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index c5578560..09755ede 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -975,9 +975,9 @@ def _sync_request( verify=self.ssl_verify_cert, cert=self.ssl_cert, ) - + r_headers = CaseInsensitiveDict(r.headers) - + # Handle 429, 503 responses for retry negotiation if r.status_code in (429, 503) and "Retry-After" in r_headers: retry_after = r_headers["Retry-After"] @@ -995,7 +995,7 @@ def _sync_request( raise error.RateLimitError( f"Rate limited or service unavailable. Retry after: {retry_after}", retry_after=retry_after, - retry_after_seconds=retry_seconds + retry_after_seconds=retry_seconds, ) # Handle 401 responses for auth negotiation diff --git a/caldav/lib/error.py b/caldav/lib/error.py index ea721974..5daddca8 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -132,8 +132,10 @@ class ConsistencyError(DAVError): class ResponseError(DAVError): pass + class RateLimitError(CaldavError): """Returns in case 429 Too Many Requests or 503 Service Unavailable w Retry-After.""" + def __init__(self, msg, retry_after=None, retry_after_seconds=None): super().__init__(msg) self.retry_after = retry_after From 80ce80b6f688afbba801ad666c3e641cc1a55151 Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:53:28 +0300 Subject: [PATCH 06/12] chore: fix DAVError --- caldav/lib/error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caldav/lib/error.py b/caldav/lib/error.py index 5daddca8..5391c62b 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -133,7 +133,7 @@ class ResponseError(DAVError): pass -class RateLimitError(CaldavError): +class RateLimitError(DAVError): """Returns in case 429 Too Many Requests or 503 Service Unavailable w Retry-After.""" def __init__(self, msg, retry_after=None, retry_after_seconds=None): From 290d6968e188acd4b6819fd4dfe422d04c5e917d Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:32:37 +0300 Subject: [PATCH 07/12] chore: add tobixen ideas --- caldav/davclient.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index 09755ede..10a709c8 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -8,6 +8,7 @@ For async code, use: from caldav import aio """ +import time import logging import sys import warnings @@ -208,6 +209,9 @@ def __init__( features: FeatureSet | dict | str = None, enable_rfc6764: bool = True, require_tls: bool = True, + rate_limit_handle: bool = False, + rate_limit_default_sleep: int = None, + rate_limit_max_sleep: int = None, ) -> None: """ Sets up a HTTPConnection object towards the server in the url. @@ -245,6 +249,13 @@ def __init__( redirect to unencrypted HTTP. Set to False ONLY if you need to support non-TLS servers and trust your DNS infrastructure. This parameter has no effect if enable_rfc6764=False. + rate_limit_handle: boolean, a parameter that determines whether the rate limit response + should be handled. Default: False. + rate_limit_default_sleep: integer, the default number of seconds to sleep if the server + response cannot be parsed, or if no retry-after is specified + and the HTTP response status code is 429. Default: None. + rate_limit_max_sleep: integer, the maximum number of seconds the script will sleep + when encountering a rate limit. Default: None. The niquests library will honor a .netrc-file, if such a file exists username and password may be omitted. @@ -343,6 +354,10 @@ def __init__( self._principal = None + self.rate_limit_handle = rate_limit_handle + self.rate_limit_default_sleep = rate_limit_default_sleep + self.rate_limit_max_sleep = rate_limit_max_sleep + def __enter__(self) -> Self: ## Used for tests, to set up a temporarily test server if hasattr(self, "setup"): @@ -933,7 +948,22 @@ def request( Returns: DAVResponse """ - return self._sync_request(url, method, body, headers) + try: + return self._sync_request(url, method, body, headers) + except error.RateLimitError as e: + if self.rate_limit_handle: + retry_after_seconds = self.rate_limit_default_sleep + if e.retry_after_seconds is not None: + retry_after_seconds = e.retry_after_seconds + retry_after_seconds = max( + [retry_after_seconds or 0, self.rate_limit_max_sleep or 0] + ) + if retry_after_seconds <= 0: + raise e + time.sleep(retry_after_seconds) + return self._sync_request(url, method, body, headers) + + raise e def _sync_request( self, From 457a73992f1d2492defcef55e7d76b6ebfd9e525 Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:42:48 +0300 Subject: [PATCH 08/12] chore: fix max2min sleep --- caldav/davclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index 10a709c8..f80cf2fb 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -955,7 +955,7 @@ def request( retry_after_seconds = self.rate_limit_default_sleep if e.retry_after_seconds is not None: retry_after_seconds = e.retry_after_seconds - retry_after_seconds = max( + retry_after_seconds = min( [retry_after_seconds or 0, self.rate_limit_max_sleep or 0] ) if retry_after_seconds <= 0: From e65ed19da8d8e3382d2bb8d4920ab5f250e97edc Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:47:58 +0300 Subject: [PATCH 09/12] chore: add if self.rate_limit_max_sleep --- caldav/davclient.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index f80cf2fb..536c4564 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -955,9 +955,8 @@ def request( retry_after_seconds = self.rate_limit_default_sleep if e.retry_after_seconds is not None: retry_after_seconds = e.retry_after_seconds - retry_after_seconds = min( - [retry_after_seconds or 0, self.rate_limit_max_sleep or 0] - ) + if self.rate_limit_max_sleep: + retry_after_seconds = min(retry_after_seconds or 0, self.rate_limit_max_sleep) if retry_after_seconds <= 0: raise e time.sleep(retry_after_seconds) From 5919c77af6b2c358590622374714e3b41dbffa14 Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:07:56 +0300 Subject: [PATCH 10/12] chore: fix ruff styles --- caldav/davclient.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index 536c4564..99e8d007 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -8,15 +8,15 @@ For async code, use: from caldav import aio """ -import time import logging import sys +import time import warnings +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from types import TracebackType from typing import TYPE_CHECKING, Any, Optional from urllib.parse import unquote -from email.utils import parsedate_to_datetime -from datetime import datetime, timezone # Try niquests first (preferred), fall back to requests _USE_NIQUESTS = False From 119add54d0649d52f3f8067554aa0896fd244451 Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:16:38 +0300 Subject: [PATCH 11/12] chore: retry in case only 429 http_code without headers --- caldav/davclient.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index 99e8d007..059aa96b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -1008,19 +1008,23 @@ def _sync_request( r_headers = CaseInsensitiveDict(r.headers) # Handle 429, 503 responses for retry negotiation - if r.status_code in (429, 503) and "Retry-After" in r_headers: - retry_after = r_headers["Retry-After"] - if retry_after: + if r.status_code in (429, 503): + retry_after_header: Optional[str] = r_headers.get("Retry-After") + retry_after_value: Optional[str] = None + retry_seconds: Optional[float] = None + if retry_after_header: # непустая строка + retry_after_value = retry_after_header try: - retry_seconds = int(retry_after) + # пытаемся интерпретировать как целое число секунд + retry_seconds = int(retry_after_header) except ValueError: try: retry_date = parsedate_to_datetime(retry_after) now = datetime.now(timezone.utc) retry_seconds = max(0, (retry_date - now).total_seconds()) except: - retry_seconds = None - + pass + if r.status_code == 429 or retry_after_header is not None: raise error.RateLimitError( f"Rate limited or service unavailable. Retry after: {retry_after}", retry_after=retry_after, From 22b79ce5040a424466898a527942fc2926772fbc Mon Sep 17 00:00:00 2001 From: Tema <11072055+temsocial@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:37:45 +0300 Subject: [PATCH 12/12] chore: fix comments --- caldav/davclient.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index 059aa96b..dfd29e0b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -1012,10 +1012,9 @@ def _sync_request( retry_after_header: Optional[str] = r_headers.get("Retry-After") retry_after_value: Optional[str] = None retry_seconds: Optional[float] = None - if retry_after_header: # непустая строка + if retry_after_header: retry_after_value = retry_after_header try: - # пытаемся интерпретировать как целое число секунд retry_seconds = int(retry_after_header) except ValueError: try: