fix(api): bound transient dependency failure responses

This commit is contained in:
2026-10-13 12:01:30 +08:00
parent 70f4c78905
commit f5f99c9cae
4 changed files with 104 additions and 0 deletions
+3
View File
@@ -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")
@@ -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)
+1
View File
@@ -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",
+58
View File
@@ -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