diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index b4e4e37c..17dd68b5 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_Router_Errors.py b/B03_FileInput/B03_FileInput_Router_Errors.py new file mode 100644 index 00000000..a8fad526 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Errors.py @@ -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)}) diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index 3ed8adc8..5b1f7930 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -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: diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index b6d85b21..864eac59 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -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 캐시 업데이트