fix(proxy): write the Prisma engine through the public setter, not the mangled name

`_write_engine` assigned directly to `prisma_client._Prisma__engine`, the
name Prisma <0.13 used internally for the engine attribute. Prisma 0.13+
is a `__slots__` class that no longer has that attribute at all, only a
public `_engine` property/setter backed by `_internal_engine`. Writing to
the mangled name on a modern client raises `AttributeError`, which
`connect()`'s `backoff` decorator retries indefinitely, spinning CPU and
respawning Prisma engine subprocesses without ever connecting.

Use the public `_engine` setter instead, which exists on both the legacy
and current Prisma internal layouts.

Reproduced locally, prisma==0.15.0: router at 196-293% CPU with a
continuous `AttributeError: 'Prisma' object has no attribute
'_Prisma__engine'` retry loop; 0% after the fix, connects immediately.
This commit is contained in:
Dominic Fallows 2026-09-01 15:22:28 +01:00
parent ec3f8183c3
commit 6a5632c377
3 changed files with 35 additions and 7 deletions

View file

@ -53,10 +53,7 @@ class _PrismaEngine(Protocol):
class _PrismaClient(Protocol):
_Prisma__engine: _PrismaEngine
@property
def _engine(self) -> _PrismaEngine: ...
_engine: _PrismaEngine
class _PrismaDrainTracker:
@ -240,7 +237,9 @@ class PrismaWrapper:
@staticmethod
def _write_engine(prisma_client: _PrismaClient, engine: _PrismaEngine) -> None:
prisma_client._Prisma__engine = engine
# Public setter: prisma <0.13 stored the engine on the mangled `Prisma.__engine`,
# 0.13+ on `BasePrisma._internal_engine`. Both are __slots__ classes.
prisma_client._engine = engine # rebind-ok: third-party Prisma client instance, not a local param we own
def _instrument_prisma_client(self, prisma_client: _PrismaClient) -> _PrismaDrainTracker | None:
from prisma.errors import ClientNotConnectedError

View file

@ -171,6 +171,35 @@ def test_get_engine_pid_returns_zero_for_disconnected_client(disconnected_prisma
assert wrapper._get_engine_pid() == 0
def test_write_engine_uses_the_public_setter_not_the_mangled_name():
"""Real Prisma clients (>=0.13) are __slots__ classes exposing only a public
`_engine` property/setter, with no `_Prisma__engine` slot. Writing to the
mangled name raises AttributeError instead of storing the engine."""
class _FakeSlotsBasedPrismaClient:
__slots__ = ("_internal_engine",)
def __init__(self):
self._internal_engine = None
@property
def _engine(self):
if self._internal_engine is None:
raise AttributeError("not connected")
return self._internal_engine
@_engine.setter
def _engine(self, engine):
self._internal_engine = engine
client = _FakeSlotsBasedPrismaClient()
sentinel = object()
PrismaWrapper._write_engine(client, sentinel)
assert PrismaWrapper._read_engine(client) is sentinel
@pytest.mark.asyncio
async def test_recreate_prisma_client_recovers_from_disconnected_client(
mock_prisma_binary, disconnected_prisma

View file

@ -60,7 +60,7 @@ def _make_wrapper(engine_pid: int = 111, iam: bool = False) -> PrismaWrapper:
prisma = GeneratedPrisma(use_dotenv=False)
engine = MagicMock()
engine.process.pid = engine_pid
setattr(prisma, "_Prisma__engine", engine)
prisma._engine = engine
return PrismaWrapper(original_prisma=prisma, iam_token_db_auth=iam)
@ -158,7 +158,7 @@ def _token_db_url(created: datetime, expires_in: int = 900) -> str:
def test_wrapper_instruments_generated_prisma_engine() -> None:
prisma = GeneratedPrisma(use_dotenv=False)
engine = MagicMock()
setattr(prisma, "_Prisma__engine", engine)
prisma._engine = engine
wrapper = PrismaWrapper(original_prisma=prisma, iam_token_db_auth=False)