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