From 448dbd5f48a4b07ee6407ed1c53d5f5b0bc4f623 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 02:31:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(packs-lab):=20DSM=20=ED=98=B8=EC=B6=9C=20r?= =?UTF-8?q?etry/backoff=20+=20=EC=97=85=EB=A1=9C=EB=93=9C=20cleanup=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packs-lab/app/dsm_client.py | 60 +++++++++++++-- packs-lab/app/routes.py | 94 +++++++++++++----------- packs-lab/tests/test_dsm_client.py | 113 +++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 50 deletions(-) diff --git a/packs-lab/app/dsm_client.py b/packs-lab/app/dsm_client.py index 22469c5..7d9d2d0 100644 --- a/packs-lab/app/dsm_client.py +++ b/packs-lab/app/dsm_client.py @@ -4,6 +4,7 @@ - create_share_link(file_path, expires_in_sec) -> share URL """ +import asyncio import logging import os from datetime import datetime, timedelta, timezone @@ -18,6 +19,8 @@ DSM_PASS = os.getenv("DSM_PASS", "") # LAN IP로 DSM 접근 시 self-signed cert가 IP에 매칭 안 되어 검증 실패. LAN 내부 통신이라 false 허용. # 운영에서 LAN IP + self-signed면 DSM_VERIFY_SSL=false. 도메인 + 정상 cert면 기본값(true) 유지. DSM_VERIFY_SSL = os.getenv("DSM_VERIFY_SSL", "true").strip().lower() != "false" +DSM_MAX_RETRIES = max(1, int(os.getenv("DSM_MAX_RETRIES", "3"))) +DSM_BACKOFF_SEC = float(os.getenv("DSM_BACKOFF_SEC", "0.5")) API_AUTH = "/webapi/auth.cgi" API_SHARE = "/webapi/entry.cgi" @@ -27,13 +30,45 @@ class DSMError(RuntimeError): pass +async def _request_with_retry( + client: httpx.AsyncClient, + url: str, + params: dict, + timeout: float, +) -> httpx.Response: + """5xx · transport · timeout만 지수백오프 retry. 4xx와 DSM success=false는 호출자가 판단.""" + last_exc: Exception | None = None + for attempt in range(DSM_MAX_RETRIES): + try: + r = await client.get(url, params=params, timeout=timeout) + if r.status_code < 500: + return r + last_exc = httpx.HTTPStatusError( + f"HTTP {r.status_code}", request=r.request, response=r + ) + logger.warning( + "DSM HTTP %s — attempt %s/%s body=%s", + r.status_code, attempt + 1, DSM_MAX_RETRIES, r.text[:200], + ) + except (httpx.TransportError, httpx.TimeoutException) as e: + last_exc = e + logger.warning( + "DSM transport error: %s — attempt %s/%s", + e, attempt + 1, DSM_MAX_RETRIES, + ) + if attempt < DSM_MAX_RETRIES - 1: + await asyncio.sleep(DSM_BACKOFF_SEC * (2 ** attempt)) + raise DSMError(f"DSM 요청 실패 (재시도 {DSM_MAX_RETRIES}회): {last_exc}") + + async def _login(client: httpx.AsyncClient) -> str: """DSM 세션 sid 반환.""" if not all([DSM_HOST, DSM_USER, DSM_PASS]): raise DSMError("DSM 환경변수 미설정") - r = await client.get( + r = await _request_with_retry( + client, f"{DSM_HOST}{API_AUTH}", - params={ + { "api": "SYNO.API.Auth", "version": "7", "method": "login", @@ -42,12 +77,14 @@ async def _login(client: httpx.AsyncClient) -> str: "session": "FileStation", "format": "sid", }, - timeout=15.0, + 15.0, ) r.raise_for_status() data = r.json() if not data.get("success"): - raise DSMError(f"DSM login 실패: {data.get('error')}") + err = data.get("error", {}) + logger.error("DSM login 실패: code=%s error=%s", err.get("code"), err) + raise DSMError(f"DSM login 실패: code={err.get('code')} error={err}") return data["data"]["sid"] @@ -80,9 +117,10 @@ async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tupl async with httpx.AsyncClient(verify=DSM_VERIFY_SSL) as client: sid = await _login(client) try: - r = await client.get( + r = await _request_with_retry( + client, f"{DSM_HOST}{API_SHARE}", - params={ + { "api": "SYNO.FileStation.Sharing", "version": "3", "method": "create", @@ -90,16 +128,22 @@ async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tupl "date_expired": expire_time_ms, "_sid": sid, }, - timeout=15.0, + 15.0, ) r.raise_for_status() data = r.json() if not data.get("success"): - raise DSMError(f"DSM Sharing.create 실패: {data.get('error')}") + err = data.get("error", {}) + logger.error( + "DSM Sharing.create 실패: path=%s code=%s error=%s", + file_path, err.get("code"), err, + ) + raise DSMError(f"DSM Sharing.create 실패: code={err.get('code')} error={err}") links = data["data"]["links"] if not links: raise DSMError("Sharing 응답에 링크 없음") url = links[0]["url"] + logger.info("DSM share link created: path=%s", file_path) return url, expires_at finally: await _logout(client, sid) diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index 0090620..82c8ed6 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -135,52 +135,62 @@ async def upload( if target.exists(): raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다. 다른 이름으로 업로드하거나 기존 파일을 먼저 삭제하세요") - # multipart 스트림 저장 + 크기 검증 - written = 0 - with target.open("wb") as f: - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - written += len(chunk) - if written > MAX_BYTES: - f.close() - target.unlink(missing_ok=True) - raise HTTPException(status_code=413, detail="파일 크기 5GB 초과") - f.write(chunk) + upload_committed = False + try: + # multipart 스트림 저장 + 크기 검증 + written = 0 + with target.open("wb") as f: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + written += len(chunk) + if written > MAX_BYTES: + raise HTTPException(status_code=413, detail="파일 크기 5GB 초과") + f.write(chunk) - if written != expected_size: - target.unlink(missing_ok=True) - raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치") + if written != expected_size: + raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치") - # Supabase·DSM에 노출되는 file_path는 NAS 호스트 절대경로여야 한다. - # 컨테이너 경로(target)는 마운트된 호스트경로의 다른 시점일 뿐이라, 같은 디렉토리 구조를 보유. - host_path = PACK_HOST_DIR / filename + # Supabase·DSM에 노출되는 file_path는 NAS 호스트 절대경로여야 한다. + # 컨테이너 경로(target)는 마운트된 호스트경로의 다른 시점일 뿐이라, 같은 디렉토리 구조를 보유. + host_path = PACK_HOST_DIR / filename - # supabase INSERT - sb = _supabase() - file_id = str(uuid.uuid4()) - res = sb.table("pack_files").insert({ - "id": file_id, - "min_tier": tier, - "label": label, - "file_path": str(host_path), - "filename": filename, - "size_bytes": written, - }).execute() - if not res.data: - target.unlink(missing_ok=True) - raise HTTPException(status_code=500, detail="DB INSERT 실패") + # supabase INSERT + sb = _supabase() + file_id = str(uuid.uuid4()) + try: + res = sb.table("pack_files").insert({ + "id": file_id, + "min_tier": tier, + "label": label, + "file_path": str(host_path), + "filename": filename, + "size_bytes": written, + }).execute() + except Exception as e: + logger.exception("Supabase INSERT 예외: filename=%s", filename) + raise HTTPException(status_code=500, detail=f"DB INSERT 실패: {e}") from e + if not res.data: + raise HTTPException(status_code=500, detail="DB INSERT 실패") - return UploadResponse( - file_id=file_id, - file_path=str(host_path), - filename=filename, - size_bytes=written, - min_tier=tier, - label=label, - uploaded_at=res.data[0]["uploaded_at"], - ) + upload_committed = True + return UploadResponse( + file_id=file_id, + file_path=str(host_path), + filename=filename, + size_bytes=written, + min_tier=tier, + label=label, + uploaded_at=res.data[0]["uploaded_at"], + ) + finally: + if not upload_committed and target.exists(): + try: + target.unlink() + logger.warning("업로드 실패로 부분 파일 정리: %s", target) + except Exception as e: + logger.exception("부분 파일 정리 실패: %s — %s", target, e) @router.get("/list", response_model=list[PackFileItem]) diff --git a/packs-lab/tests/test_dsm_client.py b/packs-lab/tests/test_dsm_client.py index e488402..4da8ef6 100644 --- a/packs-lab/tests/test_dsm_client.py +++ b/packs-lab/tests/test_dsm_client.py @@ -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"