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 import __version__
|
||||||
from kf_oj.api.availability import AvailabilityMiddleware
|
from kf_oj.api.availability import AvailabilityMiddleware
|
||||||
from kf_oj.api.body_limits import BodyLimitMiddleware
|
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_imports import router as import_router
|
||||||
from kf_oj.api.v1.admin_problems import router as maintenance_router
|
from kf_oj.api.v1.admin_problems import router as maintenance_router
|
||||||
from kf_oj.api.v1.identity import router as identity_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(_, __):
|
async def invalid_request(_, __):
|
||||||
return JSONResponse({"detail": "invalid request"}, status_code=422)
|
return JSONResponse({"detail": "invalid request"}, status_code=422)
|
||||||
|
|
||||||
|
install_handlers(app)
|
||||||
|
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router, prefix="/api/v1")
|
||||||
app.include_router(identity_router, prefix="/api/v1")
|
app.include_router(identity_router, prefix="/api/v1")
|
||||||
app.include_router(problems_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,
|
max_overflow=0,
|
||||||
pool_timeout=5,
|
pool_timeout=5,
|
||||||
connect_args={
|
connect_args={
|
||||||
|
"timeout": 5,
|
||||||
"command_timeout": 10,
|
"command_timeout": 10,
|
||||||
"server_settings": {
|
"server_settings": {
|
||||||
"statement_timeout": "10000",
|
"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