Skip to content

Commit 6758175

Browse files
authored
feat(aurora): invalidate connection on readonly transaction error - DIA-51197 (#79)
1 parent cbb5628 commit 6758175

4 files changed

Lines changed: 106 additions & 2 deletions

File tree

fastapi_sqla/asyncio_support.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from sqlalchemy.orm.session import sessionmaker
1313
from sqlalchemy.sql import Select, func, select
1414

15-
from fastapi_sqla.sqla import Base, Page, T, aws_rds_iam_support, new_engine
15+
from fastapi_sqla import aws_aurora_support, aws_rds_iam_support
16+
from fastapi_sqla.sqla import Base, Page, T, new_engine
1617

1718
logger = structlog.get_logger(__name__)
1819
_ASYNC_SESSION_KEY = "fastapi_sqla_async_session"
@@ -31,6 +32,7 @@ def new_async_engine():
3132
async def startup():
3233
engine = new_async_engine()
3334
aws_rds_iam_support.setup(engine.sync_engine)
35+
aws_aurora_support.setup(engine.sync_engine)
3436

3537
# Fail early:
3638
try:

fastapi_sqla/aws_aurora_support.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pydantic import BaseSettings
2+
from sqlalchemy import event
3+
from sqlalchemy.engine import Engine
4+
from sqlalchemy.engine.interfaces import ExceptionContext
5+
6+
# Taken from
7+
# https://www.postgresql.org/docs/current/errcodes-appendix.html#ERRCODES-TABLE
8+
READONLY_ERROR_CODE = "25006"
9+
10+
11+
def setup(engine: Engine):
12+
config = Config()
13+
14+
if not config.aws_aurora_enabled:
15+
return
16+
17+
event.listen(engine, "handle_error", disconnect_on_readonly_error)
18+
19+
20+
def disconnect_on_readonly_error(context: ExceptionContext):
21+
if context.is_disconnect:
22+
return
23+
24+
error_code = getattr(context.original_exception, "pgcode", None)
25+
if error_code == READONLY_ERROR_CODE:
26+
context.is_disconnect = True # type: ignore
27+
28+
29+
class Config(BaseSettings):
30+
aws_aurora_enabled: bool = False
31+
32+
class Config:
33+
env_prefix = "fastapi_sqla_"

fastapi_sqla/sqla.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from sqlalchemy.orm.session import sessionmaker
2121
from sqlalchemy.sql import Select, func, select
2222

23-
from fastapi_sqla import aws_rds_iam_support
23+
from fastapi_sqla import aws_aurora_support, aws_rds_iam_support
2424

2525
try:
2626
from sqlalchemy.orm import declarative_base
@@ -50,6 +50,7 @@ def is_async_dialect(engine):
5050
def startup():
5151
engine = new_engine()
5252
aws_rds_iam_support.setup(engine.engine)
53+
aws_aurora_support.setup(engine.engine)
5354

5455
# Fail early:
5556
try:

tests/test_aws_aurora_support.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from unittest.mock import Mock
2+
3+
from pytest import mark, raises
4+
from sqlalchemy import text
5+
6+
7+
@mark.sqlalchemy("1.4")
8+
@mark.dont_patch_engines
9+
def test_sync_disconnects_on_readonly_error(monkeypatch):
10+
from fastapi_sqla.sqla import _Session, startup
11+
12+
monkeypatch.setenv("fastapi_sqla_aws_aurora_enabled", "true")
13+
14+
startup()
15+
16+
connection = _Session().connection(execution_options={"postgresql_readonly": True})
17+
with raises(Exception):
18+
connection.execute(text("CREATE TABLE fail(id integer)"))
19+
20+
assert connection.invalidated
21+
22+
23+
@mark.sqlalchemy("1.4")
24+
@mark.require_asyncpg
25+
@mark.dont_patch_engines
26+
async def test_async_disconnects_on_readonly_error(monkeypatch, async_sqlalchemy_url):
27+
from fastapi_sqla.asyncio_support import _AsyncSession, startup
28+
29+
monkeypatch.setenv("fastapi_sqla_aws_aurora_enabled", "true")
30+
monkeypatch.setenv("async_sqlalchemy_url", async_sqlalchemy_url)
31+
32+
await startup()
33+
34+
connection = await _AsyncSession().connection(
35+
execution_options={"postgresql_readonly": True}
36+
)
37+
with raises(Exception):
38+
await connection.execute(text("CREATE TABLE fail(id integer)"))
39+
40+
assert connection.invalidated
41+
42+
43+
@mark.parametrize(
44+
"pgcode, should_disconnect", [("25006", True), ("00000", False), (None, False)]
45+
)
46+
def test_readonly_error_codes(pgcode, should_disconnect):
47+
from fastapi_sqla.aws_aurora_support import disconnect_on_readonly_error
48+
49+
exception = Mock()
50+
exception.pgcode = pgcode
51+
context = Mock()
52+
context.original_exception = exception
53+
context.is_disconnect = False
54+
55+
disconnect_on_readonly_error(context)
56+
57+
assert context.is_disconnect == should_disconnect
58+
59+
60+
def test_already_disconnected():
61+
from fastapi_sqla.aws_aurora_support import disconnect_on_readonly_error
62+
63+
context = Mock()
64+
context.is_disconnect = True
65+
66+
disconnect_on_readonly_error(context)
67+
68+
assert context.is_disconnect is True

0 commit comments

Comments
 (0)