mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
http: add rate limiting (mainly for google)
- google calendar uses the `403` and `429` codes to perform rate limiting [1][2]. this pr adds `tenacity` to perform exponential back off as suggested in google calendar's docs [3]. [1]: https://developers.google.com/workspace/calendar/api/guides/errors#403_rate_limit_exceeded [2]: https://developers.google.com/workspace/calendar/api/guides/errors#429_too_many_requests [3]: https://developers.google.com/workspace/calendar/api/guides/quota#backoff
This commit is contained in:
parent
b5d3b7e578
commit
0d741022a9
4 changed files with 235 additions and 0 deletions
|
|
@ -39,6 +39,7 @@ dependencies = [
|
||||||
"requests>=2.20.0",
|
"requests>=2.20.0",
|
||||||
"aiohttp>=3.8.2,<4.0.0",
|
"aiohttp>=3.8.2,<4.0.0",
|
||||||
"aiostream>=0.4.3,<0.8.0",
|
"aiostream>=0.4.3,<0.8.0",
|
||||||
|
"tenacity>=9.0.0",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import aiohttp
|
||||||
from aioresponses import CallbackResult
|
from aioresponses import CallbackResult
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
from tests import normalize_item
|
from tests import normalize_item
|
||||||
from vdirsyncer.exceptions import UserError
|
from vdirsyncer.exceptions import UserError
|
||||||
|
from vdirsyncer.http import request
|
||||||
from vdirsyncer.http import BasicAuthMethod
|
from vdirsyncer.http import BasicAuthMethod
|
||||||
from vdirsyncer.http import DigestAuthMethod
|
from vdirsyncer.http import DigestAuthMethod
|
||||||
|
from vdirsyncer.http import UsageLimitReached
|
||||||
from vdirsyncer.storage.http import HttpStorage
|
from vdirsyncer.storage.http import HttpStorage
|
||||||
from vdirsyncer.storage.http import prepare_auth
|
from vdirsyncer.storage.http import prepare_auth
|
||||||
|
|
||||||
|
|
@ -120,3 +123,41 @@ def test_verify_false_disallowed(aio_connector):
|
||||||
HttpStorage(url="http://example.com", verify=False, connector=aio_connector)
|
HttpStorage(url="http://example.com", verify=False, connector=aio_connector)
|
||||||
|
|
||||||
assert "must be a path to a pem-file." in str(excinfo.value).lower()
|
assert "must be a path to a pem-file." in str(excinfo.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_403_usage_limit_exceeded(aio_connector):
|
||||||
|
url = "http://127.0.0.1/test_403"
|
||||||
|
error_body = {
|
||||||
|
"error": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"domain": "usageLimits",
|
||||||
|
"message": "Calendar usage limits exceeded.",
|
||||||
|
"reason": "quotaExceeded",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"code": 403,
|
||||||
|
"message": "Calendar usage limits exceeded.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(connector=aio_connector) as session:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(url, status=403, payload=error_body, repeat=True)
|
||||||
|
with pytest.raises(UsageLimitReached):
|
||||||
|
await request("GET", url, session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_403_without_usage_limits_domain(aio_connector):
|
||||||
|
"""A 403 JSON error without the Google 'usageLimits' domain should not be
|
||||||
|
treated as UsageLimitReached and should surface as ClientResponseError.
|
||||||
|
"""
|
||||||
|
url = "http://127.0.0.1/test_403_no_usage_limits"
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(connector=aio_connector) as session:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(url, status=403, repeat=True)
|
||||||
|
with pytest.raises(aiohttp.ClientResponseError):
|
||||||
|
await request("GET", url, session)
|
||||||
|
|
|
||||||
140
tests/unit/test_retry.py
Normal file
140
tests/unit/test_retry.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
|
from vdirsyncer.exceptions import Error as VdirsyncerError
|
||||||
|
from vdirsyncer.http import UsageLimitReached, request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def _create_mock_response(status: int, body: str | dict):
|
||||||
|
raw_body = body
|
||||||
|
if isinstance(body, dict):
|
||||||
|
text_body = json.dumps(body)
|
||||||
|
else:
|
||||||
|
text_body = body
|
||||||
|
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = status
|
||||||
|
mock_response.ok = 200 <= status < 300
|
||||||
|
mock_response.reason = "OK" if mock_response.ok else "Forbidden"
|
||||||
|
mock_response.headers = (
|
||||||
|
{"Content-Type": "application/json"}
|
||||||
|
if isinstance(raw_body, dict)
|
||||||
|
else {"Content-Type": "text/plain"}
|
||||||
|
)
|
||||||
|
mock_response.text.return_value = text_body
|
||||||
|
if isinstance(raw_body, dict):
|
||||||
|
mock_response.json.return_value = raw_body
|
||||||
|
else:
|
||||||
|
mock_response.json.side_effect = ValueError("Not JSON")
|
||||||
|
mock_response.raise_for_status = Mock(
|
||||||
|
side_effect=(
|
||||||
|
aiohttp.ClientResponseError(
|
||||||
|
request_info=AsyncMock(),
|
||||||
|
history=(),
|
||||||
|
status=status,
|
||||||
|
message=mock_response.reason,
|
||||||
|
headers=mock_response.headers,
|
||||||
|
)
|
||||||
|
if not mock_response.ok
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_retry_on_usage_limit():
|
||||||
|
url = "http://example.com/api"
|
||||||
|
max_retries = 5 # As configured in the @retry decorator
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate (max_retries - 1) 403 errors and then a 200 OK
|
||||||
|
mock_session.request.side_effect = [
|
||||||
|
await _create_mock_response(
|
||||||
|
403,
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"errors": [{"domain": "usageLimits", "reason": "quotaExceeded"}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for _ in range(max_retries - 1)
|
||||||
|
] + [await _create_mock_response(200, "OK")]
|
||||||
|
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession()
|
||||||
|
) as session: # Dummy session. Will be replaced by mock_session at call
|
||||||
|
response = await request("GET", url, mock_session)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert mock_session.request.call_count == max_retries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_retry_exceeds_max_attempts():
|
||||||
|
url = "http://example.com/api"
|
||||||
|
max_retries = 5 # As configured in the @retry decorator
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
# Simulate max_retries 403 errors and then a 200 OK
|
||||||
|
mock_session.request.side_effect = [
|
||||||
|
await _create_mock_response(
|
||||||
|
403,
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"errors": [{"domain": "usageLimits", "reason": "quotaExceeded"}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for _ in range(max_retries)
|
||||||
|
]
|
||||||
|
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession()
|
||||||
|
) as session: # Dummy session. Will be replaced by mock_session at call
|
||||||
|
with pytest.raises(UsageLimitReached):
|
||||||
|
await request("GET", url, mock_session)
|
||||||
|
assert mock_session.request.call_count == max_retries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_no_retry_on_generic_403_json():
|
||||||
|
url = "http://example.com/api"
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
# Generic non-Google 403 error payload (e.g., GitHub-style)
|
||||||
|
mock_session.request.side_effect = [
|
||||||
|
await _create_mock_response(403, {"message": "API rate limit exceeded"})
|
||||||
|
]
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
with pytest.raises(aiohttp.ClientResponseError):
|
||||||
|
await request("GET", url, mock_session)
|
||||||
|
# Should not retry because it's not the Google quotaExceeded shape
|
||||||
|
assert mock_session.request.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_no_retry_on_generic_403_text():
|
||||||
|
url = "http://example.com/api"
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
# Plain-text 403 body mentioning rate limits, but not structured as Google error
|
||||||
|
mock_session.request.side_effect = [
|
||||||
|
await _create_mock_response(403, "Rate limit exceeded")
|
||||||
|
]
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
with pytest.raises(aiohttp.ClientResponseError):
|
||||||
|
await request("GET", url, mock_session)
|
||||||
|
# Should not retry because the JSON shape is not Google quotaExceeded
|
||||||
|
assert mock_session.request.call_count == 1
|
||||||
|
|
@ -12,6 +12,12 @@ from ssl import create_default_context
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import requests.auth
|
import requests.auth
|
||||||
from requests.utils import parse_dict_header
|
from requests.utils import parse_dict_header
|
||||||
|
from tenacity import (
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
|
|
@ -148,6 +154,47 @@ def prepare_client_cert(cert):
|
||||||
return cert
|
return cert
|
||||||
|
|
||||||
|
|
||||||
|
class UsageLimitReached(exceptions.Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_quota_exceeded_google(response: aiohttp.ClientResponse) -> bool:
|
||||||
|
"""Return True if the response JSON indicates Google-style `usageLimits` exceeded.
|
||||||
|
|
||||||
|
Expected shape:
|
||||||
|
{"error": {"errors": [{"domain": "usageLimits", ...}], ...}}
|
||||||
|
|
||||||
|
See https://developers.google.com/workspace/calendar/api/guides/errors#403_usage_limits_exceeded
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await response.json(content_type=None)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
error = data.get("error")
|
||||||
|
if not isinstance(error, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
errors = error.get("errors")
|
||||||
|
if not isinstance(errors, list):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for entry in errors:
|
||||||
|
if isinstance(entry, dict) and entry.get("domain") == "usageLimits":
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||||
|
retry=retry_if_exception_type(UsageLimitReached),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
async def request(
|
async def request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
|
@ -210,6 +257,12 @@ async def request(
|
||||||
# some other error, will be handled later on
|
# some other error, will be handled later on
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if response.status == 429:
|
||||||
|
raise UsageLimitReached(response.reason)
|
||||||
|
|
||||||
|
if response.status == 403 and await _is_quota_exceeded_google(response):
|
||||||
|
raise UsageLimitReached(response.reason)
|
||||||
|
|
||||||
# See https://github.com/kennethreitz/requests/issues/2042
|
# See https://github.com/kennethreitz/requests/issues/2042
|
||||||
content_type = response.headers.get("Content-Type", "")
|
content_type = response.headers.get("Content-Type", "")
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue