The File That Never Says Goodbye
TL;DR: AsyncConnection has close() method but no __aenter__() - it's a context manager that only knows goodbye
The Code
1from typing import Any
2
3from .base import ProxyComparable, StartableContext
4
5def create_async_engine(*arg, **kw) -> AsyncEngine: ...
6
7class AsyncConnectable: ...
8
9class AsyncConnection(ProxyComparable, StartableContext, AsyncConnectable):
10 engine: Any
11 sync_engine: Any
12 sync_connection: Any
13 def __init__(self, async_engine, sync_connection: Any | None = ...) -> None: ...
14 async def start(self, is_ctxmanager: bool = ...): ...
15 @property
16 def connection(self) -> None: ...
17 async def get_raw_connection(self): ...
18 @property
19 def info(self): ...
20 def begin(self): ...
21 def begin_nested(self): ...
22 async def invalidate(self, exception: Any | None = ...): ...
23 async def get_isolation_level(self): ...
24 async def set_isolation_level(self): ...
25 def in_transaction(self): ...
26 def in_nested_transaction(self): ...
27 def get_transaction(self): ...
28 def get_nested_transaction(self): ...
29 async def execution_options(self, **opt): ...
30 async def commit(self) -> None: ...
31 async def rollback(self) -> None: ...
32 async def close(self) -> None: ...
33 async def exec_driver_sql(self, statement, parameters: Any | None = ..., execution_options=...): ...
34 async def stream(self, statement, parameters: Any | None = ..., execution_options=...): ...
35 async def execute(self, statement, parameters: Any | None = ..., execution_options=...): ...
36 async def scalar(self, statement, parameters: Any | None = ..., execution_options=...): ...
37 async def scalars(self, statement, parameters: Any | None = ..., execution_options=...): ...
38 async def stream_scalars(self, statement, parameters: Any | None = ..., execution_options=...): ...
39 async def run_sync(self, fn, *arg, **kw): ...
40 def __await__(self): ...
41 async def __aexit__(self, type_, value, traceback) -> None: ...
42 # proxied from Connection
43 dialect: Any
44 @property
45 def closed(self): ...
46 @property
47 def invalidated(self): ...
48 @property
49 def default_isolation_level(self): ...
50
51class AsyncEngine(ProxyComparable, AsyncConnectable):
52 class _trans_ctx(StartableContext):
53 conn: Any
54 def __init__(self, conn) -> None: ...
55 transaction: Any
56 async def start(self, is_ctxmanager: bool = ...): ...
57 async def __aexit__(self, type_, value, traceback) -> None: ...
58 sync_engine: Any
59 def __init__(self, sync_engine) -> None: ...
60 def begin(self): ...
61 def connect(self): ...
62 async def raw_connection(self): ...
63 def execution_options(self, **opt): ...
64 async def dispose(self): ...
65 # proxied from Engine
66 url: Any
67 pool: Any
68 dialect: Any
69 echo: Any
70 @property
71 def engine(self): ...
72 @property
73 def name(self): ...
74 @property
75 def driver(self): ...
76 def clear_compiled_cache(self) -> None: ...
77 def update_execution_options(self, **opt) -> None: ...
78 def get_execution_options(self): ...
79
80class AsyncTransaction(ProxyComparable, StartableContext):
81 connection: Any
82 sync_transaction: Any
83 nested: Any
84 def __init__(self, connection, nested: bool = ...) -> None: ...
85 @property
86 def is_valid(self): ...
87 @property
88 def is_active(self): ...
89 async def close(self) -> None: ...
90 async def rollback(self) -> None: ...
91 async def commit(self) -> None: ...
92 async def start(self, is_ctxmanager: bool = ...): ...
93 async def __aexit__(self, type_, value, traceback) -> None: ...
94The Prayer 🤞🤞
🤞🤞 Maybe if I just call close() manually everywhere, I won't leak database connections like a sieve. Surely I'll remember to close every single connection in every code path, including the ones where exceptions happen. What could possibly go wrong with manual resource management in async code?
The Reality Check
This AsyncConnection class implements aexit() for context manager cleanup but appears to be missing aenter(). This creates a half-baked context manager that will crash when you try to use it with 'async with'. Even worse, without proper context manager support, developers are forced to manually manage connection lifecycles, which is exactly the kind of error-prone drudgery that context managers were invented to eliminate.
In production, this leads to connection leaks faster than you can say "connection pool exhausted." Every forgotten close() call is a database connection that lives forever, slowly strangling your application. Your monitoring will show mysterious connection pool timeouts, and your database will eventually refuse new connections entirely. The async nature makes it even trickier to track down which code paths aren't properly cleaning up, especially when exceptions interrupt the normal flow.
The Fix
The fix is straightforward: implement aenter() to complete the context manager protocol. The connection needs to return itself when entering the context:
1class AsyncConnection(ProxyComparable, StartableContext, AsyncConnectable):
2 # ... existing code ...
3
4 async def __aenter__(self) -> 'AsyncConnection':
5 await self.start(is_ctxmanager=True)
6 return self
7
8 async def __aexit__(self, type_, value, traceback) -> None:
9 await self.close()
10With this fix, you can use the connection safely with async context managers:
1# Now this works without exploding
2async with AsyncConnection(engine) as conn:
3 result = await conn.execute("SELECT * FROM users")
4 # Connection automatically closed, even if exceptions occur
5For extra safety, consider adding a warning or error if someone tries to use the connection without the context manager, helping catch manual resource management attempts before they become production leaks.
Lesson Learned
Context managers are only as good as their enter and exit methods - implement both or implement neither.