feat(packs-lab): DSM 호출 retry/backoff + 업로드 cleanup 보강
- dsm_client.py: _request_with_retry()로 5xx·transport·timeout만 지수백오프 재시도 (DSM_MAX_RETRIES, DSM_BACKOFF_SEC env). DSM error code 응답 본문 로깅. - routes.py: upload 핸들러를 try/finally로 감싸 부분파일 정리 보장, Supabase INSERT 호출 자체에 try/except 추가해 네트워크 예외도 cleanup. - test_dsm_client.py: retry 시나리오 4종 추가 (5xx→성공/소진/transport error/4xx no-retry). 전체 29 테스트 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,13 @@ import httpx
|
||||
from app.dsm_client import create_share_link, DSMError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_backoff(monkeypatch):
|
||||
"""retry 백오프 sleep 제거 — 테스트 속도."""
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_BACKOFF_SEC", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _dsm_env(monkeypatch):
|
||||
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||||
@@ -109,3 +116,109 @@ def test_dsm_share_failure_logs_out():
|
||||
|
||||
assert "login" in call_order
|
||||
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||||
|
||||
|
||||
def test_retry_on_5xx_then_success(monkeypatch):
|
||||
"""첫 호출 5xx → retry → 두 번째 200으로 성공."""
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)
|
||||
|
||||
login_calls = {"n": 0}
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
if method == "login":
|
||||
login_calls["n"] += 1
|
||||
if login_calls["n"] == 1:
|
||||
return _make_response({}, status_code=503)
|
||||
return _make_response({"success": True, "data": {"sid": "sid-after-retry"}})
|
||||
if method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://nas/sharing/retry"}]},
|
||||
})
|
||||
return _make_response({"success": True})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, _ = asyncio.run(create_share_link("/volume1/x.zip"))
|
||||
|
||||
assert url == "https://nas/sharing/retry"
|
||||
assert login_calls["n"] == 2, "5xx 응답에 대해 retry가 동작해야 함"
|
||||
|
||||
|
||||
def test_retry_exhausts_on_persistent_5xx(monkeypatch):
|
||||
"""5xx가 MAX_RETRIES 동안 계속되면 DSMError로 raise."""
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 2)
|
||||
|
||||
login_calls = {"n": 0}
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
if method == "login":
|
||||
login_calls["n"] += 1
|
||||
return _make_response({}, status_code=503)
|
||||
return _make_response({"success": True})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(DSMError, match="재시도"):
|
||||
asyncio.run(create_share_link("/volume1/x.zip"))
|
||||
|
||||
assert login_calls["n"] == 2, f"MAX_RETRIES만큼 시도해야 함 (실제: {login_calls['n']})"
|
||||
|
||||
|
||||
def test_retry_on_transport_error_then_success(monkeypatch):
|
||||
"""httpx.ConnectError → retry → 성공."""
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)
|
||||
|
||||
login_calls = {"n": 0}
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
if method == "login":
|
||||
login_calls["n"] += 1
|
||||
if login_calls["n"] == 1:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||
if method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://nas/sharing/tr"}]},
|
||||
})
|
||||
return _make_response({"success": True})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, _ = asyncio.run(create_share_link("/volume1/x.zip"))
|
||||
|
||||
assert url == "https://nas/sharing/tr"
|
||||
assert login_calls["n"] == 2
|
||||
|
||||
|
||||
def test_no_retry_on_4xx(monkeypatch):
|
||||
"""4xx (영구 오류)는 retry 없이 즉시 raise_for_status."""
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)
|
||||
|
||||
login_calls = {"n": 0}
|
||||
|
||||
def _raise_4xx():
|
||||
raise httpx.HTTPStatusError(
|
||||
"client error",
|
||||
request=MagicMock(),
|
||||
response=MagicMock(status_code=403),
|
||||
)
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
login_calls["n"] += 1
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = 403
|
||||
resp.json.return_value = {}
|
||||
resp.raise_for_status = _raise_4xx
|
||||
return resp
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
asyncio.run(create_share_link("/volume1/x.zip"))
|
||||
|
||||
assert login_calls["n"] == 1, "4xx는 retry 없이 즉시 raise"
|
||||
|
||||
Reference in New Issue
Block a user