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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user