fix(api): bound transient dependency failure responses
This commit is contained in:
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user