Skip to content

Commit 3836534

Browse files
author
Chris Coutinho
committed
fix(client): Strip cookies from responses to avoid falsely raising CSRF errors
1 parent f852a18 commit 3836534

File tree

4 files changed

+94
-11
lines changed

4 files changed

+94
-11
lines changed

app-hooks/post-installation/install-calendar-app.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ php /var/www/html/occ app:enable calendar
1111
echo "Waiting for calendar app to initialize..."
1212
sleep 5
1313

14+
# Increase limits on calendar creation for integration tests (100 in 60s)
15+
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
16+
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
17+
1418
# Ensure maintenance mode is off before calendar operations
1519
php /var/www/html/occ maintenance:mode --off
1620

nextcloud_mcp_server/client/__init__.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import logging
22
import os
33

4-
from httpx import AsyncClient, Auth, BasicAuth, Request, Response
4+
from httpx import (
5+
AsyncClient,
6+
Auth,
7+
BasicAuth,
8+
Request,
9+
Response,
10+
AsyncBaseTransport,
11+
AsyncHTTPTransport,
12+
)
513

614
from ..controllers.notes_search import NotesSearchController
715
from .calendar import CalendarClient
@@ -13,19 +21,34 @@
1321
logger = logging.getLogger(__name__)
1422

1523

16-
def log_request(request: Request):
17-
logger.info(
24+
async def log_request(request: Request):
25+
logger.debug(
1826
"Request event hook: %s %s - Waiting for content",
1927
request.method,
2028
request.url,
2129
)
22-
logger.info("Request body: %s", request.content)
23-
logger.info("Headers: %s", request.headers)
30+
logger.debug("Request body: %s", request.content)
31+
logger.debug("Headers: %s", request.headers)
2432

2533

26-
def log_response(response: Response):
27-
response.read() # Explicitly read the stream before accessing .text
28-
logger.info("Response [%s] %s", response.status_code, response.text)
34+
async def log_response(response: Response):
35+
await response.aread()
36+
logger.debug("Response [%s] %s", response.status_code, response.text)
37+
38+
39+
class AsyncDisableCookieTransport(AsyncBaseTransport):
40+
"""This Transport disable cookies from accumulating in the httpx AsyncClient
41+
42+
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
43+
"""
44+
45+
def __init__(self, transport: AsyncBaseTransport):
46+
self.transport = transport
47+
48+
async def handle_async_request(self, request: Request) -> Response:
49+
response = await self.transport.handle_async_request(request)
50+
response.headers.pop("set-cookie", None)
51+
return response
2952

3053

3154
class NextcloudClient:
@@ -36,7 +59,8 @@ def __init__(self, base_url: str, username: str, auth: Auth | None = None):
3659
self._client = AsyncClient(
3760
base_url=base_url,
3861
auth=auth,
39-
# event_hooks={"request": [log_request], "response": [log_response]},
62+
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
63+
event_hooks={"request": [log_request], "response": [log_response]},
4064
)
4165

4266
# Initialize app clients

nextcloud_mcp_server/client/base.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,65 @@
33
import logging
44
from abc import ABC
55

6-
from httpx import AsyncClient
6+
from functools import wraps
7+
import time
8+
from httpx import HTTPStatusError, codes, RequestError, AsyncClient
79

810
logger = logging.getLogger(__name__)
911

1012

13+
def retry_on_429(func):
14+
"""This decorator handles the 429 response from REST APIs
15+
16+
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
17+
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
18+
response, the function will wait for a couple of seconds and retry the request.
19+
"""
20+
21+
MAX_RETRIES = 5
22+
23+
@wraps(func)
24+
async def wrapper(*args, **kwargs):
25+
retries = 0
26+
27+
while retries < MAX_RETRIES:
28+
try:
29+
# Make GET API call
30+
retries += 1
31+
response = await func(*args, **kwargs)
32+
break
33+
34+
except HTTPStatusError as e:
35+
# If we get a '429 Client Error: Too Many Requests'
36+
# error we wait a couple of seconds and do a retry
37+
if e.response.status_code == codes.TOO_MANY_REQUESTS:
38+
logger.warning(
39+
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
40+
)
41+
time.sleep(5)
42+
else:
43+
logger.warning(
44+
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
45+
)
46+
raise
47+
except RequestError as e:
48+
logger.warning(
49+
f"RequestError {e.request.url}: {e}, Number of attempts: {retries}"
50+
)
51+
raise
52+
53+
# If for loop ends without break statement
54+
else:
55+
logger.warning("All API call retries failed")
56+
raise RuntimeError(
57+
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
58+
)
59+
60+
return response
61+
62+
return wrapper
63+
64+
1165
class BaseNextcloudClient(ABC):
1266
"""Base class for all Nextcloud app clients."""
1367

@@ -25,6 +79,7 @@ def _get_webdav_base_path(self) -> str:
2579
"""Helper to get the base WebDAV path for the authenticated user."""
2680
return f"/remote.php/dav/files/{self.username}"
2781

82+
@retry_on_429
2883
async def _make_request(self, method: str, url: str, **kwargs):
2984
"""Common request wrapper with logging and error handling.
3085

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
logger = logging.getLogger(__name__)
1414

1515

16-
@pytest.fixture(scope="module")
16+
@pytest.fixture(scope="session")
1717
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
1818
"""
1919
Fixture to create a NextcloudClient instance for integration tests.

0 commit comments

Comments
 (0)