diff --git a/backend/src/kf_oj/api/app.py b/backend/src/kf_oj/api/app.py index c1da944..3578162 100644 --- a/backend/src/kf_oj/api/app.py +++ b/backend/src/kf_oj/api/app.py @@ -8,6 +8,7 @@ from starlette.responses import JSONResponse from kf_oj import __version__ from kf_oj.api.availability import AvailabilityMiddleware from kf_oj.api.body_limits import BodyLimitMiddleware +from kf_oj.api.dependency_errors import install_handlers from kf_oj.api.v1.admin_imports import router as import_router from kf_oj.api.v1.admin_problems import router as maintenance_router from kf_oj.api.v1.identity import router as identity_router @@ -148,6 +149,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: async def invalid_request(_, __): return JSONResponse({"detail": "invalid request"}, status_code=422) + install_handlers(app) + app.include_router(router, prefix="/api/v1") app.include_router(identity_router, prefix="/api/v1") app.include_router(problems_router, prefix="/api/v1") diff --git a/backend/src/kf_oj/api/dependency_errors.py b/backend/src/kf_oj/api/dependency_errors.py new file mode 100644 index 0000000..96abd09 --- /dev/null +++ b/backend/src/kf_oj/api/dependency_errors.py @@ -0,0 +1,42 @@ +"""Bounded, non-secret API responses for transient dependency/capacity failures.""" + +import errno + +from asyncpg import CannotConnectNowError, PostgresConnectionError +from fastapi import FastAPI +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError +from starlette.responses import JSONResponse + + +async def temporarily_unavailable(_, __): + # Write outcomes may be uncertain. Existing operation/submission UUIDs, not + # automatic server retries, own recovery from a lost response. + return JSONResponse( + {"detail": "service temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "5"}, + ) + + +async def filesystem_failure(_, error: OSError): + if error.errno in {errno.ENOSPC, errno.EDQUOT, errno.EMFILE, errno.ENFILE}: + return await temporarily_unavailable(_, error) + # Unrelated filesystem/programming failures retain server-error semantics. + # Never serialize path/source/query/credential text from an exception. + return JSONResponse( + {"detail": "internal server error"}, status_code=500, headers={"Cache-Control": "no-store"} + ) + + +def install_handlers(app: FastAPI) -> None: + for error in ( + ConnectionError, + TimeoutError, + OperationalError, + PoolTimeoutError, + PostgresConnectionError, + CannotConnectNowError, + ): + app.add_exception_handler(error, temporarily_unavailable) + app.add_exception_handler(OSError, filesystem_failure) diff --git a/backend/src/kf_oj/infra/db/session.py b/backend/src/kf_oj/infra/db/session.py index ade3488..d037ffb 100644 --- a/backend/src/kf_oj/infra/db/session.py +++ b/backend/src/kf_oj/infra/db/session.py @@ -13,6 +13,7 @@ def build_engine(settings: Settings) -> AsyncEngine: max_overflow=0, pool_timeout=5, connect_args={ + "timeout": 5, "command_timeout": 10, "server_settings": { "statement_timeout": "10000", diff --git a/backend/tests/test_dependency_errors.py b/backend/tests/test_dependency_errors.py new file mode 100644 index 0000000..c896a1e --- /dev/null +++ b/backend/tests/test_dependency_errors.py @@ -0,0 +1,58 @@ +"""Ordinary exception-boundary checks; real dependency failure is VM-only.""" + +import asyncio +import errno + +import httpx +import pytest +from asyncpg import CannotConnectNowError +from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy.exc import TimeoutError as PoolTimeoutError + +from kf_oj.api.app import create_app +from kf_oj.core.settings import Settings + + +@pytest.mark.parametrize( + "error,expected", + [ + (ConnectionRefusedError("private_connection_text"), 503), + (TimeoutError("private_timeout_text"), 503), + (CannotConnectNowError("private_database_text"), 503), + (PoolTimeoutError("private_pool_text"), 503), + ( + OperationalError( + "private_query", {"secret": "private_value"}, RuntimeError("private_driver") + ), + 503, + ), + (OSError(errno.ENOSPC, "private_source_path"), 503), + (OSError(errno.EDQUOT, "private_quota_path"), 503), + (FileNotFoundError(errno.ENOENT, "private_missing_path"), 500), + ], +) +def test_dependency_boundary_preserves_status_without_exception_text(error, expected): + app = create_app(Settings(_env_file=None, environment="test")) + + @app.get("/api/v1/auth/boundary-test") + async def fail(): + raise error + + async def request(): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/v1/auth/boundary-test") + assert response.status_code == expected + assert "private_" not in response.text + assert response.headers["cache-control"] == "no-store" + if expected == 503: + assert response.headers["retry-after"] == "5" + + asyncio.run(request()) + + +def test_programming_errors_are_not_classified_as_transient_dependency_failures(): + app = create_app(Settings(_env_file=None, environment="test")) + assert ProgrammingError not in app.exception_handlers + assert ValueError not in app.exception_handlers