fix(observability): count a pool timeout the engine reports without a code

Pool contention raises a typed prisma error carrying `code == "P2024"`, which
the attribute check already handled. When the database itself is unreachable the
engine reports the same P2024 as a raw `EngineRequestError` instead, with no
`code` attribute and the code recorded only in the JSON body it was built from,
so those went uncounted. Seen on a live proxy at connection limit 1 with
Postgres paused.

Prisma classifies both as P2024, so which layer surfaced it should not decide
whether the counter moves. The match is on prisma's own `error_code` field
rather than the message text, so an error that merely mentions the code cannot
trip it, and a different engine code is rejected.

The two cases stay distinguishable in the metrics that matter: saturation holds
busy at max with waiters queued, while an unreachable database drops open
connections instead.
This commit is contained in:
Yucheng Zhu 2026-08-19 16:16:29 -07:00
parent bd2a9dbdea
commit 5fdd4161d7
2 changed files with 61 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import re
from collections.abc import Awaitable, Callable
from typing import Any, Final, TypeVar
@ -9,6 +10,11 @@ from litellm.proxy._types import (
)
from litellm.secret_managers.main import str_to_bool
# The engine reports a pool timeout as a raw HTTP error whose body carries the
# code, with no `code` attribute to read. Matched on prisma's own JSON field so a
# message that merely mentions the code cannot trip it.
_P2024_IN_ENGINE_BODY: Final = re.compile(r'"error_code"\s*:\s*"P2024"')
# Bounds the __cause__/__context__ walk in is_database_service_unavailable_error_in_chain.
# Real exception chains are a few links deep; the cap also makes the walk cycle-safe.
_MAX_EXCEPTION_CHAIN_DEPTH: Final = 20
@ -137,8 +143,19 @@ class PrismaDBExceptionHandler:
The ``P####`` codes are prisma's own namespace and only prisma populates
``code`` with one, so no type check is needed. Skipping it also keeps
this callable on the DB failure path when prisma itself is stubbed.
Both shapes have to be read. Contention raises a typed prisma error
carrying the code, which the attribute check already handles. When the
database is unreachable the engine reports the same P2024 as a raw HTTP
error instead, with no ``code`` attribute and the code only in the JSON
body. Prisma classifies both as P2024, so which layer surfaced it should
not decide whether it is counted. The neighbouring gauges separate the
two cases: saturation holds ``busy`` at ``max`` with waiters queued,
while an unreachable database drops ``open`` instead.
"""
return getattr(e, "code", None) == "P2024"
if getattr(e, "code", None) == "P2024":
return True
return bool(_P2024_IN_ENGINE_BODY.search(str(e)))
@staticmethod
def is_database_transport_error(e: Exception) -> bool:

View file

@ -623,6 +623,49 @@ def test_only_p2024_counts_as_pool_exhaustion(other_error):
assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(other_error) is False
def test_pool_exhaustion_is_detected_in_the_shape_the_engine_actually_raises():
"""Contention raises a typed prisma error carrying the code, and that path is
covered above. When the database itself is unreachable the engine reports the
same P2024 as a raw HTTP error instead, with no `code` attribute at all and
the code recorded only in the JSON body. Prisma classifies both as P2024, so
the counter should not depend on which layer surfaced it. Body captured
verbatim from a live proxy run at connection limit 1 with Postgres paused."""
from types import SimpleNamespace
from prisma.engine.errors import EngineRequestError
body = (
'{"is_panic":false,"message":"Timed out fetching a new connection from the connection pool. '
'More info: http://pris.ly/d/connection-pool (Current connection pool timeout: 2, connection limit: 1)",'
'"meta":{"connection_limit":1,"timeout":2},"error_code":"P2024"}'
)
error = EngineRequestError(response=SimpleNamespace(status=500), body=body)
assert not hasattr(error, "code"), "the shape under test is the one with no code attribute"
assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(error) is True
def test_another_engine_error_code_is_not_read_as_pool_exhaustion():
"""The body is matched on prisma's own error_code field, so a different
engine failure carrying its own code must not be counted."""
from types import SimpleNamespace
from prisma.engine.errors import EngineRequestError
error = EngineRequestError(
response=SimpleNamespace(status=500),
body='{"is_panic":false,"message":"boom","error_code":"P2010"}',
)
assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(error) is False
def test_a_message_merely_mentioning_the_code_is_not_pool_exhaustion():
"""Matching loose text would let any error whose message quotes P2024 inflate
the exhaustion counter."""
assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(Exception("see P2024 docs")) is False
def test_pool_exhaustion_is_not_mistaken_for_a_reachability_failure():
"""P2024's message contains "Timed out", which the transport classifier
keyword-matches. The pool predicate must not inherit that ambiguity."""