Skip to content

Commit 36a5aeb

Browse files
committed
Add automatic retry on 429/503 with exponential backoff via urllib3 Retry
1 parent cf68205 commit 36a5aeb

6 files changed

Lines changed: 146 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export NEXTCLOUD_PASSWORD=your-app-password # Use an app password, not your mai
6969

7070
# Optional
7171
export NEXTCLOUD_MCP_PERMISSIONS=read # read (default), write, or destructive
72+
export NEXTCLOUD_MCP_RETRY_MAX=3 # max retries on 429/503 (default: 3, 0 to disable)
7273
```
7374

7475
### Getting an App Password

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ venvPath = "."
106106
venv = "venv"
107107
reportUnusedFunction = false
108108
reportPrivateUsage = false
109+
executionEnvironments = [
110+
{ root = "tests/test_client_retry.py", reportAttributeAccessIssue = false, reportUnknownMemberType = false }
111+
]
109112

110113
[tool.pytest]
111114
ini_options.testpaths = [ "tests" ]

src/nextcloud_mcp/client.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
import niquests
8+
from urllib3.util import Retry
89

910
from .config import Config
1011

@@ -70,14 +71,23 @@ def __init__(self, config: Config) -> None:
7071

7172
async def _get_session(self) -> niquests.AsyncSession:
7273
if self._session is None:
73-
self._session = niquests.AsyncSession(
74-
auth=(self._config.user, self._config.password),
75-
timeout=30,
76-
headers={
74+
kwargs: dict[str, object] = {
75+
"auth": (self._config.user, self._config.password),
76+
"timeout": 30,
77+
"headers": {
7778
"OCS-APIRequest": "true",
7879
"Accept": "application/json",
7980
},
80-
)
81+
}
82+
if self._config.retry_max > 0:
83+
kwargs["retries"] = Retry(
84+
total=self._config.retry_max,
85+
status_forcelist=[429, 503],
86+
backoff_factor=1.0,
87+
respect_retry_after_header=True,
88+
allowed_methods=None,
89+
)
90+
self._session = niquests.AsyncSession(**kwargs) # type: ignore[arg-type]
8191
return self._session
8292

8393
async def close(self) -> None:

src/nextcloud_mcp/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Config:
1919
NEXTCLOUD_MCP_PERMISSIONS: Permission level — 'read' (default), 'write', or 'destructive'
2020
NEXTCLOUD_MCP_HOST: Host to bind HTTP server (default: 0.0.0.0)
2121
NEXTCLOUD_MCP_PORT: Port for HTTP server (default: 8100)
22+
NEXTCLOUD_MCP_RETRY_MAX: Max retries on 429/503 (default: 3, 0 to disable)
2223
"""
2324

2425
nextcloud_url: str = field(default="")
@@ -27,6 +28,7 @@ class Config:
2728
permission_level: PermissionLevel = field(default=PermissionLevel.READ)
2829
host: str = field(default="0.0.0.0")
2930
port: int = field(default=8100)
31+
retry_max: int = field(default=3)
3032

3133
@classmethod
3234
def from_env(cls) -> "Config":
@@ -44,6 +46,7 @@ def from_env(cls) -> "Config":
4446

4547
host = os.environ.get("NEXTCLOUD_MCP_HOST", "0.0.0.0")
4648
port = int(os.environ.get("NEXTCLOUD_MCP_PORT", "8100"))
49+
retry_max = int(os.environ.get("NEXTCLOUD_MCP_RETRY_MAX", "3"))
4750

4851
return cls(
4952
nextcloud_url=url,
@@ -52,6 +55,7 @@ def from_env(cls) -> "Config":
5255
permission_level=perm,
5356
host=host,
5457
port=port,
58+
retry_max=max(0, retry_max),
5559
)
5660

5761
def validate(self) -> None:

tests/test_client_retry.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for HTTP client retry configuration."""
2+
3+
import pytest
4+
5+
from nextcloud_mcp.client import NextcloudClient
6+
from nextcloud_mcp.config import Config
7+
8+
9+
def _make_config(retry_max: int = 3) -> Config:
10+
return Config(
11+
nextcloud_url="http://localhost",
12+
user="admin",
13+
password="admin",
14+
retry_max=retry_max,
15+
)
16+
17+
18+
class TestRetryConfiguration:
19+
@pytest.mark.asyncio
20+
async def test_retry_enabled_by_default(self) -> None:
21+
client = NextcloudClient(_make_config())
22+
session = await client._get_session()
23+
try:
24+
adapter = session.get_adapter("http://localhost")
25+
assert adapter.max_retries.total == 3
26+
finally:
27+
await client.close()
28+
29+
@pytest.mark.asyncio
30+
async def test_retry_status_forcelist(self) -> None:
31+
client = NextcloudClient(_make_config())
32+
session = await client._get_session()
33+
try:
34+
adapter = session.get_adapter("http://localhost")
35+
assert 429 in adapter.max_retries.status_forcelist
36+
assert 503 in adapter.max_retries.status_forcelist
37+
finally:
38+
await client.close()
39+
40+
@pytest.mark.asyncio
41+
async def test_retry_respects_retry_after(self) -> None:
42+
client = NextcloudClient(_make_config())
43+
session = await client._get_session()
44+
try:
45+
adapter = session.get_adapter("http://localhost")
46+
assert adapter.max_retries.respect_retry_after_header is True
47+
finally:
48+
await client.close()
49+
50+
@pytest.mark.asyncio
51+
async def test_retry_custom_max(self) -> None:
52+
client = NextcloudClient(_make_config(retry_max=5))
53+
session = await client._get_session()
54+
try:
55+
adapter = session.get_adapter("http://localhost")
56+
assert adapter.max_retries.total == 5
57+
finally:
58+
await client.close()
59+
60+
@pytest.mark.asyncio
61+
async def test_retry_disabled_when_zero(self) -> None:
62+
client = NextcloudClient(_make_config(retry_max=0))
63+
session = await client._get_session()
64+
try:
65+
adapter = session.get_adapter("http://localhost")
66+
assert adapter.max_retries.total == 0
67+
finally:
68+
await client.close()
69+
70+
@pytest.mark.asyncio
71+
async def test_retry_allows_all_methods(self) -> None:
72+
client = NextcloudClient(_make_config())
73+
session = await client._get_session()
74+
try:
75+
adapter = session.get_adapter("http://localhost")
76+
assert adapter.max_retries.allowed_methods is None
77+
finally:
78+
await client.close()
79+
80+
@pytest.mark.asyncio
81+
async def test_retry_backoff_factor(self) -> None:
82+
client = NextcloudClient(_make_config())
83+
session = await client._get_session()
84+
try:
85+
adapter = session.get_adapter("http://localhost")
86+
assert adapter.max_retries.backoff_factor == 1.0
87+
finally:
88+
await client.close()

tests/test_config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,41 @@ def test_invalid_permission_raises(self, monkeypatch: pytest.MonkeyPatch) -> Non
6060
with pytest.raises(ValueError, match="Invalid NEXTCLOUD_MCP_PERMISSIONS"):
6161
Config.from_env()
6262

63+
def test_default_retry_max(self, monkeypatch: pytest.MonkeyPatch) -> None:
64+
monkeypatch.setenv("NEXTCLOUD_URL", "http://localhost")
65+
monkeypatch.setenv("NEXTCLOUD_USER", "admin")
66+
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "admin")
67+
68+
config = Config.from_env()
69+
assert config.retry_max == 3
70+
71+
def test_retry_max_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
72+
monkeypatch.setenv("NEXTCLOUD_URL", "http://localhost")
73+
monkeypatch.setenv("NEXTCLOUD_USER", "admin")
74+
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "admin")
75+
monkeypatch.setenv("NEXTCLOUD_MCP_RETRY_MAX", "5")
76+
77+
config = Config.from_env()
78+
assert config.retry_max == 5
79+
80+
def test_retry_max_zero_disables(self, monkeypatch: pytest.MonkeyPatch) -> None:
81+
monkeypatch.setenv("NEXTCLOUD_URL", "http://localhost")
82+
monkeypatch.setenv("NEXTCLOUD_USER", "admin")
83+
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "admin")
84+
monkeypatch.setenv("NEXTCLOUD_MCP_RETRY_MAX", "0")
85+
86+
config = Config.from_env()
87+
assert config.retry_max == 0
88+
89+
def test_retry_max_negative_clamped_to_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
90+
monkeypatch.setenv("NEXTCLOUD_URL", "http://localhost")
91+
monkeypatch.setenv("NEXTCLOUD_USER", "admin")
92+
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "admin")
93+
monkeypatch.setenv("NEXTCLOUD_MCP_RETRY_MAX", "-1")
94+
95+
config = Config.from_env()
96+
assert config.retry_max == 0
97+
6398
def test_case_insensitive_permission(self, monkeypatch: pytest.MonkeyPatch) -> None:
6499
monkeypatch.setenv("NEXTCLOUD_URL", "http://localhost")
65100
monkeypatch.setenv("NEXTCLOUD_USER", "admin")

0 commit comments

Comments
 (0)