fix(B03): 직접 업로드 마지막 파일 404 + 실패 기록이 실패하던 문제 (E2E 결함 1·4)

결함 1 — 업로드 세션이 인증 세션을 가리고 있었다
- finalize_project_upload가 Depends(verify_session)로 받은 session을 같은 이름으로
  덮어써, str(session["role"])이 업로드 세션 행에서 role을 찾다 KeyError를 냈다.
  KeyError는 LookupError 하위라 404 {"message": "'role'"}로 나가고 로그도 안 남았다.
- 업로드 세션 변수를 upload_session으로 분리(청크 업로드·finalize·상태 조회 3곳).
- B03_FileInput_Router_Errors.lookup_error_response() 신설: 조회 실패만 404,
  KeyError·IndexError는 500 + 예외 로그. 두 라우터의 LookupError 처리 13곳에 적용.
  batch 단위 엔드포인트에는 batch_id를, 프로젝트 단위에는 project_id를 로그 필드로 준다.

결함 4 — fail_stage가 예외 문자열을 그대로 넣어 UPDATE가 죽었다
- project_workflow_stages.message는 varchar(255)인데 PermissionError 메시지는 300자를
  넘겨 DataError로 실패했고, 단계가 FAILED로 못 가 화면이 영영 "분석 중"이었다.
- 200자로 자르고 말줄임표를 붙인다. 원문은 호출부 로그에 남는다.

검증(실서버, 신규 프로젝트 f45243b3에 표본 5개 직접 청크 업로드):
  finalize 성공 11건 / "'role'" 오류 0건, 마지막 파일 complete_upload=true도 성공.
  이어서 WF1 자동 분석이 시작됨(stage 0 COMPLETE, stage 1 IN_PROGRESS) — 종전에는
  예외가 스케줄링 앞에서 터져 자동 분석이 아예 걸리지 않았다.
ruff format·check 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:18:42 +09:00
co-authored by Claude Opus 5
parent 1e905c257f
commit 1f119f9845
4 changed files with 74 additions and 29 deletions
+20 -19
View File
@@ -36,6 +36,7 @@ from B03_FileInput.B03_FileInput_Repository import (
mark_upload_session_failed,
upsert_upload_chunk,
)
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
from B03_FileInput.B03_FileInput_Schema import (
ChunkSessionCreateRequest,
ChunkSessionCreateResponse,
@@ -348,7 +349,7 @@ async def upload_project_files(
)
return FileUploadResponse(project_id=str(project_id), files=results)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
@@ -398,7 +399,7 @@ async def create_project_upload_session(
total_chunks=total_chunks,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -420,12 +421,12 @@ async def upload_project_chunk(
pool = get_db_pool()
try:
async with pool.acquire() as connection:
session = await get_upload_session(
upload_session = await get_upload_session(
connection,
project_id=project_id,
session_id=session_id,
)
if chunk_index < 0 or chunk_index >= int(session["total_chunks"]):
if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]):
return JSONResponse(
status_code=400,
content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."},
@@ -437,7 +438,7 @@ async def upload_project_chunk(
chunk_data,
session_dir,
chunk_index,
expected_max_bytes=int(session["chunk_size_bytes"]),
expected_max_bytes=int(upload_session["chunk_size_bytes"]),
)
relative_chunk_path = chunk_path.relative_to(project_root).as_posix()
completed_chunks = await upsert_upload_chunk(
@@ -452,11 +453,11 @@ async def upload_project_chunk(
upload_session_id=session_id,
chunk_index=chunk_index,
completed_chunks=completed_chunks,
total_chunks=int(session["total_chunks"]),
total_chunks=int(upload_session["total_chunks"]),
chunk_hash=chunk_hash,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -485,12 +486,12 @@ async def finalize_project_upload(
point_cloud_input_id: int | None = None
try:
async with pool.acquire() as connection:
session = await get_upload_session(
upload_session = await get_upload_session(
connection,
project_id=project_id,
session_id=payload.session_id,
)
if int(session["total_chunks"]) != payload.total_chunks:
if int(upload_session["total_chunks"]) != payload.total_chunks:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."},
@@ -509,8 +510,8 @@ async def finalize_project_upload(
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
descriptor = FileUploadDescriptor(
original_filename=str(session["original_filename"]),
size_bytes=int(session["file_size_bytes"]),
original_filename=str(upload_session["original_filename"]),
size_bytes=int(upload_session["file_size_bytes"]),
)
final_path = merge_upload_chunks(
project_root,
@@ -571,7 +572,7 @@ async def finalize_project_upload(
)
return FileUploadResponse(project_id=str(project_id), files=[result])
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
if final_path is not None:
final_path.unlink(missing_ok=True)
@@ -595,7 +596,7 @@ async def get_project_upload_status(
pool = get_db_pool()
try:
async with pool.acquire() as connection:
session = await get_upload_session(
upload_session = await get_upload_session(
connection,
project_id=project_id,
session_id=session_id,
@@ -606,16 +607,16 @@ async def get_project_upload_status(
)
return UploadStatusResponse(
upload_session_id=session_id,
upload_status=str(session["status"]),
original_filename=str(session["original_filename"]),
file_size_bytes=int(session["file_size_bytes"]),
chunk_size_bytes=int(session["chunk_size_bytes"]),
total_chunks=int(session["total_chunks"]),
upload_status=str(upload_session["status"]),
original_filename=str(upload_session["original_filename"]),
file_size_bytes=int(upload_session["file_size_bytes"]),
chunk_size_bytes=int(upload_session["chunk_size_bytes"]),
total_chunks=int(upload_session["total_chunks"]),
completed_chunks=len(completed_indexes),
completed_chunk_indexes=completed_indexes,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -0,0 +1,31 @@
"""B03 라우터 공통 오류 응답 — 조회 실패와 코드 버그를 갈라 준다.
`LookupError`를 통째로 404로 돌리면 `KeyError`·`IndexError` 같은 **코드 버그까지 404로
조용히 덮인다**. 실제로 업로드 finalize에서 `KeyError('role')`이 404 `{"message": "'role'"}`
로 나가 로그도 안 남고, 마지막 파일 업로드가 매번 실패로 보이던 사고가 있었다
(2026-08-08 E2E 점검). 조회 실패(레코드 없음)만 404로 두고 버그는 500 + 로그로 보낸다.
"""
import logging
from typing import Any
from fastapi.responses import JSONResponse
def lookup_error_response(
exc: LookupError,
logger: logging.Logger,
*,
context: str,
fallback_message: str = "요청을 처리하지 못했습니다.",
**log_fields: Any,
) -> JSONResponse:
"""조회 실패면 404, 코드 버그(`KeyError`·`IndexError`)면 500 + 예외 로그."""
if isinstance(exc, (KeyError, IndexError)):
detail = " ".join(f"{key}=%s" for key in log_fields)
logger.exception(f"{context} 처리 중 내부 오류 {detail}".strip(), *log_fields.values())
return JSONResponse(
status_code=500,
content={"status": "error", "message": fallback_message},
)
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
+9 -8
View File
@@ -50,6 +50,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
mark_temp_batch_linked,
upsert_temp_batch_file,
)
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
from B03_FileInput.B03_FileInput_Schema import (
ChunkSessionCreateRequest,
ChunkSessionCreateResponse,
@@ -232,7 +233,7 @@ async def remove_batch(
shutil.rmtree(batch_root, ignore_errors=True)
return JSONResponse(content={"status": "success", "batch_id": batch_id})
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except Exception:
logger.exception("임시 보관함 삭제 실패: batch_id=%s", batch_id)
return JSONResponse(
@@ -279,7 +280,7 @@ async def remove_batch_file(
}
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except OSError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -343,7 +344,7 @@ async def upload_batch_files(
batch_id=batch_id, files=results, required_complete=required_complete
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -391,7 +392,7 @@ async def create_batch_upload_session(
total_chunks=total_chunks,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -447,7 +448,7 @@ async def upload_batch_chunk(
chunk_hash=chunk_hash,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
@@ -489,7 +490,7 @@ async def get_batch_upload_status(
completed_chunk_indexes=completed_indexes,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except Exception:
logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id)
return JSONResponse(
@@ -576,7 +577,7 @@ async def finalize_batch_upload(
required_complete=required_complete,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id)
except (OSError, ValueError) as exc:
if final_path is not None:
final_path.unlink(missing_ok=True)
@@ -703,7 +704,7 @@ async def attach_temp_batch(
analysis_started=analysis_started,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
return lookup_error_response(exc, logger, context="B03 보관함", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
+14 -2
View File
@@ -6,6 +6,9 @@ from typing import Any, Dict
import aiomysql
# `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다.
_STAGE_MESSAGE_MAX_LENGTH = 200
STAGE_KEYS = [
"FILE_INPUT", # 0 (B03)
"PREPROCESS", # 1 (B04)
@@ -131,8 +134,17 @@ async def update_stage_progress(
async def fail_stage(
cursor: aiomysql.DictCursor, project_id: str, stage_no: int, message: str
) -> None:
"""단계를 실패 상태로 전환한다."""
"""단계를 실패 상태로 전환한다.
`message` 컬럼 길이에 맞춰 잘라 넣는다. 예외 문자열을 그대로 넣었다가 실패 기록
자체가 `DataError (1406, "Data too long for column 'message'")` 죽어, 단계가 FAILED로
가지 못하고 화면이 영영 "분석 중" 머문 사고가 있었다(2026-08-08 E2E 점검).
원문 전체는 호출부가 이미 로그에 남긴다.
"""
now = datetime.utcnow()
trimmed = (message or "").strip()
if len(trimmed) > _STAGE_MESSAGE_MAX_LENGTH:
trimmed = trimmed[: _STAGE_MESSAGE_MAX_LENGTH - 1] + ""
await cursor.execute(
"""
UPDATE project_workflow_stages
@@ -140,7 +152,7 @@ async def fail_stage(
message = %s
WHERE project_id = %s AND stage_no = %s
""",
(message, project_id, stage_no),
(trimmed, project_id, stage_no),
)
# projects.status 캐시 업데이트